本文已参与「新人创作礼」活动, 一起开启掘金创作之路。
React Diff 算法
在我们了解 diff
算法之前,先了解一下几个点。
-
diff
算法相关的节点。 -
diff
算法中用到的tag
。 -
diff
算法的瓶颈以及优化措施。
diff 算法相关的节点
在 diff
中会涉及到 4 种节点:
-
jsx
对象。 -
Dom
节点本身。 -
current fiber
。当前页面中显示的Dom
元素所对应的fiber
节点。 -
workInProgress fiber
当前页面中,Dom
元素发生变化时所对应的fiber
节点。
Diff
算法就是 jsx
对象与 current fiber
进行对比,生成 workInProgess fiber
的过程。
diff 算法中用到的 tag
Diff
算法就是在 render
阶段对有 Dom
节点对应的 fiber
节点打上 flag
,在 commit
阶段去做 Dom
的操作。
而这些 tag
的定义在 react-reconciler
包中的 ReactFiberFlags.js
中。
// Don't change these two values. They're used by React Dev Tools.
export const NoFlags =/* */0b00000000000000000000000000;
export const PerformedWork =/* */0b00000000000000000000000001;
export const Placement =/* */0b00000000000000000000000010;
export const Update =/* */0b00000000000000000000000100;
export const Deletion =/* */0b00000000000000000000001000;
export const ChildDeletion = / **/0b00000000000000000000010000;
export const ContentReset = /* */0b00000000000000000000100000;
export const Callback = /* */0b00000000000000000001000000;
export const DidCapture = /* */0b00000000000000000010000000;
export const ForceClientRender = /**/0b00000000000000000100000000;
export const Ref = /* */0b00000000000000001000000000;
export const Snapshot = /* */0b00000000000000010000000000;
export const Passive = /* */0b00000000000000100000000000;
export const Hydrating = /* */0b00000000000001000000000000;
export const Visibility = /* */0b00000000000010000000000000;
export const StoreConsistency = /**/0b00000000000100000000000000;
diff 算法的瓶颈以及优化措施
即使再优秀的算法也依旧需要 O(N^3)--> n 是树的节点数。在 Dom
元素非常多的的情况下,对于性能的损耗还是非常的大。
所以 React
在节点比较的时候做了一下的性能优化:
-
不会进行跨层级的比较,只会在同一层级进行比较。
-
必须是同一类型的才会进行比较。比如 更新之前是
div
,更新之后是p
则不会进行diff
比较,p
对应的fiber
直接标记上 Placement。div
的fiber
节点直接标记上Deletion
。 -
开发者可以在元素上增加
key
属性,来告诉 React 在比较过程中,复用节点的位置发生变化。如果没有key
,则根据规则2,直接处理,但是如果有key
则会遍历同级的fiber
节点,查找存在相同key
,并且类型也一样的节点,如果找到则可以复用。
Diff 算法解析
从上面省略后的代码可以看出来,我们将节点的比较分为复杂节点与简单节点。两者的区别就是是否为纯文本节点,如果是纯文本节点,执行 reconcileSingleTextNode
, 如果不是则进入复杂节点的比较,这其中又分为 单一节点与多节点的比较。单一节点执行 placeSingleChild
,多节点执行 reconcileChildrenArray
。下面我们进行详细的描述。
流程概览
纯文本节点
源码
首先会进行判断,如果 current fiber
存在并且 current fiber
的 tag
是根节点,那么此时我们可以复用这个 textNode
,调用 deleteRemainingChildren
将其他的兄弟节点标记为 ChildDeletion
如果不能复用的话,则删除兄弟节点,创建新的 fiber
节点。
单节点
源码
分析
在单节点中我们分为 3中情况:
-
jsx
对象不存在对应的current fiber
。 -
current fiber
存在,并且key
相同。 -
jsx
与current fiber
的type
不同,调用deleteRemainingChildren
。跳出本轮循环。 -
jsx
与current fiber
的type
相同,调用deleteRemainingChildren
。返回useFiber
复用上一次的节点。 -
current fiber
存在,并且key
不相同。则创建新的fiber
节点。
流程图
3种情况详解
1、current fiber
不存在的情况,根据 element.type
创建对应的 fiber
节点。
-
如果
type
为REACT_FRAGMENT_TYPE
即fragment
,则通过createFiberFromFragment
创建新的fiber
节点。 -
否则调用
createFiberFromElement
创建新的fiber
节点。
2、 current fiber
存在,判断 jsx
与 current fiber
的 key
与 type
的情况。
-
判断
key
是否相等。 -
如果
key
不相同,调用deleteChild
,删除当前的节点,进入下一个循环.child = child.sibing
. -
如果
key
相同,判断jsx
对象的type
与current fiber
的type
是否相等。 -
如果
type
相同,代表这个节点是可以复用的。 使用useFiber
复用原有的current fiber
节点。 -
如果
type
不相同,代表这个节点不能复用,因为在判断type
是否相等的前提是key
是否相同。key 相同,type
不同,必定没有可复用的节点,所以调用deleteRemainingChildren
将同级的current fiber
节点全部标记为删除,并退出循环。执行创建新fiber
节点的方法。
示例
1、current fiber
不存在的情况
const before = ‘’;
const afteer = <div key="afteer"></div>;
2、current fiber
存在,但是 key
不相同。
const before = <div key="a"></div>
<div key="c"></div>;
const afteer = <div key="b"></div>;
3、current fiber
存在,key
相同,type
不同。
const before = <div key="b"></div>;
const afteer = <p key="b"></p>;
3、current fiber
存在,key
相同,type
相同。(可复用)
const before = <div key="b">b</div>;
const afteer = <div key="b">a</p>;
多节点
源码
分析
在多节点的比较中,我们可以分为几种情况:
-
节点的更新。
-
节点的新增。
-
节点的删除。
-
节点位置的变化。
在上述几种情况中,节点更新最为频繁,所以 react
官方将多节点的 diff
分为两次循环:
第一次循环:针对于节点更新的情况,如果遇到不能够复用的情况,则跳出循环,或者新的 fiber
节点遍历结束跳出循环。
第二次循环:针对 oldFiber
没有处理完, newFiber
也没有处理完,其中包括节点位置移动的情况。这个时候会遍历剩下的 fiber
节点。
流程图
详细分析
变量分析:
-
resultingFirstChild
。diff
完以后的第一个fiber
节点。是链表结构的头。 -
previousNewFiber
中间变量保存遍历过程中的newChild
。 -
oldFiber
存储当前diff
节点的current fiber
节点。 -
lastPlacedIndex
. 用于在节点存在移动情况下,比较位置的基准节点。 -
newIdx newChild
的节点在数组中的位置。 -
nextOldFiber
下一次需要比较的current fiber
节点。
第一次遍历(更新阶段):
-
current fiber(oldFiber)
链表与jsx
数组,的每一项进行比较。 -
特殊情况:
jsx
中可能存在falsy
元素所以需要进行判断。如果旧的fiber.index
比newIdx
大,代表当前的oldFiber
之前存在falsy
元素。即节点位置不对应的情况。我们需要将本次oldFiber fiber
的对比放到下次,将oldFiber
置为null
。 -
否则我们
nextOldFiber
就等于oldFiber.sibling
. -
接下来调用
updateSlot
方法判断当前的节点是否可以被复用,保存在变量newFiber
中。 -
如果
newFiber == null
代表节点不可以复用,则直接退出第一轮循环。 这里也有一个特殊情况与上面相对应,oldFiber
节点不可以复用并且oldFiber == null
,oldFiber
重新赋值为nextOldFiber
。
如果 newFiber !== null
,一直到 newIdx == newChildren.length
,新的节点全部遍历完成,退出第一轮循环。
在不跳出循环的情况下, 并且是更新的情况下,oldFiber
存在并且 newFiber
没有复用,则删除 oldFiber
。
总结:
在第一轮遍历中,我们判断oldFiber.index
与 newChild[newIdx]
之间比较。
结束的条件有两种:
-
没有可以复用的节点,即
newFiber == null
; -
newFiber
全部都可以复用,正常遍历完成。
记录最新的更新位置。
- 方便第二次遍历中,节点位置变化的情况。
第一次循环完,第二次循环前
-
新的
newChildren
遍历完,我们删除其余的oldFiber
节点,返回第一个diff
之后链表的头。
- 如果
oldFiber == null
,代表旧的fiber
已经遍历完,newChild
剩下的元素全部重新创建,返回第一个diff
之后链表的头。
第二次循环
到这里还没有 return
代表 newChild
没有遍历完,oldFiber
也没有遍历完,意味着此时存在以下几种可能:
-
节点的位置变化。
-
节点的新增。
-
节点的删除。
接下来我们看过程:
-
为了能够在提升效率,我们将
oldFiber
中剩余的节点放到new Map
结构中,结构中的key
是oldFiber
的key
,没有key
则使用index
。结构中的value
是oldFiber
节点。 -
遍历
newChildren
,从newChildren
中取出来key
,没有key
,使用newIdx
从 上面new Map
的结构中取出oldFiber
, 赋值给_newFiber2
。 -
如果
_newFiber2 !== null
并且_newFiber2.alternate !== null
代表节点可以复用,new Map
中对应的元素标记为删除。
在可复用的情况下,我们之前提到的 lastPlaceIndex
就起到作用了,在之前更新阶段我们拿到最后一次更新的 oldFiber
的节点位置,在第二次循环中,我们需要判断当前遍历的 newChildren[newIdx]
对应 oldFiber.index
的位置 与 lastPlaceIndex
位置比较,如果 oldIndex < lastPlacedIndex
,那代表本次的 newFiber
需要移动。否则不需要移动,直接返回 oldIndex
。
newChildrren
遍历完成,将剩余的 new Map
的元素标记为删除。
总结:
-
在二次循环针对,节点新增,节点删除,节点位置的变化。
-
在位置变化中,通过
new Map
结构存储oldFiber
剩余的节点。 -
遍历
newChildren
数组中剩余的元素,去new Map
结构中获取对应的节点。 -
如果有对应的节点,证明节点是可服用并且是移动过的节点,将
oldFiber
标记删除,并在新的fiber
上判断是否需要移动。 -
是否移动的标准就是当前
oldIndex
与lastPlacedIndex
的大小。 -
oldIndex
比lastPlaceIndex
小,则标记为需要插入。 -
否则,不需要做任何处理。