我们将在上一章案例的基础之上学习自定义 hook。
在上一章中,我们巧妙的把大量的 JSX 逻辑处理封装在了 List 组件中,使得在页面组件的代码变得非常简单。这是针对 UI 层的逻辑处理,那么在数据的处理上,是否也能够进行一些封装呢?
// 数据的主要核心逻辑
const str = useRef('')
const [list, setList] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
function getList() {
searchApi(str.current).then(res => {
setList(res)
setLoading(false)
setError('')
}).catch(err => {
setLoading(false)
setError(err)
})
}
useEffect(() => {
loading && getList()
}, [loading])
function onSure() {
setLoading(true)
}
答案是肯定的,解决方案就是我们将要在本章中学习的自定义 hook。
一、自定义hook
我们常常会封装一个函数用于逻辑的复用。自定义 hook 也是这样的一个在 react 组件内部用于逻辑复用的函数封装。
和普通函数封装相比,他唯一的特殊之处就在于我们常常会将 react 内置 hook 封装在逻辑之中,比如 useState,useEffect 等。除此之外,为了区分与普通的函数封装,我们必须以 use 开头为自定义 hook 命名,这样的 hook 只能在 React 组件中使用。
以上一章中的数据处理逻辑为例,我们来封装一个自定义 hook,将其命名为 useFetch。
function useFetch() {}
我们先考虑单个场景的封装,单纯只是为了让组件看上去更简洁。
我们就可以把所有的数据和处理数据的逻辑封装起来。
import {useEffect, useState, useRef} from 'react'
import { searchApi } from './api'
export default function useFetch() {
const str = useRef('')
const [list, setList] = useState([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
function getList() {
searchApi(str.current).then(res => {
setList(res)
setLoading(false)
setError('')
}).catch(err => {
setLoading(false)
setError(err)
})
}
useEffect(() => {
loading && getList()
}, [loading])
return { str, list, error, loading, setLoading }
}
封装过程非常简单,就是把之前那一堆逻辑全部迁移过来,最后返回应用组件里需要的数据和方法即可。
return { str, list, error, loading, setLoading }
OK,此时我们来观察一下组件里的代码。
export default function DemoOneNormal() {
const {loading, setLoading, str, list, error} = useFetch()
return (
str.current = e.target.value}
/>
setLoading(true)}
>
搜索
(
{item}
)}
/>
)
}
逻辑简洁了许多。变成了简单的同步代码:通过一个方法获取数据,并将数据渲染到 UI 组件。
Block 组件是单独封装的布局组件,希望不要因此造成任何理解上的困难。
一个组件变成了数据与UI的结合。我们分别将复杂的数据处理逻辑封装在 hook 里,将复杂的UI交互逻辑封装在基础 UI 组件里,在使用时,利用他们的封装结果进行组合,能够简单,高效的组合出复杂的页面,这也是我们在实践中最大的追求
这里有些人可能会有一些疑问,我只是把一些逻辑放在了另外的地方,代码量最终不仅没有减少,反而还变多了,这样做的好处真的有那么大吗?当然,因为我们封装的 useFetch 和 List 组件,他们承载了大多数的复杂逻辑,并且只会在最开始的时候编写一次,在以后的使用中,就直接引入使用就行了,这极大的简化了后续的开发工作量,对工作效率的提高非常显著
二、进一步思考
此时的封装虽然足够简洁。但是没有考虑复用。因此还需要进一步思考改进。
我们来分析一下场景:每一个需要信息展示的页面,基本逻辑都是在初始化时,请求接口,获得数据,然后展示信息。我们可以把不同情况的接口请求抽象成为一个接口,然后基于这个场景来思考不同页面的请求的共性与差异。
每个页面都要处理信息展示、异常等逻辑,差异的地方就在于获取数据的 api 函数不一样,他返回的数据内容,数据类型也不一样。
不一样的东西作为参数传入,那我们只需要将 api 函数作为参数传入即可。
const info = useFetch(searchApi)
不过我们此时还需要考虑的是,为了确保自定义 hook 的返回类型具备完整准确的类型推导,我们还需要约定传入 api 的参数类型与返回类型。
因此,在定义 useFetch 时,我们先用 ts 约定 api 的具体类型,因为参数类型和返回值类型在封装时都不确定,只能在具体的实参传入之后才能明确,因此使用两个泛型来分别表示参数类型和返回值类型。
type API
= (param?: P) => Promise
正常代码不会这样换行,之所以这样只是为了在移动端能够更多的展示代码信息而不用滚动查看。
然后在定义 useFetch 时传入这两个泛型即可,完整代码如下:
import { useEffect, useState, useRef } from 'react'
type API = (param?: P) => Promise
export default function useFetch(api: API) {
const param = useRef()
const [list, setList] = useState()
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
function getList() {
api(param.current).then(res => {
setList(res)
setLoading(false)
setError('')
}).catch(err => {
setLoading(false)
setError(err)
})
}
useEffect(() => {
loading && getList()
}, [loading])
return {
param,
setParam: (p: P) => param.current = p,
list,
error,
loading,
setLoading
}
}
因为在使用时,传入的 api 函数已经具备了完善的类型,因此我们这种写法可以借助 ts 内部的自动推导而简化使用时在 ts 上的繁琐。
const {
loading,
setLoading,
setParam,
list,
error
} = useFetch(searchApi)
虽然在使用层面没有任何 ts 的痕迹,但是返回值的类型已经非常明确。
由于在封装过程中我们没有处理默认值的情况,因此返回类型可能为 undefined,这在实践中一定要引起重视。你可以根据实际情况往 useFetch 传入默认值,也可以在使用层面初始化默认值
const {
loading,
setLoading,
setParam,
list = [],
error
} = useFetch(searchApi)
这样,一个通用,高效,且具备准确类型提示的 hook 就被我们封装好了。
在实践过程中,由于不同的团队有不同的需求,你还需要根据自己的需求和项目实际情况做相应的细节调整,切记不要完整套用。
三、取舍
由于面试的影响,让不少前端同行错误的把性能当成了实践中最重要的标准。但其实工作中性能并不是最高的优先级。我们往往会在可接受的范围之内,牺牲性能换取其他的便利。
例如,多一层函数封装,其实也就意味着执行压力多那么一点点。但是他可能换来的是开发效率的极大提高。
因此,在我们的课程案例决策当中,提供的方案并不会把性能当做第一准则,代码的可读性、可维护性、开发效率的优先级都会比性能更高。只要我们在写代码的过程中,非常明确的知道这种方式我们舍弃了什么,得到了什么,你权衡之后,愿意做出这样的取舍,那么这样的方式就是可以使用的。
当然,性能依然非常重要,如果你的页面出现了卡顿,我们就应该思考一下,是不是对性能的牺牲有点过了头。