react hook 闭包陷阱

173 阅读4分钟

以定时器为代表的问题

相信很多人都遇见过以下代码这种问题。无论点击多少次,定时器里面输出的count永远是0。这是因为下面示例代码中useEffect仅在首次执行,而useEffect也是一个闭包。所以虽然setCount更新了count的值,并且页面刷新了,但是useEffect里面的count还是原来的count。

function App() {
    const [count, setCount] = useState<number>(0);

    useEffect(() => {
        setInterval(() => {
            console.log('count', count);
        }, 1000)
    }, [])

    return (
        <div>
            {count}
            <div onClick={() => setCount(count + 1)}>点击count+1</div>
        </div>
    );
}

image.png

那么我们如何解决呢?

  1. 我们可以使用useRef
  2. 可以使用hook版本的定时器比如ahook的useInterval

第二种我们就不过多介绍了,根据文档直接使用就行。 下面我们使用useRef每次返回的都是相同的引用解决这个问题:

function App() {
    
    const [count, setCount] = useState<number>(0);
    const countRef = useRef<number>(count);

    useEffect(() => {
        countRef.current = count;
    }, [count])

    useEffect(() => {
        setInterval(() => {
            console.log('count', countRef.current);
        }, 1000)
    }, [])

}

然后我们进行测试,发现打印结果已经正常。虽然useRef可以解决这个问题,但是我还是推荐大家使用ahook封装好的。

setState之后调用函数无法获取刚刚设置最新的state值。

比如以下例子,用户触发点击事件,我更新了一个state,紧接着调用了一个函数,输出这个state。

function App() {

   const [count, setCount] = useState<number>(0);

   const logCount = () => {
       console.log('count', count);
   }

   return (
       <div>
           {count}
           <div onClick={() => {
               setCount(count + 1);
               logCount();
           }}>点击count+1
           </div>
       </div>
   );
}

image.png 我们发现每次输出的结果,都是上一次的副本。达不到我们的期望。

为什么会出现这种情况?

如果理解的hook的执行顺序,这个问题就很理解。 触发点击事件的时候先执行点击事件的内部逻辑setCount(count + 1); logCount();所以先打印了count 0 这时候的count是原来的count,然后从上开始执行, count进行了更新,logCount函数里面的count也会更新成新的count,然后render渲染页面,然后触发useEffect。 这样我们是不是知道问题所在了,打印出现在了赋值之前。所以每次count打印的都是上一次的副本。

我们可以debug调试一下,看一下执行顺序

2023-04-06 17-14-54.2023-04-06 17_19_58.gif 通过debug我们可以明显的看的这个发生这个问题的原因和执行顺序,找到了原因,我们就可以解决问题了。

- 使用useRef + setTimeout,这个方案不推荐,因为我也没测试过会不会出现异常情况。仅仅是为了更好的理解hook的执行顺序。

以下是示例代码:

function App() {

    const [count, setCount] = useState<number>(0);
    const countRef = useRef<number>(count);

    useEffect(() => {
        countRef.current = count;
    }, [count])

    const logCount = () => {
        setTimeout(() => {
            console.log('count:', countRef.current);
        }, 0)
    }

    return (
        <div>
            {count}
            <div onClick={() => {
                setCount(count + 1);
                logCount();
            }}>点击count+1
            </div>
        </div>
    );
}

可以自己debug一一下,就能更清楚的理解react hook的执行顺序。

使用useEffect解决

function App() {

    const [count, setCount] = useState<number>(0);
    const [flag, setFlag] = useState<boolean>(false);

    useEffect(() => {
        if (flag) {
            console.log('count:',count);
            setFlag(false);
        }
    }, [flag])


    return (
        <div>
            {count}
            <div onClick={() => {
                setCount(count + 1);
                setFlag(true);
            }}>点击count+1
            </div>
        </div>
    );
}

image.png

我们发现虽然实现了效果,但并不是我们想要的效果,我们之前是一个函数,现在变成了useEffect的副作用。所以我们封装一下变成一个hook方便我们调用。

const useSyncCallback = (callback: Function) => {

    const [flag, setFlag] = useState(false);

    const fn = useCallback(() => {
        setFlag(true);
    }, [])

    useEffect(() => {
        if (flag) {
            callback && callback();
            setFlag(false)
        }
    }, [flag])

    return fn
}

然后再改造一下原来的代码

function App() {

    const [count, setCount] = useState<number>(0);

    const logCount = useSyncCallback(()=>{
        console.log('count', count);
    })
    
    return (
        <div>
            {count}
            <div onClick={() => {
                setCount(count + 1);
                logCount();
            }}>点击count+1
            </div>
        </div>
    );
}

这样写也有弊端。 每次调用logCount()都会触发useEffect没有依赖的场景,而且也会多余的触发一次rander。

其实更多的应该是避免这样的形式,我们应该思考,为什么会出现改变一个值,然后再去调用一个函数。大部分这种情况都是可以避免的。比如我已经明确的知道点击的时候需要count+1。那我们完全可以把count+1赋值为一个新变量在logCount中使用。setCount仅仅是为了渲染页面新的count。类似下面这样:

function App() {

    const [count, setCount] = useState<number>(0);

    const logCount = ()=>{
        const newCount = count + 1;
        setCount(newCount);
        console.log('count:',newCount);
    }

    return (
        <div>
            {count}
            <div onClick={() => {
                logCount();
            }}>点击count+1
            </div>
        </div>
    );
}

本篇文章仅作为分享,总之想要学好hook一定要明白执行的顺序和触发的时机,这样才能更好的架构项目。多用debug调试,你会发现很多之前不理解的问题突然就豁然开朗。