闭包陷阱

在使用 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 中的闭包捕获的是 count0 时候的状态,而当 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 的刷新条件)来解决;对于捕获的状态过时的,可以通过函数式方法来更新状态。