一切始于一个星期前的某一天......
同事新写了一个组件,我们称其为 A,A 组件接收一个属性,类型定义为字符串,用代码描述就是:
function A({ title }) {
// Some operations...
return <div>{title}</div>
}
我们使用这个组件的时候,title
传入一个 string
,没有任何问题。但是如果我们仔细想想,在没有严格类型约束的情况下,title
其实也可以传入 React Element
,因为 {}
这对魔法符号接受任何表达式。然而同事就是想试一下这么一个操作,出问题了。
现场复原
伪代码:
function App() {
const initialFileList = [{ name: 'file1' }, { name: 'file2' }...];
const fileList = showAll ? fileList : _.cloneDeep(fileList.slice(0, 5))
// Some operations...
return <div>
{fileList.map(file => <A title={file.name} />}
</div>
}
看起来是一个很简单的操作,fileList
最初的设定类型是 {name: string}[]
,但是在实际使用组件 A 的过程当中,发现有一些组件需要自定义 title
的显示样式,同事看 A 组件好像没有具体的判断,就顺手写了一个
[...{ name: <h2>fileN</h2> }...]
上去,然后页面就卡死了,同时有一条警告瞬间拉长了整个控制台。
这是为什么呢?
我们如果平时写代码稍微有一些性能方面考虑的话,就会发现这里有一个 cloneDeep
操作(注:这里是使用的是 lodash
的 cloneDeep
),实现过深度拷贝的开发者肯定知道 cloneDeep
是一个有递归调用的操作;而 React
为了性能优化考虑,在 Fiber
树中,除了父节点记录子节点以外,还会同时向子节点挂载父节点属性,这样就会出现 循环依赖,如果我们尝试对一个 FiberNode
调用 JSON.stringify
,会有类似以下的错误产生:
Uncaught TypeError: Converting circular structure to JSON
--> starting at object with constructor 'Ol'
| property 'stateNode' -> object with constructor 'HTMLDivElement'
--- property '__reactInternalInstance$k9qbtblfr5' closes the circle
at JSON.stringify (<anonymous>)
at <anonymous>:1:6
这里就是表明在当前要 stringify
的对象上产生了循环依赖,导致操作无法终止。
再看上面,当 name
修改为 React Element
之后进行 cloneDeep
操作,初步判断可能会有循环依赖产生。在让同事删掉 cloneDeep
后,页面果然正常了。然后询问了一下当时写这段 cloneDeep
操作的同事是处于什么考虑这样写,同事也已经忘了......
(注:去询问是因为这里 fileList
是纯粹的展示数据,没有必要进行拷贝操作)
至此,问题就解决了,然后周末过去了......
=========== 下一周开始的分割线 =============
周三到公司之后,又回忆了一下这个问题,想到当时去问同事为什么这么写的时候,同事虽然忘了,但是说了一句话:“cloneDeep
对于循环依赖是有处理的啊!“。粗略想了一下......大概是有处理的吧...... 于是去翻了一下 lodash
的 cloneDeep
,发现果然有 处理,那就奇怪了,为什么页面还会卡死呢?
第二次尝试
于是从头开始,再仔细回顾一下这个问题。
首先尝试用最小量代码复现这个问题,用 createReactApp
创建一个 demo
,启动后调用 cloneDeep
,发现元素被正常打印出来了!这说明对 React Element
调用 cloneDeep
并不会有循环依赖问题。那为什么我们的页面会卡死呢?难道跟辅助库有关?毕竟 demo
没有辅助库而我们项目用了很多,于是尝试向 demo
项目加入 react-redux
,再次宣告失败。又想加入 react-router
,最后再努力一把,但是内心冷静了一下,觉得应该不太可能是 react
上的问题了,于是又回到了原点。
这个时候又启动了我们的项目,然后复现问题,页面再次卡死(崩溃中...... = =)。经历过上面两个小时尝试复现问题无果的折磨之后,我的脑子已经转不动了,看着控制台的警告,不知道该从哪里另起头绪。就在这个时候,手突然抖了一下,鼠标按到了其它位置,然后慢慢地......慢慢地......页面跳转了过去......我顿时表演一个垂死病中惊坐起!
所以页面并没有卡死,只是度过了一段很长的 JavaScript
执行时间,导致我们误以为页面进入了死循环。
那是哪一段比较耗时呢?一下就可以猜到了吧!
所以这个时候来看一下 cloneDeep
到底有多耗时,先在 demo
中进行一次,结果显示:
// cloneDeep takes: 1.08203125 ms
cloneDeep(<div>title</div>)
再回到我们的项目,打印同样一行 cloneDeep
操作,结果是:
// cloneDeep takes: 2320.087158203125 ms
而在我们项目热更新的过程当中,一个组件可能会渲染多次,这个耗时会成倍的增长,甚至达到分钟的级别,所以我们才 以为页面卡死了,其实是假死了。在 cloneDeep
操作结束之后,页面还是可以正常响应了。
耗时分析
那为什么 demo
项目中只需要 1ms
,而我们的项目需要 2s
以上呢?这就要看 FiberNode
了。
一个 FiberNode
会存储很多属性,比如 child
、return
、sibling
、stateNode
等等,这些属性可能又是上层或者下层 FiberNode
,如果组件层级嵌套很深,那么在拷贝的过程中,cloneDeep
就会向上或向下遍历整棵 Fiber Tree
,虽然有为循环依赖做的缓存,但是遍历过程依旧非常耗时,这样 JavaScript
一直占据着主线程,所以页面呈现出了假死的状态。(想尝试的同学可以从一个深层级的组件循着 return 属性向上找,可能会有意外的收获哦~)
结语
至此问题算是调查清楚啦。虽然最后反馈给同事问题的真正情形和原因之后只收获了一句“哦”,但查问题的过程还是很有意思的~
如果要总结一下的话,那可能就是:
- 查问题之前先搞清楚问题到底是什么(卡死还是假死)
- 慎用危险操作(
cloneDeep
一类内部有递归的操作) - 冷静,莫要病急乱投医!(竟然怀疑
react-redux
有问题(>人<;))
吧......