旧文重读:那些年,我们一起追过的 useEffect

最近重新回看了自己在 2023 年初发表在掘金的技术文章,剖析 React 的 useEffect 文档背后那些不易察觉的坑点。那时我还习惯沿着异常现象一路追溯到源码、再推演出解决方案,相信「理解」本身就是一种力量。

两年过去,AI 的普及改变了一切。问题的解决路径从「思考-查阅-推理-验证」变成了「提问—生成—对比—再提问」。我开始怀疑技术文章的意义——当答案被即时生成,我们是否还需要这些记录和分享?

可当我重读那篇旧文,却又感受到一种被唤醒的熟悉感。那些耐心拆解、抽丝剥茧的片段质问我:思考的过程,还是我们与工具的分界线吗?。

原文如下:


前言

如果你是一个入行不久的前端开发,面试中多半会遇到一个问题: 你认为使用React要注意些什么?

这个问题意在考察你对React的使用深度,因为沉浸式地写过一个项目就会发现,不同于一些替你做决定的框架,“潜规则”丰富的React远比看上去要难相处。

React中主要有两类坑点,一种是让你措手不及,结果对不上预期,严重影响开发进度,另一种更为头痛,表面风平浪静,水下暗流涌动。

官方文档的触角只伸到Demo级别,并不涉及花样百出的最差实践,所以下一批开发者又会掉入相同的陷阱。隐藏的坑点需要开发者亲自下地扫雷,经验主义发挥了重要作用,尤其是在Hooks使用中。

为了避免更多的心智负担,这个系列的文章会介绍一些React使用的常见陷阱,带你追溯原因和探索解决方案,帮助新手迅速跳过坑点。

异常现象

1const Issue = function () { 2 const [count, setCount] = useState(0); 3 const [person, setPerson] = useState({ name: 'Alice', age: 15 }); 4 const [array, setArray] = useState([1, 2, 3]); 5 6 useEffect(() => { 7 console.log('Component re-rendered by count'); 8 }, [count]); 9 10 useEffect(() => { 11 console.log('Component re-rendered by person'); 12 }, [person]); 13 14 useEffect(() => { 15 console.log('Component re-rendered by array'); 16 }, [array]); 17 18 return ( 19 <div> 20 <p>You clicked {count} times</p> 21 <button onClick={() => setCount(1)}>Update Count</button> 22 <button onClick={() => setPerson({ name: 'Bob', age: 30 })}>Update Person</button> 23 <button onClick={() => setArray([1, 2, 3, 4])}>Update Array</button> 24 </div> 25 ); 26};

在这个案例中,初始化了三个状态,和对应的三个副作用函数useEffect,理想状态是状态的值更新时才触发useEffect。
多次点击Update Count更新State,因为更新后的值还是1,所以第一个useEffect执行第一次后不会重复执行,这符合预期。但是重复点击Update Person和Update Array时,却不是这样,尽管值相同,但useEffect每一次都会触发。当useEffect中的副作用计算量较大时,必然会引起性能问题。

原因追溯

为了追溯这个原因,可以首先熟悉一下useEffect的源码:

1function useEffect(create, deps) { 2 const fiber = get(); 3 const { alternate } = fiber; 4 5 if (alternate !== null) { 6 const oldProps = alternate.memoizedProps; 7 const [oldDeps, hasSameDeps] = areHookInputsEqual(deps, alternate.memoizedDeps); 8 9 if (hasSameDeps) { 10 pushEffect(fiber, oldProps, deps); 11 return; 12 } 13 } 14 15 const newEffect = create(); 16 17 pushEffect(fiber, newEffect, deps); 18} 19 20function areHookInputsEqual(nextDeps, prevDeps) { 21 if (prevDeps === null) { 22 return false; 23 } 24 25 for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) { 26 if (Object.is(nextDeps[i], prevDeps[i])) { 27 continue; 28 } 29 30 return false; 31 } 32 33 return true; 34}

在上面的代码中,我们着重关注areHookInputsEqual的实现,这个函数对比了前后两次传入的依赖项,决定了后续副作用函数create()是否会执行。可以明显看到,useEffect对于依赖项执行的是浅比较,即Object.is (arg1, arg2),这可能是出于性能考虑。对于原始类型这没有问题,但对于引用类型(数组、对象、函数等),这意味着即使内部的值保持不变,引用本身也会发生变化,导致 useEffect执行副作用。

方案探索

1.饮鸩止渴

缝缝补补只是为了等一个人替你推倒重盖

最直接的思路是把useEffect的依赖项从引用类型换成基本类型:

