前段时间在项目中遇到一个Bug,在编辑页面且在一种特殊条件下,页面停留一会儿之后就直接无法操作,直接卡死了。
看了下进程,其中 Chrome 浏览器有一个进程的CPU使用直接跑到了130%。
根据经验判断,这个多半是因为代码里面有死循环了。
由于代码逻辑比较复杂,组件嵌套比较深,这让我一顿好找。
最后经过抽丝剥茧,一段一段断点调试终于找到了问题的原因。
确实是代码陷入死循环了。
一、死循环代码段
下面代码段为去除业务逻辑之后的简化代码段。
1 | import { useEffect, useState } from "react"; |
这里不纠结为何要这么写,以及为何不使用更安全的写法(问就是历史原因)。
本文仅单纯的分析一下,以及本代码段是如何一步一步陷入死循环的,为什么这样写就会陷入死循环?,
1 | PS:如果看完了代码,你已经从代码里面看出来了为什么会死循环以及会导致死循环的原因了,那么说明你已经懂了。 |
二、代码段分析
从代码段不难看出,这段代码的初衷以及期望运行逻辑为:
1 | 1)父组件 App 将 value 和 onChange 方法传入子组件。 |
逻辑思路也很清晰,目的也很明确,看起来也没啥问题的。
然而,事实上它就是导致了死循环,完整测试代码如下(可能需要翻墙,打不开就算了):
codesandbox代码段实验
三、原因分析
接下来我们将从 useEffect、useState 入手,从他们的生命周期、执行顺序分析一下。
1.useEffect Hook 特性
1 | <!--官方文档原文--> |
在上面代码段中,useEffect 其实际执行时机类似于在 componentDidMount 和 componentDidUpdate 方法执行的时候执行。
1 | <!--官方文档原文--> |
注意,useEffect 并不完全等同于上面三个生命周期函数,其不一样的地方是:
1 | <!--官方文档原文--> |
也就是说 useEffect 是一个异步操作(也有人说是类似于异步宏任务)
当组件里面有多个 useEffect 的时候,其执行顺序为按照其声明顺序依次执行。
1 | <!--官方文档原文--> |
因此不难看出,如上代码段中,当 ViewItem 组件初次渲染到 DOM 中之后,会分别顺序触发 useEffect1
和 useEffect2
。(其中,useEffect1 中会执行 setValueObj({ a: 1 })
)。
2.useState Hook 特性
上面代码段中,useEffect1
的内部执行是一个 setState,所以setValueObj
修改 valueObj 的值不会立即生效。
1 | <!--官方文档原文--> |
所以,setState 可以看做是一个事件通知
当调用 setValueObj 的时候,valueObj 的值的变更不会立即生效,而是会产生一次通知,只会产生一个事件队列(类似于监听-观察者模式)。
1 | <!--官方文档原文--> |
3.执行顺序分析
有了如上两个 Hook 方法的分析基础,接下来就可以对代码段的执行顺序进行详细分析了。
主要包含3个步骤。
第1步:初次渲染
当组件被挂载到 DOM 之后,会触发两个 useEffect。
先执行 useEffect1,会触发 setValueObj,此操作会产生一个 state 更新事件,产生一次计划 UI 更新(注意:此时并不会立即修改valueObj的值)。
再执行 useEffect2,此时会对 value 和 valueObj 的值进行比较(JSON.stringify之后比较字符串)
所以,在比较的时候,其实际上是下面两个值的比较:
1 | JSON.stringify({ a: 1 }) // value,此为App父组件传入的值 |
很显然,二者不相等,于是触发 onChange({ a: 99999 })
。
onChange 同步执行,会立即调用父组件 App 的 setValue
方法,
此方法同样是一个 setState,会产生一个 state 更新事件,产生一次计划 UI 更新。
至此,我们 React更新队列中就有了两个更新计划,前面 “useState hook” 分析中有说明,出于性能考虑,React 可能会把多个 setState() 调用合并成一个调用。
因此,接下来会执行合并之后 state 的UI渲染,两次计划 UI 更新一起执行。
第2步:合并渲染
经过第一步之后,会合并前面的两次 setState 触发的 UI 更新计划,进行一轮新的综合性的组件 UI 更新。
此时,value 的最新值为 onChange 传出来的 { a: 99999 }
valueObj 的最新值为 setValueObj({ a: 1 }) 执行的时候设置的 { a: 1 }
值。
不难看出来 value 和 valueObj 都产生了变化
旧值为
1 | value => {a: 1} |
新值为
1 | value => {a: 99999} |
二者进行了互换。
第3步:useEffect 依赖更新
从第二步可以看出两个 useEffect 的依赖项都和原来不一样了,都发生了变化。
而根据 useEffect 第二个依赖数组的特性可以推断出,依赖项的变化一定会导致 useEffect 的执行。
因此,此依赖更新同样会触发 useEffect1
和useEffect2
这两个useEffect。
并且,这波更新除了 value 和 valueObj 的值产生了互换之外,和第一步(初次渲染的时候)完全一样。
所以,我们不难推断出,接下来同样会产生两次 setState 触发的 UI 更新计划。
而这次更新的结果就是 value 和 valueObj 的值的再次互换
。
互换之后又将触发 useEffect 依赖项的变化
。
至此,死循环形成,生生不息,永无止境,直至浏览器卡死。
以上上就是产生死循环的原因了。
四、解决办法
既然知道原因,解决起来就好办了,想办法解除死循环即可。
最好的解决办法就是修改代码逻辑,将setValueObj
的操作移出去,不要在组件内部变更。
让组件只安心做渲染的事情,当 value 的值发生变化的时候,直接调用 onChange 将数据传出去,在外部统一处理。
当然,这样改动比较大,内部很多依赖逻辑都需要改,因此,我在这里采用了一个取巧的办法。
从上面的分析我们可以得知,这里导致死循环的直接原因是 setValueObj 的时候 valueObj 的值是异步所致。
因此,最简单粗暴的方式就是在 onChange 比较的时候拿到 valueObj 的实时的值进行比较。
1 | useEffect(() => { |
怎么拿到实时的值呢?
我采用的办法是:定义一个临时变量 valueObjTemp
来保存 valueObj 的值。
即在组件之外定义一个
1 | let valueObjTemp = {} // 也可以在组件内部定义一个 useRef 来存储 |
此变量将临时存储 valueObj 的值,这个值是一个实时的值。
之后在 setValueObj 的同时将其值保存在临时变量 valueObjTemp 下面。
1 | // 原代码: |
再然后,稍微修改一下比较的逻辑
1 | // 原代码: |
经过如上改造,当 useEffect1
执行的时候 valueObjTemp 实时更新,此时就等于 value 的值。
此后执行 useEffect2
的时候,valueObjTemp 和 value 进行比较,显然是相等的,自然也就不再触发 onChange 了。
也就避免了后面的死循环了。
这样改动成本很小,风险也不大,但是毕竟只是打补丁(不可学)。
五、总结
本次事件,出现死循环的直接原因就是 useEffect
和 useState
二者使用的时候没有处理好他们之间的互相依赖关系。
要找到死循环的原因,得先将 useEffect 和 useState 的生命周期和执行顺序搞清楚。
此外,除了直接原因外
其根本原因是代码组织结构的没有组织好,业务组件模块的数据处理没有做好分层,导致数据处理分散。
由于数据处理的分散,之后随着业务逻辑的复杂度的增加,数据处理和更新将会变得越来越麻烦,而这类问题的出现将不可避免。
React 的数据和状态管理一定要做好清晰的管理!
–以上,共勉–