React useEffect的陷阱

React useEffect的陷阱

好久不写React的相关的东西,因为虽然这个技术作为工具还是在得到越来越多的应用,但是,React自Hooks和Suspense以来,也没有什么特别值得一说的新功能出来,所以,我也觉得真没有什么好写的:-)

回顾一下过去几个月,值得一提的,就是React Hooks正式推出之后暴露出来的一些小问题,这些小问题不是React的缺陷,而是开发者在面对Hooks这种新的思维方式时的水土不服。

今天就来讲一个useEffect这个Hook使用的一个小陷阱,看下面的代码,一个Counter,在窗口大小改变的时候,在console上输出当前count。

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

  useEffect(() => {
     // 让resize事件触发handleResize
     window.addEventListener('resize', handleResize)
     return () => window.removeEventListener('resize', handleResize)
  }, [])

  const handleResize = () => {
    // 把count输出
    console.log(`count is ${count}`)
  }

  return (
    <div className="App">
      <button onClick={() => setCount(count + 1)}>+</button>
      <h1>{count}</h1>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

这段代码会画出我们熟悉的Counter例子。

现在我们如果点击那个+按钮,下面的数字0当然会增长,比如我们现在让count增长为1,这时候你去改变浏览器窗口的大小,console上会输出什么呢?

你可能预期这样输出:

count is 1

事实上,输出是这样:

count is 0

怎么会这样?!

我先直接说这个问题怎么fix吧,关键在useEffect是用法上,正确的写法是这样:

  useEffect(() => {
     // 让resize事件触发handleResize
     window.addEventListener('resize', handleResize)
     return window.removeEventListener('resize', handleResize)
  }, [count])             //看这一行!!! useEffect有第二个数组参数!!!

看了fix之后,你也许就明白这是怎么回事了。

useEffect的第二个参数可选,如果用上的话,这个参数必须是一个数组。useEffect在每次被调用的时候,都会“记住”这个数组参数,当下一次被调用的时候,会逐个比较数组中的元素,看是否和上一次调用的数组元素一模一样,如果一模一样,第一个参数(那个函数参数)也就不用被调用了,如果不一样,就调用那个第一个参数。

当我们代码中的App组件第一次被渲染的时候,useEffect百分之百会调用第一个函数参数,这时候count变量是0,但是,当我们点+按钮让Counter增长为1,这时候App被重新渲染,但是因为useEffect第一个参数总是一个空数组,所以不会重新做addEventListener的工作。

你可能又会问:就算useEffect不重新执行第一个函数参数,也不应该有什么问题啊,handleResize函数利用闭包(clousre)功能访问App中的count变量,那也应该是使用更新为1的count啊!

抱歉,又让你失望了,虽然闭包的确可以访问外围的变量,但是,此handleResize非彼handleResize,第一次渲染时的handleResize和第二次渲染时的handleResize,虽然源自同一段代码,但是在运行时却是两个不同的函数对象。这并不难理解,handleResize是一个局部变量,每次App被执行时,handleResize都会被重行赋值,所以每一次App被渲染时,都会给handleResize一个全新的函数对象。

如果你觉得有点绕,我们详细复盘一下:

  1. App第一次被渲染
    1. 给handleResize赋值了一个函数对象(我们姑且用XX-1代表),这个XX-1引用的count值是这一次App被渲染时的count值,值为0;
    2. handleResize被useEffect挂到resize事件上,以后,当resize时间发生时,handleResize(应该说是XX-1)被调用;
  2. App第二次被渲染
    1. 有一次给handleResize赋值了一个函数对象,代号YY-2,注意,这个YY-2和之前的XX-1不是同一个函数对象,XX-1依然引用的是值为0的count,但是YY-2引用的是值为1的count;
    2. handleResize(也就是YY-2)没有被useEffect挂到resize时间上,换句话说,YY-2这个函数对象压根没有派上用场。
  3. resize事件发生了
    1. window上挂的resize事件处理,是第一次渲染时候创造的XX-1号handleResize,所以方位的count值为0

希望现在你明白了。

总结一下,请明白这几点:

  • React Hooks只能用于函数组件,而每一次函数组件被渲染,都是一个全新的开始;
  • 每一个全新的开始,所有的局部变量全都重来,全体失忆;
  • 每一次全新的开始,只有Hooks函数(比如useEffect)具有上一次渲染的“记忆”;

对于上面说的问题,因为count每次渲染都会改变,而且我们想要useEffect总会用上count的值,所以,就要把count放在useEffect的第二个数组参数里面。

规矩就是:如果useEffect第一个函数参数直接或者间接用上某个变量,就请把这个变量放在useEffect的第二个参数里

如果根本不用useEffect的第二个参数呢?

也行,但是,这样每次渲染都会执行useEffect的第一个参数,这……在某些场景下有一点点浪费。

其实要做到上面的规矩,也没那么难,不过在实际操作的时候,的确让人容易失误,你看,在上面的例子中,useEffect并没有直接使用count,只不过使用了handleResize,handleResize虽然直接使用了count,但是它作为一个独立函数并不知道(或者说也不该知道)自己会被useEffect用到,这……让人防不胜防啊!

这只有一层简介调用,靠人的眼力脑力还能把我,假设useEffect调用了函数X,函数X调用了Y,Y调用了Z……曲里拐弯调用N层之后再调用handleResize,真的不容易看出useEffect需要加上对count的依赖。

好吧,这是useEffect的一个陷阱,所以,我们再加一个规矩:使用useEffect,不要调用函数层次太多,代码应该一眼看清楚哪些函数会被useEffect调用

编辑于 2019-09-30

文章被以下专栏收录