现在开发 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 值。

我们跑一下:

从根上理解 React Hooks 的闭包陷阱_链表

打印的并不是我们预期的 0、1、2、3,而是 0、0、0、0,这是为什么呢?

这就是所谓的闭包陷阱。

首先,我们回顾下 hooks 的原理:hooks 就是在 fiber 节点上存放了 memorizedState 链表,每个 hook 都从对应的链表元素上存取自己的值。

从根上理解 React Hooks 的闭包陷阱_链表_02

比如上面 useState、useEffect、useEffect 的 3 个 hook 就对应了链表中的 3 个 memorizedState:

从根上理解 React Hooks 的闭包陷阱_JavaScript_03

然后 hook 是存取各自的那个 memorizedState 来完成自己的逻辑。

hook 链表有创建和更新两个阶段,也就是 mount 和 update,第一次走 mount 创建链表,后面都走 update。

比如 useEffect 的实现:

从根上理解 React Hooks 的闭包陷阱_前端_04

特别要注意 deps 参数的处理,如果 deps 为 undefined 就被当作 null 来处理了。

从根上理解 React Hooks 的闭包陷阱_前端_05

那之后又怎么处理的呢?

从根上理解 React Hooks 的闭包陷阱_JavaScript_06

会取出新传入的 deps 和之前存在 memorizedState 的 deps 做对比,如果没有变,就直接用之前传入的那个函数,否则才会用新的函数。

deps 对比的逻辑很容易看懂,如果是之前的 deps 是 null,那就返回 false 也就是不相等,否则遍历数组依次对比:

从根上理解 React Hooks 的闭包陷阱_数组_07

所以:

如果 useEffect 第二个参数传入 undefined 或者 null,那每次都会执行。

如果传入了一个空数组,只会执行一次。

否则会对比数组中的每个元素有没有改变,来决定是否执行。

这些我们应该比较熟了,但是现在从源码理清了。

同样,useMemo、useCallback 等也是同样的 deps 处理:

从根上理解 React Hooks 的闭包陷阱_前端_08

从根上理解 React Hooks 的闭包陷阱_React.js_09

理清了 useEffect 等 hook 是在哪里存取数据的,怎么判断是否执行传入的函数的之后,再回来看下那个闭包陷阱问题。

我们是这样写的:

useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 500);
}, []);

useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 500);
}, []);

deps 传入了空数组,所以只会执行一次。

对应的源码实现是这样的:

从根上理解 React Hooks 的闭包陷阱_前端_10

如果是需要执行的 effect 会打上 HasEffect 的标记,然后后面会执行:

从根上理解 React Hooks 的闭包陷阱_React.js_11

因为 deps 数组是空数组,所以没有 HasEffect 的标记,就不会再执行。

我们知道了为什么只执行一次,那只执行一次有什么问题呢?定时器确实只需要设置一次呀?

定时器确实只需要设置一次没错,但是在定时器里用到了会变化的 state,这就有问题了:

deps 设置了空数组,那多次 render,只有第一次会执行传入的函数:

从根上理解 React Hooks 的闭包陷阱_数组_12

但是 state 是变化的呀,执行的那个函数却一直引用着最开始的 state。

怎么解决这个问题呢?

每次 state 变了重新创建定时器,用新的 state 变量不就行了:

从根上理解 React Hooks 的闭包陷阱_数组_13

也就是这样的:

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 的函数了:

从根上理解 React Hooks 的闭包陷阱_JavaScript_14

现在确实不是全 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,每次执行新的函数之前会把上次设置的定时器清掉。

再试一下:

从根上理解 React Hooks 的闭包陷阱_JavaScript_15

现在就是符合我们预期的了,打印 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 数组引发的这个闭包陷阱问题。