我们在努力扩大自己,以靠近,以触及我们自身以外的世界。 ---博尔赫斯谈话录-
闭包陷阱
React Hooks 中的闭包陷阱主要会发生在两种情况:
- 在 useState 中使用闭包;
- 在 useEffect 中使用闭包。
useState
例子一
闭包陷阱的原因是因为在闭包环境下使用了 Hook,实际上这是 JavaScript 的闭包问题的延伸,也就是说,在一些可能保留旧状态的引用的情况下,使用了 Hook 函数,举个例子🙋♀️🌰:
js
function Counter() {
const [count, setCount] = useState(0);
const buttonRef = useRef(null);
const handleClick = () => {
setCount(count + 1); //这里无论执行多少次,都是setCount( 0 + 1),因为页面还没有渲染,需要 1s 之后渲染,将 count 的值改变之后,才会+1
// 为什么setCount(count=>count+1); 就没问题,因为它每次是一个回调函数,将 count+1 了
};
useEffect(() => {
const buttonDom = buttonRef.current;
if (buttonDom) {
buttonDom.addEventListener('click', handleClick);
}
return () => {
if (buttonDom) {
buttonDom.removeEventListener('click', handleClick);
}
};
}, []);
return (
<div>
{count}
<button ref={buttonRef}>count++</button>
</div>
);
}
在上面的代码中,这里只有第一次点击时count变成了1,之后不再改变。
原因是useEffect
Hook 的依赖是空数组,只会在第一次的时候执行,所以保留的是最初的 count 值,也就是 count = 0,useEffect
中执行了 buttonDom.addEventListener('click', handleClick);
,就是为buttonDom
添加了函数const handleClick = () => {setCount(count + 1); };
,此时,handleClick
函数保留了count = 0,
后续 button
点击执行,由于useEffect
的依赖项是空数组,所以buttonDom.addEventListener('click', handleClick);
这句话不再执行,所以 button
绑定的点击事件handleClick
中的 count
值也不再更新,保存的变量 count
一直是count = 0
的状态,所有点击之后+1 变成了 1,再次点击还是由 0 加一变成 1,视图不再更新。
例子二
js
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
setCount(count + 1);
}, 1000);
};
const handleReset = () => {
setCount(0);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
<button onClick={handleReset}>Reset</button>
</div>
);
}
这里的代码,首先定义了一个 handleClick 函数,他使用了一个闭包来缓存 count 值,每次点击触发 handleClick 函数,1s 后 setCount 会将 count 值加 1,在这 1s 内,无论我们点击多少次 Increment 按钮,count 值都只会加 1。
原因是: setCount(count + 1);
中的count 是闭包缓存的值,后续Counter 组件在 count 的值的改变就不会影响到闭包里面的值变化,这个值是始终不变的。1s 后 setTimeout 中的 setCount 生效,函数式组件 Counter 重新执行,会生成新的 handleClick 方法,形成新的闭包,此时闭包中缓存的 count 值也就变成了最新的 count 值。再继续点击 Increment 按钮,又会重复上述循环,每过 1s count 值会加 1.
结论:
在React中,useState hook返回的更新state的函数,即setCount函数,可以接受一个回调函数作为参数。这个回调函数会接受当前state的值作为参数,然后返回一个新的state值。React会使用这个新的state值来更新组件的状态。
在上述代码中,通过使用回调函数的形式来更新count的值,这个回调函数会接受 currentCount 作为参数,即当前的count值,而不是从外部直接引用count变量。这样,即使在闭包中使用了count变量,也不会受到影响,因为回调函数内部的 currentCount 变量是函数作用域内的局部变量,不会受到外部变量的影响。这种方式可以避免闭包陷阱,保证组件可以正确更新状态。
解决方案
方案一:添加依赖项(不推荐👎)
我们可以将要改变的状态作为依赖添加到依赖项中:
js
useEffect(() => {
const buttonDom = buttonRef.current;
if (buttonDom) {
buttonDom.addEventListener('click', handleClick);
}
return () => {
if (buttonDom) {
buttonDom.removeEventListener('click', handleClick);
}
};
}, [count]);
这种方式为什么不推荐呢?
- 因为当一个组件复杂时,依赖项的增加会导致Hook中逻辑的复杂性
- 此外,这种方式生效的原因是因为我们触发了setCount导致值变化了,useEffect再次执行,实际上是创建了一个新的函数,新的词法环境,我们还需要注意其中副作用的清理。
方案二:回调形式的setState
React 为我们提供的setState可以使用回调形式,这样我们总能拿到上一个值,然后在此基础上进行修改:
js
const handleClick = () => {
setCount(preCount=>preCount+1);
};
这种方式生效的原因是尽管我们词法环境并没有发生变化,但是我们每次触发setCount时使用的不再是词法环境中保存的那个count了,而是React获取了上一次的状态。
方案三:使用Ref同步状态
js
const [count, setCount] = useState(0);
const countRef = useRef(count);
const buttonRef = useRef(null);
const handleClick = () => {
countRef.current += 1;
setCount(countRef.current);
};
这种方式生效的原因是,我们调用setCount时,使用的都是ref中存储的值,而不是当前词法环境中的count
你发现了吗:方案二 和 方案三 十分相似,都是避免使用词法环境中的count,将状态保存在其他地方进行修改和使用,setState方案相当于React帮助我们保存了一份count,而Ref相当于我们自己保存了一份
js
import { useEffect, useRef, useState } from 'react';
function Dong() {
const [count, setCount] = useState(0);
const val = useRef(count);
//必须写这句话,否则么有作用,因为 val 会一直是 0
// 写了这句话,组建再次渲染的时候会 val 重新赋值
val.current = count;
console.log('val+' + val.current);
useEffect(() => {
setInterval(() => {
setCount(count => count + 1);
}, 500);
}, []);
useEffect(() => {
setInterval(() => {
console.log(val.current);
}, 500);
}, []);
return <div>guang</div>;
}
export default Dong;
useEffect闭包陷阱
js
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(timer);
}, []);
const handleClick = () => {
setCount(count + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</div>
);
}
这里就比较简单了,由于 useEffect 只会在组件首次渲染时执行一次,因此闭包中的 count 变量始终是首次渲染时的变量,而不是最新的值。
解决办法
为了避免这种闭包陷阱,可以使用 useEffect Hook 来更新状态。例如,以下代码中,通过 useEffect Hook 来更新 count 的值,就可以避免闭包陷阱:
js
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(timer);
}, [count]);