之前我们学习了 useState
和 useEffect
两个基础 React Hook。
通过它们,可以实现以前的类组件的大部分功能:属性值传入、自身状态维持、状态更新触发、生命周期回调。
并且让你可以:
一切看起来都很美好,虽然我们基本还不知道这两个 Hook 内部是怎么样神奇的实现了维持状态和生命周期回调,但通过简单的项目 Demo 就能看到它们确实按照我们预期的效果跑起来了。
去深挖黑盒的内部构造也是很有意思的,不过现在还为时尚早。
为什么?不只是因为还有其它 Hook 没有讲到,而且现有的两个 Hook 我们也没有彻底理解。
只需要对之前的 Demo 稍微做一点小修改,出乎你预料的麻烦事就要发生了……
我们将之前 useState
的例子做个小改动,将点击计数 count
改为渲染次数计数 renderCount
。
然后设置一个副作用,不传入依赖数组,使之在每次渲染完成后都执行,执行时将 renderCount
加一来实现计数功能:
1 | function App() { |
将例子跑起来后,你就会看到——页面上的 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
……从而无限循环触发,导致运行的情况与我们想要的效果不太一样。
也就是说,我们避免 renderCount
这个 state 触发渲染就能解决问题了。
添加一个依赖数组,对于组件内除了 renderCount
之外的其它 state 发生改变,再执行副作用就能达到这个效果。
不过目前除了 renderCount
之外,不存在其它 state,所以我们的依赖数组现在是空的。
假如增加一个名为 title
的 state:
1 | const [renderCount, setRenderCount] = useState(0); |
这里其实还有个隐患,某些情况下直接使用 renderCount
取到的可能不是最新值,最好还是通过回调的方式取到最新值再处理:
1 | useEffect(() => setRenderCount(renderCount => renderCount + 1), [title]); |
但这样终究有些繁琐,每次增加 state 后找到这里添加依赖只是一项潜规则,参与项目的人越多、修改次数越多,出错的概率就越大。
之所以 renderCount
能触发渲染,是因为它是个 state,所以如果它不是 state 不触发渲染就能解决问题了?
1 | const renderCount = 0; |
这样写的话,renderCount
的改变确实不会触发渲染了,但同样它也没法按照我们的意愿改变了——
函数式组件本身相当于 render
,每次组件重新渲染都会被执行,而 renderCount 作为其中一个普通的局部变量,每次都会被赋值为 0 而非上一次修改的值。导致不管重新渲染几次,页面上的计数始终为0。
正确的方法是使用另一个 Hook —— useRef
:
1 | function App() { |
这样,就算增加别的 state,也不需要修改现有代码即可保持逻辑的正常执行。
此外,我们还可以直接使用
useState
保持一个对象状态,再通过其中的子字段实现计数,原理与useRef
一样。但是需要注意setState
时必须使用原对象而非新对象(比如使用解构赋值创建新对象),否则会导致此对象的 state 依赖对比不通过,触发重渲染从而又导致无限更新。
问题的根本在于副作用内更新 state 时,state 的变化直接或间接地影响了副作用自身的触发条件,从而导致副作用被无限触发。
想要尽量避免这样的情况,需要遵循以下原则:
eslint-plugin-react-hooks
插件,辅助开发。