说说过期闭包问题

2,990 阅读3分钟

今天想详细写写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值。