深圳幻海软件技术有限公司 欢迎您!

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

2023-02-28

现在开发React组件基本都是用hooks了,hooks很方便,但一不注意也会遇到闭包陷阱的坑。相信很多用过hooks的人都遇到过这个坑,今天我们来思考下hooks闭包陷阱的原因和怎么解决。首先这样一段代码,大家觉得有问题没:复制import{useEffect,useState}from'reac

现在开发 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;
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.

用 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);
}, []);
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

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;
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.

这样每次 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;
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.

加上了 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 数组引发的这个闭包陷阱问题。