简介
diff 算法在 React 中处于主导地位, 是 React V-dom 和渲染的性能保证, 这也是 React 最有魅力, 最吸引人的地方.
React 一个很大一个的设计有点就是将 diff 和 V-dom 的完美结合, 而高效的 diff 算法可以让用户更加自由的刷新页面, 让开发者也能远离原生 dom 操作, 更加开心的撸代码.
但总所周知, 普适 diff 的复杂度对于大量 dom 对比会出现严重的性能问题, React 团队对 diff 的优化可以让 React 能够在服务端渲染, 到底 React 的 diff 做了什么优化呢? 本文来简单探讨一下!
React 的 diff 策略
策略一: 忽略 web UI 中 DOM 节点跨层级移动;
策略二: 拥有相同类型的两个组件产生的 DOM 结构也是相似的, 不同类型的两个组件产生的 DOM 结构则不近相同
策略三: 对于同一层级的一组子节点, 通过分配唯一唯一 id 进行区分(key 值) 在 Web UI 的场景下, 基于以上三个点, React 对 tree diff,component diff,element diff 进行优化, 将普适 diff 的复杂度降低到一个数量级, 保证了整体 UI 界面的构建性能!
三个优化
tree diff
基于策略一, React 的做法是把 dom tree 分层级, 对于两个 dom tree 只比较同一层次的节点, 忽略 Dom 中节点跨层级移动操作, 只对同一个父节点下的所有的子节点进行比较. 如果对比发现该父节点不存在则直接删除该节点下所有子节点, 不会做进一步比较, 这样只需要对 dom tree 进行一次遍历就完成了两个 tree 的比较.
== 那么对于跨层级的 dom 操作是怎么进行处理的呢?== 下面通过一个图例进行阐述
两个 tree 进行对比, 右边的新 tree 发现 A 节点已经没有了, 则会直接销毁 A 以及下面的子节点 B,C; 在 D 节点上面发现多了一个 A 节点, 则会重新创建一个新的 A 节点以及相应的子节点.
具体的操作顺序: create A create B creact C delete A.
优化建议
保证稳定 dom 结构有利于提升性能, 不建议频繁真正的移除或者添加节点
component diff
React 应用是基于组件构建的, 对于组件的比较优化侧重于以下几点:
1. 同一类型组件遵从 tree diff 比较 v-dom 树 2. 不通类型组件, 先将该组件归类为 dirty component, 替换下整个组件下的所有子节点 3. 同一类型组件 Virtual Dom 没有变化, React 允许开发者使用 shouldComponentUpdate()来判断该组件是否进行 diff, 运用得当可以节省 diff 计算时间, 提升性能
如上图, 当组件 D 组件 G 时, diff 判断为不同类型的组件, 虽然它们的结构相似甚至一样, diff 仍然不会比较二者结构, 会直接销毁 D 及其子节点, 然后新建一个 G 相关的子 tree, 这显然会影响性能, 官方虽然认定这种情况极少出现, 但是开发中的这种现象造成的影响是非常大的.
优化建议
对于同一类型组件合理使用 shouldComponentUpdate(), 应该避免结构相同类型不同的组件
element diff
对于同一层级的 element 节点, diff 提供了以下 3 种节点操作:
1. INSERT_MARKUP 插入节点: 对全新节点执行节点插入操作 2. MOVE_EXISING 移动节点: 组件新集合中有组件旧集合中的类型, 且 element 可更新, 即组件调用了 receiveComponent, 这时可以复用之前的 dom, 执行 dom 移动操作 3. REMOVE_NODE 移除节点: 此时有两种情况: 组件新集合中有组件旧集合中的类型, 但对应的 element 不可更新, 旧组建不在新集合里面, 这两种情况需要执行节点删除操作
key 值 diff 中重要性
一般 diff 在比较集合 [A,B,C,D] 和[B,A,D,C]的时候会进行全部对比, 即按对应位置逐个比较, 发现每个位置对应的元素都有所更新, 则把旧集合全部移除, 替换成新的集合, 如上图, 但是这样的操作在 React 中显然是复杂, 低效, 影响性能的操作, 因为新集合中所有的元素都可以进行复用, 无需删除重新创建, 耗费性能和内存, 只需要移动元素位置即可. React 对这一现象做出了一个高效的策略: 允许开发者对同一层级的同组子节点添加唯一 key 值进行区分. 意义就是代码上的一小步, 性能上的一大步, 甚至是翻天覆地的变化!
== 重点来了, React 通过 key 是如何进行 element 管理的呢? 为何如此高效?==
算法改进:
React 会先进行新集合遍历, for(name in nextChildren), 通过 key 值判断两个对比集合中是否存在相同的节点, 即 if(prevChild === nextChild), 如何为 true 则进行移动操作, 在此之前, 需要执行被移动节点在新旧 (child._mountIndex) 集合中的位置比较, if(child._mountIndex <lastIndex)为 true 时进行移动, 否则不执行该操作, 这实际上是一种顺序优化, lastIndex 是不断更新的, 表示访问过的节点在集合中的最右的位置. 若当前访问节点在旧集合中的位置比 lastIndex 大, 即靠右, 说明它不会影响其他元素的位置, 因此不用添加到差异队列中, 不执行移动操作, 反之则进行移动操作.
下图示例:
nextIndex = 0,lastIndex = 0, 从新集合中获取 B, 在旧集合中发现相同节点 B, 旧集合中: B._mountIndex = 1,child._mountIndex < lastIndex ==> false, 不执行移动操作, 更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex), prevChild._mountIndex === B._mountIndex ==> true, 更新 B 在新集合中的位置: prevChild._mountIndex = nextIndex, 在新集合中: B._mountIndex = 0,nextIndex++, 进行下一个节点判断.
nextIndex = 1,lastIndex = 1, 从新集合中获取 A, 在旧集合中发现相同节点 A, 旧集合中: A._mountIndex = 0,child._mountIndex <lastIndex ==> true, 对 A 进行移动操作 enqueueMove(this, child._mountIndex, toIndex),toIndex 是 A 要被移动到的位置, 更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex), 更新 A 在新集合中的位置 prevChild._mountIndex = nextIndex, 在新集合中: A._mountIndex = 1,nextIndex++, 进行下一个节点判断.
nextIndex = 2,lastIndex = 1, 从新集合中获取 D, 在旧集合中发现相同节点 D, 旧集合中: D._mountIndex = 3,child._mountIndex <lastIndex ==> false, 不执行移动操作, 更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex), prevChild._mountIndex === D._mountIndex ==> true, 更新 D 在新集合中的位置: prevChild._mountIndex = nextIndex, 在新集合中: D._mountIndex = 2,nextIndex++, 进行下一个节点判断.
nextIndex = 3,lastIndex = 3, 从新集合中获取 C, 在旧集合中发现相同节点 C, 旧集合中: C._mountIndex = 2,child._mountIndex <lastIndex ==> true, 对 C 进行移动操作 enqueueMove(this, child._mountIndex, toIndex),toIndex 是 C 要被移动到的位置, 更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex), 更新 C 在新集合中的位置 prevChild._mountIndex = nextIndex, 在新集合中: A._mountIndex = 3,nextIndex++, 进行下一个节点判断.
由于是最后一个节点, diff 操作完成
== 那么, 除了有可复用节点, 新集合当有新插入节点, 旧集合有需要删除的节点呢?==
下图示例:
对于这种情况, React 则是执行以下步骤:
nextIndex = 0,lastIndex = 0, 从新集合中获取 B, 在旧集合中发现相同节点 B, 旧集合中: B._mountIndex = 1,child._mountIndex <lastIndex ==> false, 不执行移动操作, 更新 lastIndex = 1, 更新 B 在新集合中的位置, nextIndex++, 进行下一个节点判断.
nextIndex = 1,lastIndex = 1, 从新集合中获取 E, 在旧集合中没有发现相同节点 E,nextIndex++ 进入下一个节点判断.
nextIndex = 2,lastIndex = 1, 从新集合中获取 C, 在旧集合中发现相同节点 C, 旧集合中: C._mountIndex = 2,child._mountIndex <lastIndex ==> false, 不对 C 进行移动操作, 更新 lastIndex = 2, 更新 C 在新集合的位置, nextIndex++, 进行下一个节点判断.
nextIndex = 3,lastIndex = 2, 从新集合中获取 A, 在旧集合中发现相同节点 A, 旧集合中: A._mountIndex = 0,child._mountIndex <lastIndex ==> true, 对 A 进行移动操作, 更新 lastIndex = 2, 更新 A 在新集合中的位置, nextIndex++ 进入下一个节点判断.
当完成新集合所有节点中的差异对比后, 对旧集合进行遍历, 判读旧集合中是否存在新集合中不存在的节点, 此时发现 D 节点符合判断, 执行删除 D 节点的操作, diff 操作完成.
优化后 diff 的不足
世上没有百分之百完美算法, React 的 diff 也有自己的不足之处, 比如新旧集合元素全部可以复用, 只是新集合中将旧集合最后一个元素放到了第一个位置, 短板就会出现 下图示例:
按照上述顺序优化, 则旧集合中 D 的位置是最大的, 最少的操作只是将 D 移动到第一位就可以了, 实际上 diff 操作会移动 D 之前的三个节点到对应的位置, 这种情况会影响渲染的性能.
优化建议
在开发过程中, 同层级的节点添加唯一 key 值可以极大提升性能, 尽量减少将最后一个节点移动到列表首部的操作, 当节点达到一定的数量以后或者操作过于频繁, 在一定程度上会影响 React 的渲染性能. 比如大量节点拖拽排序的问题.
总之, React 为我们提供优秀的 diff 算法, 使我们能够在实际开发中 happy 的撸代码, 但也不是说可以 "随意" 去构建我们的应用, 根据 diff 的特点, 在具体场景中取长补短, 规避一些算法上面的短板也是有利于提升应用整体的性能.
参考资料:
深入 React 技术栈陈屹 --3.5 章节
来源: https://juejin.im/post/5ac355576fb9a028cc616aad