最近接手一个用 react hooks 写的项目。我发现只要接触 react hooks 的时间够长,就一定会遇到以下问题:

  • 某个 state 一直不会更新
  • 某个方法疯狂执行

我不是说用传统方法就不会遇到上面的问题。但是仔细比较两种写法引发问题的次数,以及解决问题的难度之后,你就会发觉 react hooks 问题更多更难解决。

要继续吐槽的话,可以写很多。但这篇文章更想写为什么用上 react hooks 后会有这么多问题,问题的根源是什么。

首先要说,函数式组件在每次渲染时,会从上到下执行一遍。这像是不言而喻的事情,以至于官方文档根本没有提及。

但这个概念非常重要,要是记住了,你就会对 useState 的实现很感兴趣,它是个每次传入相同的参数,但会返回不同结果的方法。

要是记住了,你就会对这个问题:Why am I seeing stale props or state inside my function?有比官方文档更深刻的理解。

把官方文档代码搬下来:

function Example() {
  const [count, setCount] = useState(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}

Example 函数组件会因为由 setCount 而触发的渲染过程,从上到下执行一遍。

handleAlertClick 方法也会因此被重新定义一遍,如果你对闭包有了解,这个 handleAlertClick 就是闭包,它内部引用的 count 也是闭包。

因为闭包,你得以在函数组件执行(渲染)完后点击 Show alert 的触发 handleAlertClick ,因此 3 秒后 alert 执行时显示的 count 值,是你点击 Show alert 那一刻的值,也就不奇怪了。

useEffects 与函数组件内的普通函数、变量并没有多大不同。

每次渲染,函数组件内部的 useEffects 会从上到下执行一遍,只是作为它的第一个参数的 side effect 会不会在渲染结束后执行就得看情况。但是只要这些 side effect 执行,它们就是以闭包的姿态执行。这意味着 side effect 内部引用到的 stateprops 都会是函数组件最近一次渲染时的值。

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

上面的代码,无论你点 Click me 有多快, console.log 都会将 count 从 0 开始逐个打印出来。

这个表现跟异步执行没什么关系,看看下面这个没有 setTimeout例子

export default function App() {
  const [count, setCount] = useState(0);
  const [list, setList] = useState([]);

  const increaseCount = () => {
    const newCount = count;
    console.log(newCount);
    setCount(newCount + 1);
  };

  useEffect(() => {
    const list = new Array(5).fill().map((_, index) => {
      return (
        <div key={index} onClick={increaseCount}>
          try click me
        </div>
      );
    });
    setList(list);
  }, []);

  return (
    <div className="App">
      <div className="list">{list}</div>
      <h2>{count}</h2>
    </div>
  );
}

所有的 onClick 事件都只在 componentDidMount 时注册,之后不再更新,并且都指向那时的 increaseCount 。因此无论你点了多少遍 try clike mecount 都是 1 ,而控制台永远打印 0 。

上述几个例子作为铺垫,比较容易看出问题。来看看下面这个:

export default function App() {
  const [loading, setLoading] = useState(false);

  const foo = () => {
    console.log("is loading ? ", loading);
  };

  const bar = callback => {
    setTimeout(() => {
      setLoading(true);
      callback();
    }, 5000);
  };

  const runner = () => {
    foo();
    bar(runner);
  };

  useEffect(() => {
    console.log("run");
    runner();
  }, []);

  return (
    <div className="App">
      <h1>Is it loading ?  {loading ? 'true' : 'false'}</h1>
    </div>
  );
}

上面代码值得注意的地方在于 runner 方法内部:会将自身传进 bar ,在其内作为回调被执行。

这里运行一下,你会发现控制台不断打印 loading 的值,但这个值永远都是 false ,这与页面上显示的不一致。

明明是因为 setLoading(true) 才触发重新渲染,为什么控制台的 loading 却一直是 false 呢?

原因就在于 runner 在第二次及之后的调用都是以 callback 的形式。这个 callback 是一个指向初次渲染之后的 runner 。初始 runner 包含的 foo ,以及 foo 包含的 loading 都是初次渲染时的值。

初次渲染时 loading 的值是 false ,因此控制台的 loading 一直是 false

随带一提,如果你把上面的 useEffect 中第二个参数 [] 去掉,就会在控制台看到每隔 5 秒先后打印出 loading 的两个值,分别为 falsetrue

以你的聪明才智,一定能弄明白为什么,我就此打住了。

参考