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