<前情提要/>

回顾

之前我们学习了 useStateuseEffect 两个基础 React Hook。

通过它们,可以实现以前的类组件的大部分功能:属性值传入、自身状态维持、状态更新触发、生命周期回调。

并且让你可以:

  1. 在业务中常见的简单场景下,使用更简单的代码实现组件;
  2. 通过副作用聚合同一数据在不同生命周期的操作,便于不同组件、项目之间复用。

二、不良实践:副作用无限触发

一切看起来都很美好,虽然我们基本还不知道这两个 Hook 内部是怎么样神奇的实现了维持状态和生命周期回调,但通过简单的项目 Demo 就能看到它们确实按照我们预期的效果跑起来了。

去深挖黑盒的内部构造也是很有意思的,不过现在还为时尚早。

为什么?不只是因为还有其它 Hook 没有讲到,而且现有的两个 Hook 我们也没有彻底理解。

只需要对之前的 Demo 稍微做一点小修改,出乎你预料的麻烦事就要发生了……

1. 无限触发的计数器

我们将之前 useState 的例子做个小改动,将点击计数 count 改为渲染次数计数 renderCount

然后设置一个副作用,不传入依赖数组,使之在每次渲染完成后都执行,执行时将 renderCount 加一来实现计数功能:

1
2
3
4
5
6
7
8
9
10
11
function App() {
const [renderCount, setRenderCount] = useState(0);

useEffect(() => setRenderCount(renderCount + 1));

return (
<div>
<p>App rendered {renderCount} times</p>
</div>
);
}

将例子跑起来后,你就会看到——页面上的 renderCount 计数在不停地疯狂飙升,控制台里也出现了来自 React 的警告:

1
Warning: Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.

为什么会这样?我们看看刚才的副作用:

1
useEffect(() => setRenderCount(renderCount + 1));

组件渲染完毕后,副作用中的 setRenderCount 会导致 renderCount 这个 state 的变化,从而触发组件重渲染。而重渲染又会再次触发 setRenderCount……从而无限循环触发,导致运行的情况与我们想要的效果不太一样。

2. 添加依赖

也就是说,我们避免 renderCount 这个 state 触发渲染就能解决问题了。

添加一个依赖数组,对于组件内除了 renderCount 之外的其它 state 发生改变,再执行副作用就能达到这个效果。

不过目前除了 renderCount 之外,不存在其它 state,所以我们的依赖数组现在是空的。

假如增加一个名为 title 的 state:

1
2
3
4
const [renderCount, setRenderCount] = useState(0);
const [title, setTitle] = useState('Hello world!');

useEffect(() => setRenderCount(renderCount + 1), [title]);

这里其实还有个隐患,某些情况下直接使用 renderCount 取到的可能不是最新值,最好还是通过回调的方式取到最新值再处理:

1
useEffect(() => setRenderCount(renderCount => renderCount + 1), [title]);

但这样终究有些繁琐,每次增加 state 后找到这里添加依赖只是一项潜规则,参与项目的人越多、修改次数越多,出错的概率就越大。

3. 使用引用

之所以 renderCount 能触发渲染,是因为它是个 state,所以如果它不是 state 不触发渲染就能解决问题了?

1
2
3
4
const renderCount = 0;
const [title, setTitle] = useState('Hello world!');

useEffect(() => renderCount = renderCount + 1);

这样写的话,renderCount 的改变确实不会触发渲染了,但同样它也没法按照我们的意愿改变了——

函数式组件本身相当于 render,每次组件重新渲染都会被执行,而 renderCount 作为其中一个普通的局部变量,每次都会被赋值为 0 而非上一次修改的值。导致不管重新渲染几次,页面上的计数始终为0。

正确的方法是使用另一个 Hook —— useRef

1
2
3
4
5
6
7
8
9
10
11
function App() {
const renderCount = useRef(0);

useEffect(() => renderCount.current += 1));

return (
<div>
<p>App rendered {renderCount.current} times</p>
</div>
);
}

这样,就算增加别的 state,也不需要修改现有代码即可保持逻辑的正常执行。

此外,我们还可以直接使用 useState 保持一个对象状态,再通过其中的子字段实现计数,原理与 useRef 一样。但是需要注意 setState 时必须使用原对象而非新对象(比如使用解构赋值创建新对象),否则会导致此对象的 state 依赖对比不通过,触发重渲染从而又导致无限更新。


小结

问题的根本在于副作用内更新 state 时,state 的变化直接或间接地影响了副作用自身的触发条件,从而导致副作用被无限触发。

想要尽量避免这样的情况,需要遵循以下原则:

  1. 不轻易在副作用内更新 state;
  2. 为副作用设置好依赖数组;
  3. 触发 state 联动更新时,注意副作用自身依赖条件是否被影响;
  4. 使用官方推荐的 eslint-plugin-react-hooks 插件,辅助开发。