3.2.2 useEffect

1.副作用

在计算机科学中,如果某些操作、函数或表达式在其局部环境之外修改了一些状态变量值,则称其具有副作用(side effect)。副作用可以是一个与第三方通信的网络请求,或者是外部变量的修改,或者是调用具有副作用的任何其他函数。副作用并无好坏之分,其存在可能影响其他环境的使用,开发者需要做的是正确处理副作用,使得副作用操作与程序的其余部分隔离,这将使得整个软件系统易于扩展、重构、调试、测试和维护。在大多数前端框架中,也鼓励开发者在单独的、松耦合的模块中管理副作用和组件渲染。

对于函数来说,无副作用执行的函数称为纯函数,它们接收参数,并返回值。纯函数是确定性的,意味着在给定输入的情况下,它们总是返回相同的输出。但这并不意味着所有非纯函数都具有副作用,如在函数内生成随机值会使纯函数变为非纯函数,但不具有副作用。

React是关于纯函数的,它要求render纯净。若render不纯净,则会影响其他组件,影响渲染。但在浏览器中,副作用无处不在,如果希望在React中处理副作用,则可使用useEffect。useEffect,顾名思义,就是执行有副作用的操作,其声明如下:

函数的第一个参数为副作用函数,第二个参数为执行副作用的依赖数组,这将在下面的内容中介绍。

示例如下:

当上述组件初始化后,在打印render后会打印一次color effect,表明组件渲染之后,执行了传入的effect。而在单击ID为content的元素后,将更新value状态,触发一次渲染,打印render之后会打印color effect red。这一流程表明React的DOM已经更新完毕,并将控制权交给开发者的副作用函数,副作用函数成功地获取到了DOM更新后的值。事实上,上述流程与React的componentDidMount、componentDidUpdate生命周期类似,React首次渲染和之后的每次渲染都会调用一遍传给useEffect的函数,这也是useEffect与传统类组件可以类比的地方。一般来说,useEffect可类比为componentDidMount、componentDidUpdate、componentWillUnmount三者的集合,但要注意它们不完全等同,主要区别在于componentDidMount或componentDidUpdate中的代码是“同步”执行的。这里的“同步”指的是副作用的执行将阻碍浏览器自身的渲染,如有时候需要先根据DOM计算出某个元素的尺寸再重新渲染,这时候生命周期方法会在浏览器真正绘制前发生。

而useEffect中定义的副作用函数的执行不会阻碍浏览器更新视图,也就是说这些函数是异步执行的。所谓异步执行,指的是传入useEffect的回调函数是在浏览器的“绘制”阶段之后触发的,不“同步”阻碍浏览器的绘制。在通常情况下,这是比较合理的,因为大多数的副作用都没有必要阻碍浏览器的绘制。对于useEffect,React使用了一种特殊手段保证effect函数在“绘制”阶段后触发:

requestAnimationFrame与postMessage结合使用以达到这一类目的。

简而言之,useEffect会在浏览器执行完reflow/repaint流程之后触发,effect函数适合执行无DOM依赖、不阻碍主线程渲染的副作用,如数据网络请求、外部事件绑定等。

2.清除副作用

当副作用对外界产生某些影响时,在再次执行副作用前,应先清除之前的副作用,再重新更新副作用,这种情况可以在effect中返回一个函数,即cleanup(清除)函数。

每个effect都可以返回一个清除函数。作为useEffect可选的清除机制,其可以将监听和取消监听的逻辑放在一个effect中。

那么,React何时清除effect?effect的清除函数将会在组件重新渲染之后,并先于副作用函数执行。以一个例子来说明:

每次单击div元素,都会打印:

如上例所示,React会在执行当前 effect 之前对上一个 effect 进行清除。清除函数作用域中的变量值都为上一次渲染时的变量值,这与Hooks的Caputure Value特性有关,将在下面的内容中介绍。

除了每次更新会执行清除函数,React还会在组件卸载的时候执行清除函数。

3.减少不必要的effect

如上面内容所说,在每次组件渲染后,都会运行effect中的清除函数及对应的副作用函数。若每次重新渲染都执行一遍这些函数,则显然不够经济,在某些情况下甚至会造成副作用的死循环。这时,可利用useEffect参数列表中的第二个参数解决。useEffect参数列表中的第二个参数也称为依赖列表,其作用是告诉React只有当这个列表中的参数值发生改变时,才执行传入的副作用函数:

那么,React是如何判断依赖列表中的值发生了变化的呢?事实上,React对依赖列表中的每个值,将通过Object.is进行元素前后之间的比较,以确定是否有任何更改。如果在当前渲染过程中,依赖列表中的某一个元素与该元素在上一个渲染周期的不同,则将执行effect副作用。

注意,如果元素之一是对象或数组,那么由于Object.is将比较对象或数组的引用,因此可能会造成一些疑惑:

如果config每次都由外部传入,那么尽管config对象的字段值都不变,但由于新传入的对象与之前config对象的引用不相等,因此effect副作用将被执行。要解决此种问题,可以依赖一些社区的解决方案,如use-deep-compare-effect。

在通常情况下,若useEffect的第二个参数传入一个空数组[](这并不属于特殊情况,它依然遵循依赖列表的工作方式),则React将认为其依赖元素为空,每次渲染比对,空数组与空数组都没有任何变化。React认为effect不依赖于props或state中的任何值,所以effect副作用永远都不需要重复执行,可理解为componentDidUpdate永远不会执行。这相当于只在首次渲染的时候执行effect,以及在销毁组件的时候执行cleanup函数。要注意,这仅是便于理解的类比,对于第二个参数传入一个空数组[]与这类生命周期的区别,可查看下面的注意事项。

4.注意事项

1)Capture Value特性

注意,React Hooks有着Capture Value的特性,每一次渲染都有它自己的props和state:

在useEffect中,获得的永远是初始值0,将永远打印“count is 0”;h1中的值也将永远为setCount(0+1)的值,即“1”。若希望count能依次增加,则可使用useRef保存count,useRef将在3.2.4节介绍。

2)async函数

useEffect不允许传入async函数,如:

原因在于async函数返回了promise,这与useEffect的cleanup函数容易混淆。在async函数中返回cleanup函数将不起作用,若要使用async函数,则可进行如下改写:

3)空数组依赖

注意,useEffect传递空数组依赖容易产生一些问题,这些问题通常容易被忽视,如以下示例:

单击“销毁Child组件”按钮,浏览器将弹出“componentWillUnmount and count is 0”提示框,无论setCount被调用多少次,都将如此,这是由Capture Value特性所导致的。而类组件的componentWillUnmount生命周期可从this.props.count中获取到最新的count值。

在使用useEffect时,注意其不完全与componentDidUpdate、componentWillUnmount等生命周期等同,应该以“副作用”或状态同步的方式去思考useEffect。但这也不代表不建议使用空数组依赖,需要结合上下文场景决定。与其将useEffect视为一个功能来经历3个单独的生命周期,不如将其简单地视为一种在渲染后运行副作用的方式,可能会更有帮助。

useEffect的设计意图是关注数据流的改变,然后决定effect该如何执行,与生命周期的思考模型需要区分开。