因为手头上了项目暂时告一段落,也终于有时间可以回过头来总结总结,因为在之前关于 React
的使用也仅仅只局限在小型应用当中,而目前完结的这个项目算是整体基于 React
全家桶搭建而成的项目
当然在开发当中也遇到了不少棘手的问题,也踩了不少的坑,尤其是关于 Hook
的一些使用方面,所以也就打算抽些时间,重新温故一下文档以及参考一些前辈们整理的文章,简单的梳理一下在使用 Hook
过程当中遇到的一些问题和总结
单个 state 与多个 state
useState
的出现,让我们可以使用多个 state
变量来保存 state
,比如
1 | const [width, setWidth] = useState(100) |
但同时我们也可以像 Class
组件的 this.state
一样,将所有的 state
放到一个对象中,这样我们就只需一个 state
变量即可
1 | const [state, setState] = useState({ |
那么问题来了,我们到底应该使用单个的 state
变量还是多个的 state
变量呢?
如果使用单个 state
变量,每次更新 state
时需要合并之前的 state
,因为 useState
返回的 setState
会替换原来的值,这一点和 Class
组件的 this.setState
有所不同,this.setState
会把更新的字段自动合并到 this.state
对象中
1 | const handleMouseMove = e => { |
如果使用多个 state
变量可以让 state
的粒度更细,更易于逻辑的拆分和组合,比如们可以将关联的逻辑提取到自定义 Hook
中
1 | const usePosition = () => { |
这样一来,我们每次在更新 left
的时候 top
也会随之更新,因此把 top
和 left
拆分为两个 state
变量显得有点多余
在使用 state
之前,我们通常需要考虑状态拆分的『粒度』问题,如果粒度过细,代码就会变得比较冗余,如果粒度过粗,代码的可复用性就会降低,那么到底哪些 state
应该合并,哪些 state
又应该拆分呢?
简单来说我们遵循的规律主要有以下两点
- 将完全不相关的
state
拆分为多组state
,比如size
和position
- 如果某些
state
是相互关联的,或者需要一起发生改变,就可以把它们合并为一组state
,比如left
和top
1 | const Box = () => { |
依赖过多的问题
我们在使用 useEffect Hook
时,为了避免每次 render
都去执行它的 callback
,通常会传入第二个参数 dependency array
,也就是我们常说的『依赖数组』,这样一来只有当依赖数组发生变化时,才会执行 useEffect
的回调函数
1 | const Example = ({ id, name }) => { |
在上面的例子中,只有当 id
或 name
发生变化时,才会打印日志,依赖数组中必须包含在 callback
内部用到的所有参与 React
数据流的值,比如 state
、props
以及它们的衍生物,如果有遗漏,可能会埋下隐患
1 | const Example = ({ id, name }) => { |
在 React
中,除了 useEffect
外,接收依赖数组作为参数的 Hook
还有 useMemo
、useCallback
和 useImperativeHandle
,我们在上面也提到过,依赖数组中千万不要遗漏回调函数内部依赖的值,但是如果依赖数组依赖了过多东西,可能导致代码难以维护,比如下面这段代码
1 | const refresh = useCallback(() => { |
光是看到这一堆依赖就令人头大,如果项目中到处都是这样的代码,可想而知维护起来多么痛苦,所以针对于这样的情况,我们就需要重新的思考一下这些 deps
是否真的都需要?看下面这个例子
1 | const Example = ({ id }) => { |
虽然 useEffect
的回调函数依赖了 id
和 refresh
方法,但是我们观察 refresh
方法可以发现,它在首次 render
被创建之后,就永远不会发生改变了,因此把它作为 useEffect
的 deps
是多余的
其次,如果这些依赖真的都是需要的,那么这些逻辑是否应该放到同一个 Hook
中
1 | const Example = ({ id, name, address, status, personA, personB, progress }) => { |
可以看出,在 useEffect
中有两段逻辑,而且这两段逻辑是相互独立的,因此我们可以将这两段逻辑放到不同 useEffect
中
1 | useEffect(() => { |
但是如果逻辑无法继续拆分,但是依赖数组还是依赖了过多东西,该怎么办呢?就比如我们上面的代码
1 | useEffect(() => { |
这段代码中的 useEffect
依赖了七个值,但是我们仔细观察上面的代码,可以发现这些值都是『过滤条件』的一部分,通过这些条件可以过滤页面上的数据,因此我们可以将它们看做一个整体,也就是我们前面讲过的合并 state
1 | const [filters, setFilters] = useState({ |
但是如果 state
不能合并,并且在 callback
内部又使用了 setState
方法的话,在这种情况下,我们就可以考虑使用 setState callback
来减少一些依赖,比如
1 | const useValues = () => { |
上面的代码中,我们必须在 useCallback
的依赖数组中指定 values
,否则我们无法在 callback
中获取到最新的 values
状态,但是通过 setState
回调函数,我们就不用再依赖外部的 values
变量,因此也无需在依赖数组中指定它,就像下面这样
1 | const useValues = () => { |
最后我们还可以通过 ref
来保存可变变量,以前我们只是把 ref
用作保持 DOM
节点引用的工具,可是 useRef Hook
能做的事情远不止如此,我们可以用它来保存一些值的引用,并对它进行读写,举个例子
1 | const useValues = () => { |
但是需要注意的是,我们在使用 ref
的过程当中要特别小心,因为它可以随意赋值,所以一定要控制好修改它的方法,特别是一些底层模块,在封装的时候千万不要直接暴露 ref
,而是提供一些修改它的方法
说了这么多,归根到底都是为了写出更加清晰、易于维护的代码,如果发现依赖数组依赖过多,我们就需要重新审视自己的代码
- 依赖数组依赖的值最好不要超过三个,否则会导致代码会难以维护
- 如果发现依赖数组依赖的值过多,我们应该采取一些方法来减少它
- 去掉不必要的依赖
- 将
Hook
拆分为更小的单元,每个Hook
依赖于各自的依赖数组 - 通过合并相关的
state
,将多个依赖值聚合为一个 - 通过
setState
回调函数获取最新的state
,以减少外部依赖 - 通过
ref
来读取可变变量的值,不过需要注意控制修改它的途径
该不该使用 useMemo
对于这个问题,有的人从来没有思考过,有的人甚至不觉得这是个问题,因为我们在网上经常见到的做法是,那就是不管什么情况,只要用 useMemo
或者 useCallback
简单的『包裹一下』,似乎就能使应用远离性能的问题,但真的是这样吗?有的时候 useMemo
没有任何作用,甚至还会影响应用的性能
为什么这么说呢?首先我们需要知道,useMemo
本身也有开销,useMemo
会『记住』一些值,同时在后续 render
时,将依赖数组中的值取出来和上一次记录的值进行比较,如果不相等才会重新执行回调函数,否则直接返回『记住』的值,这个过程本身就会消耗一定的内存和计算资源,因此过度使用 useMemo
可能会影响程序的性能
所以说要想合理使用 useMemo
,我们需要搞清楚 useMemo
适用的场景
- 有些计算开销很大,我们就需要『记住』它的返回值,避免每次
render
都去重新计算 - 由于值的引用发生变化,导致下游组件重新渲染,我们也需要『记住』这个值
让我们来看个例子
1 | const Example = ({ page, type }) => { |
在上面的例子中,渲染 ExpensiveComponent
的开销很大,所以当 resolvedValue
的引用发生变化时,我们不想重新渲染这个组件,因此使用 useMemo
来避免每次 render
的时候重新计算 resolvedValue
,导致它的引用发生改变,从而使下游组件 re-render
这个担忧是正确的,但是使用 useMemo
之前,我们应该先思考两个问题
- 传递给
useMemo
的函数开销大不大?
在上面的例子中,就是考虑 getResolvedValue
函数的开销大不大,JavaScript
中大多数方法都是优化过的,比如 Array.map
、Array.forEach
等,如果你执行的操作开销不大,那么就不需要记住返回值,否则使用 useMemo
本身的开销就可能超过重新计算这个值的开销,因此对于一些简单的 JavaScript
运算来说,我们不需要使用 useMemo
来记住它的返回值
- 当输入相同时,记忆的值的引用是否会发生改变?
在上面的例子中,就是当 page
和 type
相同时,resolvedValue
的引用是否会发生改变?这里我们就需要考虑 resolvedValue
的类型了,如果 resolvedValue
是一个对象,由于我们项目上使用『函数式编程』,每次函数调用都会产生一个新的引用,但是如果 resolvedValue
是一个原始值(string
,boolean
,null
,undefined
,number
,symbol
),也就不存在『引用』的概念了,每次计算出来的这个值一定是相等的,也就是说 ExpensiveComponent
组件不会被重新渲染
因此,如果 getResolvedValue
的开销不大,并且 resolvedValue
返回一个字符串之类的原始值,那我们完全可以去掉 useMemo
,就像下面这样
1 | const Example = ({ page, type }) => { |
还有一个误区就是对创建函数开销的评估,有的人觉得在 render
中创建函数可能会开销比较大,为了避免函数多次创建,使用了 useMemo
或者 useCallback
,但是对于现代浏览器来说,创建函数的成本微乎其微
因此我们没有必要使用 useMemo
或者 useCallback
去节省这部分性能开销,当然如果是为了保证每次 render
时回调的引用相等,你可以放心使用 useMemo
或者 useCallback
1 | const Example = () => { |
其实通常来说在这里可能会存在一些争议,那就是如果只是想在重新渲染时保持值的引用不变,除了可以使用 useMemo
外,还可以使用 useRef
,那么它们两者之间有什么区别呢?
1 | // 使用 useMemo |
在上面的例子中,我们用 useMemo
来『记住』users
数组,不是因为数组本身的开销大,而是因为 users
的引用在每次 render
时都会发生改变,从而导致子组件 ExpensiveComponent
重新渲染(可能会带来较大开销)
虽然在 React
中 useRef
和 useMemo
的实现有一点差别,但是当 useMemo
的依赖数组为空数组时,它和 useRef
的开销可以说相差无几,useRef
甚至可以直接用 useMemo
来实现,就像下面这样
1 | const useRef = v => { |
因此,使用 useMemo
或者是 useRef
都可以用来保持值的引用一致
我们在编写自定义 Hook
时,返回值一定要保持引用的一致性,因为你无法确定外部要如何使用它的返回值,如果返回值被用做其他 Hook
的依赖,并且每次 re-render
时引用不一致(当值相等的情况),就可能就会产生问题,比如
1 | const useData = () => { |
在上面的例子中,我们通过 useData Hook
获取了 data
,每次 render
时 data
的值没有发生变化,但是引用却不一致,如果把 data
用到 useEffect
的依赖数组中,就可能产生非预期的结果,另外由于引用的不同,也会导致 ExpensiveComponent
组件 re-render
,产生性能问题
如果因为
prop
的值相同而引用不同,从而导致子组件发生re-render
,不一定会造成性能问题,因为Virtual DOM re-render
并不等同于DOM re-render
,但是当子组件特别大时,Virtual DOM
的Diff
开销也很大,因此还是应该尽量避免子组件re-render
所以在使用 useMemo
之前,我们不妨先问自己几个问题
- 要记住的函数开销很大吗?
- 返回的值是原始值吗?
- 记忆的值会被其他
Hook
或者子组件用到吗?
下面我们就来看看 useMemo
的具体使用场景
应该使用 useMemo 的场景
- 保持引用相等
- 对于组件内部用到的
object
、array
、函数等,如果用在了其他Hook
的依赖数组中,或者作为props
传递给了下游组件,应该使用useMemo
- 自定义
Hook
中暴露出来的object
、array
、函数等,都应该使用useMemo
,以确保当值相同时,引用不发生变化 - 使用
Context
时,如果Provider
的value
中定义的值(第一层)发生了变化,即便用了Pure Component
或者React.memo
,仍然会导致子组件re-render
,这种情况下,仍然建议使用useMemo
保持引用的一致性
- 对于组件内部用到的
- 成本很高的计算
- 比如
cloneDeep
一个很大并且层级很深的数据
- 比如
无需使用 useMemo 的场景
- 如果返回的值是原始值
string
、boolean
、null
、undefined
、number
、symbol
(不包括动态声明的Symbol
),一般不需要使用useMemo
- 仅在组件内部用到的
object
、array
、函数等(没有作为props
传递给子组件),且没有用到其他Hook
的依赖数组中,一般不需要使用useMemo
一些好的实践方式
- 若
Hook
类型相同,且依赖数组一致时,应该合并成一个Hook
,否则会产生更多开销
1 | const dataA = useMemo(() => { |
- 参考原生
Hooks
的设计,自定义Hooks
的返回值可以使用Tuple
类型,更易于在外部重命名,但如果返回值的数量超过三个,还是建议返回一个对象
1 | export const useToggle = (defaultVisible: boolean = false) => { |
ref
不要直接暴露给外部使用,而是提供一个修改值的方法在使用
useMemo
或者useCallback
时,确保返回的函数只创建一次,也就是说,函数不会根据依赖数组的变化而二次创建,举个例子
1 | export const useCount = () => { |
在 useCount Hook
中,count
状态的改变会让 useMemo
中的 increase
和 decrease
函数被重新创建,由于闭包特性,如果这两个函数被其他 Hook
用到了,我们应该将这两个函数也添加到相应 Hook
的依赖数组中,否则就会产生问题,比如
1 | const Counter = () => { |
在 useCount
中,increase
会随着 count
的变化而被重新创建,但是 increase
被重新创建之后,useEffect
并不会再次执行,所以 useEffect
中取到的 increase
永远都是首次创建时的 increase
,而首次创建时 count
的值为 0
,因此无论点击多少次,count
的值永远都是 1
既然这样的话,那把 increase
函数放到 useEffect
的依赖数组中不就好了吗?事实上,这会带来更多问题
- 首先
increase
的变化会导致频繁地绑定事件监听,以及解除事件监听 - 其次需求是只在组件
mount
时执行一次useEffect
,但是increase
的变化会导致useEffect
多次执行,不能满足需求
那么我们如何解决这些问题呢?有下面两种方式
- 通过
setState
回调,让函数不依赖外部变量,例如
1 | export const useCount = () => { |
- 通过
ref
来保存可变变量,例如
1 | export const useCount = () => { |
总结
最后的最后,我们再来将上面涉及到的内容整体的回顾一下
- 将完全不相关的
state
拆分为多组state
- 如果某些
state
是相互关联的,或者需要一起发生改变,就可以把它们合并为一组state
- 依赖数组的值最好不要过多,如果发现依赖数组依赖的值过多,我们应该采取一些方法来减少它
- 去掉不必要的依赖
- 将
Hook
拆分为更小的单元,每个Hook
依赖于各自的依赖数组 - 通过合并相关的
state
,将多个依赖值聚合为一个 - 通过
setState
回调函数获取最新的state
,以减少外部依赖 - 通过
ref
来读取可变变量的值,不过需要注意控制修改它的途径
- 应该使用
useMemo
的场景- 保持引用相等
- 成本很高的计算
- 无需使用
useMemo
的场景- 如果返回的值是原始值,一般不需要使用
useMemo
- 仅在组件内部用到的
object
、array
、函数等(没有作为props
传递给子组件),且没有用到其他Hook
的依赖数组中,一般不需要使用useMemo
- 如果返回的值是原始值,一般不需要使用
- 若
Hook
类型相同,且依赖数组一致时,应该合并成一个Hook
- 自定义
Hook
的返回值可以使用Tuple
类型,更易于在外部重命名,如果返回的值过多,则不建议使用 ref
不要直接暴露给外部使用,而是提供一个修改值的方法- 在使用
useMemo
或者useCallback
时,可以借助ref
或者setState callback
,确保返回的函数只创建一次,也就是说,函数不会根据依赖数组的变化而二次创建