闭包陷阱
在使用 React Hook 的时候最常遇到的问题就是闭包陷阱。要理清这个闭包陷阱,就需要先从两个简单的示例开始。
首先看一个无论何时都只会输出 5
的示例。
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, 0);
}
示例中使用了 setTimeout(f, 0)
的形式来让延时过程立刻执行,但是这段代码的输出依旧全部都是 5
。出现这种情况,首先需要明确在 Javascript 中什么是闭包。闭包就是一个函数与对其周围词法状态的引用捆绑在一起的组合。闭包可以在内层函数中访问到外层函数的作用域。
追究上例中问题出现的原因,就是因为闭包是在循环过程中定义的,这时的闭包捕获的是外层函数的作用域,但是这个作用域是所有闭包共享的,所以当这个作用域中的变量发生了变化,所有闭包能够取得的值也就发生了变化。
要避免这个问题,最简单的方法是在闭包外面再套上一层函数。
for (var i = 0; i < 5; i++) {
(function (i) {
setTimeout(() => {
console.log(i);
}, 0);
})(i);
}
上面这个改进以后的示例,就是通过使用函数作用域,“固化”了闭包捕获的作用域,使所有的闭包不再共享作用域,就使问题得到了解决。
其实在使用 React Hook 过程中常常遇到的“闭包陷阱”,其实就是 Javascript 中这种闭包特性的体现。例如下面这个 React 组件,在使用的时候就会出现“闭包陷阱”。
function Counter() {
const [count, setCount] = useState(0);
useEffect(function () {
setInterval(function () {
console.log(`Count: [${count}]`);
}, 1000);
}, []);
return (
<div>
{count}
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
这个组件在运行的时候,无论怎样点击界面中的按钮,控制台中的输出始终是 Count: [0]
。这是因为在第一次渲染的时候,setInterval
中的闭包捕获的是 count
为 0
时候的状态,而当 count
发生变化以后,setInterval
中的闭包所捕获的内容就变成了过时的内容,而 setInterval
中的闭包也就成了一个过时的闭包。
那么知道了这个“闭包陷阱”形成的原因,要解决它就比较容易了,一个比较简单可行的思路就是重新创建这个闭包。重新创建闭包听起来似乎不容易,但是 React 已经提供了针对这个问题的解决方法。例如把上面这个错误的组件修改一下,就成了下面可以正常工作的样子。
function Counter() {
const [count, setCount] = useState(0);
useEffect(
function () {
const id = setInterval(function () {
console.log(`Count: [${count}]`);
}, 1000);
return function () {
clearInterval(id);
};
},
[count]
);
return (
<div>
{count}
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
仔细与前面的示例对比一下,就可以发现,useEffect
里多了清理计时器的操作,而且 useEffect
的执行也加入了条件。这样一来,在 count
发生变化的时候,setInterval
中的闭包就会得到重建,组件也就会按照期望来执行了。
相同的情况也会发生在 useState
上,例如下面这个组件,它的运行效果也是十分诡异的。
function Counter() {
const [count, setCount] = useState(0);
function increaseAsync() {
setTimeout(function () {
setCount(count + 1);
}, 2000);
}
function increaseSync() {
setCount(count + 1);
}
return (
<div>
{count}
<button onClick={increaseAsync}>延时递增</button>
<button onClick={increaseSync}>立刻递增</button>
</div>
);
}
在这个组件中,交替点击两个按钮,会发现界面上所显示的数字在一定时间之后总会变成 1
。这是因为 increaseAsync()
函数中的闭包捕获了旧的 count
状态,所以要解决这个问题,就不能像 useEffect
中那样重新创建闭包了,但是可以利用给 useState
返回的 set
方法一个闭包来使其捕获最新的状态值。
那么上面这个组件就可以修改成以下样子。
function Counter() {
const [count, setCount] = useState(0);
function increaseAsync() {
setTimeout(function () {
setCount(count => count + 1);
}, 2000);
}
function increaseSync() {
setCount(count + 1);
}
return (
<div>
{count}
<button onClick={increaseAsync}>延时递增</button>
<button onClick={increaseSync}>立刻递增</button>
</div>
);
}
所以,解决 React Hook 中的“闭包陷阱”所使用的方法就是判断闭包是因为什么情况捕获的过时变量。对于整个闭包都过时的,可以通过设置闭包的依赖(即 useEffect
的刷新条件)来解决;对于捕获的状态过时的,可以通过函数式方法来更新状态。