1 useEffect(() => { 2 console.log('Component re-rendered by person'); 3 }, [JSON.stringify(person)]); 4 5 useEffect(() => { 6 console.log('Component re-rendered by array'); 7 }, [JSON.stringify(array)]);

表面上可行,实际后患无穷(具体参考JSON.stringify为什么不能用来深拷贝),为了避坑而挖另外的坑,显然不是我们期待的解决方案。

1useEffect(() => { 2 console.log('Component re-rendered by person'); 3}, [person.name, person.age]);

对比之下,这样的写法可以容忍,但是person对象如果增加了其他属性,你要确保自己还记得更新依赖,否则依然是掩盖问题。

2.前置拦截

第二种思路:

在你决定要出手之前,我已经帮你决定了 —— 格林公式引申

我们可以把问题尽可能前置,手动加一层深对比,如何发现引用值没有变化,就不执行状态更新的逻辑,也就不会触发useEffect重复执行。

1<button onClick={() => { 2 const newPerson = { name: 'Bob', age: 18 }; 3 if (!isEqual(newPerson, person)) { 4 setPerson(newPerson)} 5 } 6 } 7>Update person</button>

但这样显然不太优雅,且每一次写setState时心智负担太重,对比逻辑可不可以封装起来。

3.他山之石

实际上自定义的Hooks就是为了解决方法级别的逻辑复用,这里我们利用useRef绑定的值可以跨渲染周期的特点,实现一个自定义的useCompare。

1const useCompare = (value, compare) => { 2 const ref = useRef(null); 3 if (!compare(value, ref.current)) { 4 ref.current = value; 5 } 6 return ref.current; 7} 8

经过ref记录的上一次结果,我们同时拥有了前后两次更新的状态,如果发现值不同,再让ref绑定新的引用类型地址。

1import { isEqual } from 'lodash'; 2 3const comparePerson = useCompare(person, isEqual); 4 5useEffect(() => { 6 console.log('Component re-rendered by comparePerson'); 7}, [comparePerson]); 8 9// 重复执行 10useEffect(() => { 11 console.log('Component re-rendered by person'); 12}, [person]);

需要注意的是,这里使用了lodash的isEqual函数实现深对比,看似省心实际是一个成本极其不稳定的选择,如果对象过于庞大,可能得不偿失,可以传入简化的compare函数,有取舍的比较常变的key值。
而且每次又到单独调用useCompare生成新的对象,这里的逻辑也值得被封装。

4.回归本质

停止曲线救国,直面问题本身。

说了这么多,实际还是useEffect中对比逻辑问题,本着支持拓展但不支持修改的原则,我们需要支持一个新的useEffect支持深度对比。我们将useRef实现的记忆引用传入useEffect的对比逻辑中:

1import { useEffect, useRef } from 'react'; 2import isEqual from 'lodash.isequal'; 3 4const useDeepCompareEffect = (callback, dependencies, compare) => { 5 // 默认的对比函数采用lodash.isEqual, 支持自定义 6 if (!compare) compare = isEqual; 7 const memoizedDependencies = useRef([]); 8 if (!compare (memoizedDependencies.current, dependencies)) { 9 memoizedDependencies.current = dependencies; 10 } 11 useEffect(callback, memoizedDependencies.current); 12}; 13 14export default useDeepCompareEffect; 15 16 17function App({ data }) { 18 useDeepCompareEffect(() => { 19 // 这里的代码只有在 data 发生深层级的改变时才会执行 20 console.log('data 发生了改变', data); 21 }, [data]); 22 23 return <div>Hello World</div>; 24}

考虑到前文提到的复杂对象的深对比隐患,我依然结和个人意志,在useDeepCompareEffect中加了一个可选参数compare函数,把isEqual作为一种默认模式。于是,我们终于有了一劳永逸的方法。

总结

实际上,react-use和a-hooks等第三方库都已经实现了useDeepCompareEffect,也可以发现自定义hooks解决问题将会是目前体系下一种复用性极高的实践。

useDeepCompareEffect - ahooks 3.0

react-use/useDeepCompareEffect.md at master · streamich/react-use

以上是这个系列的第一篇,通过推导结论的过程,能看出我们解决问题的一般思路:


由异常现象出发(依赖项的值不变但重新渲染),到文档和源码追根溯源(useEffect),在语言底层定位原因(基本类型和引用类型的储存方式不同),围绕原因提出治标(方案1)或治本(方案2,3)的方案,优化方案形成更好的工程实践(方案4),总结形成可推广服用的逻辑(ahooks / react-use)。