现在开发 React 组件基本都是用 hooks 了,hooks 很方便,但一不注意也会遇到闭包陷阱的坑。
相信很多用过 hooks 的人都遇到过这个坑,今天我们来探究下 hooks 闭包陷阱的原因和怎么解决吧。
首先这样一段代码,大家觉得有问题没:
import { useEffect, useState } from 'react';
function Dong() {
const [count,setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(count + 1);
}, 500);
}, []);
useEffect(() => {
setInterval(() => {
console.log(count);
}, 500);
}, []);
return <div>guang</div>;
}
export default Dong;
用 useState 创建了个 count 状态,在一个 useEffect 里定时修改它,另一个 useEffect 里定时打印最新的 count 值。
我们跑一下:
打印的并不是我们预期的 0、1、2、3,而是 0、0、0、0,这是为什么呢?
这就是所谓的闭包陷阱。
首先,我们回顾下 hooks 的原理:hooks 就是在 fiber 节点上存放了 memorizedState 链表,每个 hook 都从对应的链表元素上存取自己的值。
比如上面 useState、useEffect、useEffect 的 3 个 hook 就对应了链表中的 3 个 memorizedState:
然后 hook 是存取各自的那个 memorizedState 来完成自己的逻辑。
hook 链表有创建和更新两个阶段,也就是 mount 和 update,第一次走 mount 创建链表,后面都走 update。
比如 useEffect 的实现:
特别要注意 deps 参数的处理,如果 deps 为 undefined 就被当作 null 来处理了。
那之后又怎么处理的呢?
会取出新传入的 deps 和之前存在 memorizedState 的 deps 做对比,如果没有变,就直接用之前传入的那个函数,否则才会用新的函数。
deps 对比的逻辑很容易看懂,如果是之前的 deps 是 null,那就返回 false 也就是不相等,否则遍历数组依次对比:
所以:
如果 useEffect 第二个参数传入 undefined 或者 null,那每次都会执行。
如果传入了一个空数组,只会执行一次。
否则会对比数组中的每个元素有没有改变,来决定是否执行。
这些我们应该比较熟了,但是现在从源码理清了。
同样,useMemo、useCallback 等也是同样的 deps 处理:
理清了 useEffect 等 hook 是在哪里存取数据的,怎么判断是否执行传入的函数的之后,再回来看下那个闭包陷阱问题。
我们是这样写的:
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 500);
}, []);
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 500);
}, []);
deps 传入了空数组,所以只会执行一次。
对应的源码实现是这样的:
如果是需要执行的 effect 会打上 HasEffect 的标记,然后后面会执行:
因为 deps 数组是空数组,所以没有 HasEffect 的标记,就不会再执行。
我们知道了为什么只执行一次,那只执行一次有什么问题呢?定时器确实只需要设置一次呀?
定时器确实只需要设置一次没错,但是在定时器里用到了会变化的 state,这就有问题了:
deps 设置了空数组,那多次 render,只有第一次会执行传入的函数:
但是 state 是变化的呀,执行的那个函数却一直引用着最开始的 state。
怎么解决这个问题呢?
每次 state 变了重新创建定时器,用新的 state 变量不就行了:
也就是这样的:
import { useEffect, useState } from 'react';
function Dong() {
const [count,setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(count + 1);
}, 500);
}, [count]);
useEffect(() => {
setInterval(() => {
console.log(count);
}, 500);
}, [count]);
return <div>guang</div>;
}
export default Dong;
这样每次 count 变了就会执行引用了最新 count 的函数了:
现在确实不是全 0 了,但是这乱七八遭的打印是怎么回事?
那是因为现在确实是执行传入的 fn 来设置新定时器了,但是之前的那个没有清楚呀,需要加入一段清除逻辑:
import { useEffect, useState } from 'react';
function Dong() {
const [count,setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 500);
return () => clearInterval(timer);
}, [count]);
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 500);
return () => clearInterval(timer);
}, [count]);
return <div>guang</div>;
}
export default Dong;
加上了 clearInterval,每次执行新的函数之前会把上次设置的定时器清掉。
再试一下:
现在就是符合我们预期的了,打印 0、1、2、3、4。
很多同学学了 useEffect 却不知道要返回一个清理函数,现在知道为啥了吧。就是为了再次执行的时候清掉上次设置的定时器、事件监听器等的。
这样我们就完美解决了 hook 闭包陷阱的问题。
总结
hooks 虽然方便,但是也存在闭包陷阱的问题。
我们过了一下 hooks 的实现原理:
在 fiber 节点的 memorizedState 属性存放一个链表,链表节点和 hook 一一对应,每个 hook 都在各自对应的节点上存取数据。
useEffect、useMomo、useCallback 等都有 deps 的参数,实现的时候会对比新旧两次的 deps,如果变了才会重新执行传入的函数。所以 undefined、null 每次都会执行,[] 只会执行一次,[state] 在 state 变了才会再次执行。
闭包陷阱产生的原因就是 useEffect 等 hook 里用到了某个 state,但是没有加到 deps 数组里,这样导致 state 变了却没有执行新传入的函数,依然引用的之前的 state。
闭包陷阱的解决也很简单,正确设置 deps 数组就可以了,这样每次用到的 state 变了就会执行新函数,引用新的 state。不过还要注意要清理下上次的定时器、事件监听器等。
要理清 hooks 闭包陷阱的原因是要理解 hook 的原理的,什么时候会执行新传入的函数,什么时候不会。
hooks 的原理确实也不难,就是在 memorizedState 链表上的各节点存取数据,完成各自的逻辑的,唯一需要注意的是 deps 数组引发的这个闭包陷阱问题。