一切始于一个星期前的某一天......

同事新写了一个组件,我们称其为 AA 组件接收一个属性,类型定义为字符串,用代码描述就是:

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 操作(注:这里是使用的是 lodashcloneDeep,实现过深度拷贝的开发者肯定知道 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 对于循环依赖是有处理的啊!“。粗略想了一下......大概是有处理的吧...... 于是去翻了一下 lodashcloneDeep,发现果然有 处理,那就奇怪了,为什么页面还会卡死呢?

第二次尝试

于是从头开始,再仔细回顾一下这个问题。

首先尝试用最小量代码复现这个问题,用 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 会存储很多属性,比如 childreturnsiblingstateNode 等等,这些属性可能又是上层或者下层 FiberNode,如果组件层级嵌套很深,那么在拷贝的过程中,cloneDeep 就会向上或向下遍历整棵 Fiber Tree,虽然有为循环依赖做的缓存,但是遍历过程依旧非常耗时,这样 JavaScript 一直占据着主线程,所以页面呈现出了假死的状态。(想尝试的同学可以从一个深层级的组件循着 return 属性向上找,可能会有意外的收获哦~)

结语

至此问题算是调查清楚啦。虽然最后反馈给同事问题的真正情形和原因之后只收获了一句“哦”,但查问题的过程还是很有意思的~

如果要总结一下的话,那可能就是:

  1. 查问题之前先搞清楚问题到底是什么(卡死还是假死)
  2. 慎用危险操作cloneDeep 一类内部有递归的操作)
  3. 冷静,莫要病急乱投医!(竟然怀疑 react-redux 有问题(>人<;))

吧......