今天想详细写写Hooks中的过期闭包问题(Stale Closure Problem)。这个问题在上一篇文章,手写Hooks中出现过。当时只是简单的提了一句,现在打算展开仔细说一下。
这次我想试验一种新的写作方式,我叫它“问答式写作法”。灵感来自于费曼技巧 - 诺贝尔获奖者理查德 费曼(Richard Feynman)。费曼技巧是说,当你想理解一个概念时,就把这个概念解释给别人听,解释的过程中如果遇到不懂的地方,停下来查书查资料,搞清楚以后继续解释,直到没有不懂的问题为止。基本上就是一个提出问题 - 解答 - 追问 - 解答 - 再追问 - 再解答的过程。
所以这篇文章没有分割章节,而是以每个问题自成一体,这样的话,文章粒度更细,如果遇到某个问题是你完全了解的,你就可以略过看下一个。如果某个问题不太懂,可以多花点时间研究。
So let's start.
什么是过期闭包问题(Stale Closure Problem)?
在JS中,函数运行的上下文是由定义的位置决定的。
当函数的闭包包住了旧的变量值时,就出现了过期闭包问题。
太抽象了,能举个例子吗?
function createIncrement(i) {
let value = 0;
function increment() {
value += i;
console.log(value);
const message = `Current value is ${value}`;
return function logValue() {
console.log(message);
};
}
return increment;
}
const inc = createIncrement(1);
const log = inc(); // logs 1
inc(); // logs 2
inc(); // logs 3
// Does not work!
log(); // logs "Current value is 1"
当log执行时,value已经是3了。但是log还是打印出1,是因为log的闭包包住了过期的value值。
log为什么包住了过期的value而不是当前的value?
因为每次logValue执行,都会产生一个闭包,会包住当时的value。
闭包相当于记住函数的上下文。
那么就是说,每次logValue执行,都对应不同的上下文了?
是的,并且闭包会记住这个上下文。
for (var i=0;i<3;i++){ setTimeout(() => console.log(i), 1000); } // 3 3 3我记得之前学习闭包的时候看过这样的代码。
这仿佛和上面的例子相悖。回调函数打印了最新的i值。
这里为什么没有出现过期闭包的问题?
这个例子和上面的不同。
setTimeout的回调函数虽然执行了三次,但是整个代码是运行了一次,所以三次回调的闭包包住的是同一个上下文。
并且执行时,i的值已经是3了。
怎么改写一下这段代码,使之打印出0,1,2 ?
如果想让变量i捕捉到每次循环的值,就要创建出三个不同的闭包。
for (var i=0;i<3;i++){
(function(){
let j = i;
setTimeout(function handler(){ console.log(j)}, 1000);
})();
}
要点是,要将外面套一层函数,这样创建出一层新的作用域。让回调函数的闭包去包住这一层作用域中的j。
for (var i=0;i<3;i++){ (function(){ setTimeout(() => console.log(i), 1000); })(); } // 3 3 3这样写为什么不行?
这样虽然套了一层函数,创建多一层的作用域。但是这个作用域是空的。回调函数执行时查找变量仍然找的是上上层的作用域。
所以说要做两件事情:
1,套一层函数,创建多一层作用域。
2,在新创建的作用域中,要捕捉上层作用域中改变的值
这里你提及的
上下文和作用域,这两个概念有什么区别?
上下文针对的函数执行,是一个运行时的概念。你可以理解成是一个值。一个js object。
所用域,是一个静态的概念。表示某个变量的定义在哪个范围内有效。
过期闭包问题是Hooks独有的吗?
不是。第一个例子就是典型的过期闭包问题,但和Hooks无关。
那为什么我们要在Hooks的背景下讨论它呢?
是因为在应用了Hooks以后,这个问题变得更常见。
函数组件使用了Hooks,因为状态改变导致函数组件多次re-render,每次render都是不同的上下文。
一旦结合setTimeout/setInterval这种异步调用,就会出现stale closure。
能举一个Hooks中stale closure problem的例子吗?
function WatchCount() {
const [count, setCount] = useState(0);
useEffect(function() {
const id = setInterval(function log() {
console.log(`Count is: ${count}`);
}, 2000);
return () => clearInterval(id);
}, []);
return (
<div>
{count}
<button onClick={() => setCount(count + 1) }>
Increase
</button>
</div>
);
}
这里即使点击Increase,counter值增加,但是仍然打印0。
为什么会打印0呢?按理说,setInterval的回调函数多次执行之间,查找的是同一个作用域。count就在这个作用域中。应该会实时更新才对。
因为查找的不是同一上下文了。
因为多次re-render之间,上下文不是同一个。而setInterval的回调函数的闭包包住的还是当时render的上下文。
这个问题Hooks是怎么解决的?
Hooks的解决方案是依赖数组。在useEffect中传入依赖数组。
useEffect(function() {
const id = setInterval(function log() {
console.log(`Count is: ${count}`);
}, 2000);
return () => clearInterval(id);
}, [count]);
如果依赖数组为空数组,则useEffect只执行一次。当把count加入到依赖数组中时,当count发生变化时,useEffect会重新执行。这样就能获取到最新的count值了。
这是useEffect中出现了过期闭包问题。其他的Hooks也有这个问题吗?
useState也可能出现类似的问题。考虑这个例子:
function DelayedCount() {
const [count, setCount] = useState(0);
function handleClickAsync() {
setTimeout(function delay() {
setCount(count + 1);
}, 1000);
}
function handleClickSync() {
setCount(count + 1);
}
return (
<div>
{count}
<button onClick={handleClickAsync}>Increase async</button>
<button onClick={handleClickSync}>Increase sync</button>
</div>
);
}
先点击Increase async,然后马上点击Increase sync,最终counter的值是1。
解释一下为什么吧。
1,点击Increase async,这时count为0,delay函数的闭包包住了此次执行的上下文。1000ms之后,会执行setCount(1);
2,马上点击Increase sync,同步执行handleClickSync, 这时count为0,所以同步执行setCount(1)。
所以最终counter的值还是1。
解决的方案是什么?
可以将handleClickAsync中的setCount中的变量改成更新函数。React会确保函数中传进来的count是最新的。
function handleClickAsync() {
setTimeout(function delay() {
setCount(count => count + 1);
}, 1000);
}
总结一下这篇文章的主要内容吧。
这篇文章介绍了过期闭包问题。当函数的闭包包住了过期的变量值时会有这个问题。
这个问题在应用Hooks更突出,因为函数组件多次render之间,函数的闭包可能会包住过期的上下文,也就是之前render时的上下问。
Hooks解决过期闭包问题的方法是依赖数组。针对过期state,可以使用函数的方法更新值。React确保通过更新函数可以得到最新的state值。
