一文搞懂什么是过期闭包[stale closure]

666 阅读3分钟

在开发函数式组件时,hook可以帮助我们很好地管理组件中的状态和副作用,而且重复的逻辑可以通过抽成一个自定义的hook来复用到其他业务。

Hooks主要依赖JavaScript closures闭包,所以hooks才这么有表现力、简单好用。但是闭包有时候也比较棘手。

当你使用Hooks,你经常会遇到的一个问题就是stale closure(过期闭包),有时候很难发现,很难解决。

我们通过简单的例子展示下什么是过期闭包,它会带来什么影响?然后来看怎么解决这种问题。

stale closure

我们创建一个工厂函数createIncrement(step),返回一个元组,包含increment和log两个函数。函数调用的时候,increment基于step增加内部变量value数值,log打印value数值。

function createIncrement(step) {
  let value = 0;
  function increment() {
    value += step;
    console.log(value);
  }
  const message = `Current value is ${value}`;
  function log() {
    console.log(message);
  }
  
  return [increment, log];
}
const [increment, log] = createIncrement(1);
increment(); // logs 1
increment(); // logs 2
increment(); // logs 3
// Does not work!
log(); // logs "Current value is 0"

其中createIncrement(1)创建了一个函数,递增量是1。调用三次increment()函数,内部数值增加到3,每次打印对应的数值,但是调用log函数的时候,打印出来的数值是初始值0,而不是3。

因为log中引用的message常量,这个引用是没有发生变化,这个常量引用的value值是也是函数初始化时的值0,是个过期的value值。

定义 stale closure:获取局部变量的值是过期数据值的闭包叫做stale closure(过期闭包)

解决过期闭包 上面这个例子中,想要解决stale closure其实很简单,只要把message的定义挪到log函数里面就可以。

function createIncrement(incBy) {
  let value = 0;
  function increment() {
    value += incBy;
    console.log(value);
  }
  function log() {
    const message = `Current value is ${value}`;
    console.log(message);
  }
  
  return [increment, log];
}
const [increment, log] = createIncrement(1);
increment(); // logs 1
increment(); // logs 2
increment(); // logs 3
// Works!
log();       // logs "Current value is 3"

在三次调用increment之后,log函数可以拿到最新的value值,打印"Current value is 3",log函数不再是过期闭包了

Hooks中的stale closure

1. useEffect

我们先看下在使用useEffect时经常遇到的过期闭包问题, 比如下面这个WatchCount组件,里面使用了useEffect每隔2s打印一次count数值

function WatchCount() {
  const [count, setCount] = useState(0);
  useEffect(function() {
    setInterval(function log() {
      console.log(`Count is: ${count}`);
    }, 2000);
  }, []);
  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1) }>
        Increase
      </button>
    </div>
  );
}

我们点击几次按钮之后,页面上的count值已经更新了,但是打开控制台发现log打印出来的值一直都是0,为什么会这样呢? 在组件初始化的时候,count初始值是0,然后再组件挂载之后,useEffect开始执行,调用log函数,log函数中的定时器打印count值,但是这里打印出来的一直是 count变量过期的数值0,所以这里也是一个stale closure。

解决方法是让useEffect知道这里业务逻辑依赖count数据变化,当count变化时候,useEffect会重置更新这里面的log函数,修改如下:

function WatchCount() {
  const [count, setCount] = useState(0);
  useEffect(function() {
    const id = setInterval(function log() {
      console.log(`Count is: ${count}`);
    }, 2000);
    return function() {
      clearInterval(id);
    }
  }, [count]);
  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1) }>
        Increase
      </button>
    </div>
  );
}

合理设置依赖变量之后,useEffect就在依赖变量发生变化之后可以及时更新闭包。所以,合理管理hooks的依赖,是解决stale hooks最好的办法。

2. useState

下面这个组件DelayedCount在点击按钮之后会异步更新count计数,有1s的延迟。

function DelayedCount() {
  const [count, setCount] = useState(0);
  function handleClickAsync() {
    setTimeout(function delay() {
      setCount(count + 1);
    }, 1000);
  }
  return (
    <div>
      {count}
      <button onClick={handleClickAsync}>Increase async</button>
    </div>
  );
}

当你快速点击按钮2次,你会发现页面展示的是1而不是2. 为什么呢?

因为每次点击的时候,回调函数中是一个延迟函数,两次点击很快的时候,delay设定是获取到的值都是count的初始值0,所以最终都是:setCount(count + 1) = setCount(0 + 1) = setCount(1)

在这里,delay这个闭包里面获取的就是过期的count值,这里就是stale closure。

解决的方法很简单,使用函数来更新state: setCount(count => count + 1)

function DelayedCount() {
  const [count, setCount] = useState(0);
  function handleClickAsync() {
    setTimeout(function delay() {
      setCount(count => count + 1);
    }, 1000);
  }
  function handleClickSync() {
    setCount(count + 1);
  }
  return (
    <div>
      {count}
      <button onClick={handleClickAsync}>Increase async</button>
      <button onClick={handleClickSync}>Increase sync</button>
    </div>
  );
}

在setState函数中,当入参是一个函数的时候,React会保证传入一个入参,入参是state上一次的取值,setState会返回下一个最新的取值。

setCount(alwaysActualStateValue => newStateValue);

总结

过期闭包就是闭包中的变量获取的是过期的取值。解决过期闭包最好的方法就是在useEffect中合理管理依赖变量,或者是在useState中使用函数更新状态。 当然,解决过期闭包最关键的一点就是保证闭包中的变量能够及时获取最新的数值。

收藏关注不迷路,持续更新