补充
根据同级的节点数量将 Diff 算法分为两类,单节点 Diff 和多节点 Diff:
- 当 newChild 类型为 object、number、string,代表同级只有一个节点,会调用单节点 Diff 函数 reconcileSingleElement 函数。
- 当 newChild 类型为 Array,表示同级有多个节点,会调用多节点 Diff 函数 reconcileChildrenArray 函数。
function reconcileChildFibers( returnFiber: Fiber, currentFirstChild: Fiber | null, newChild: any, ): Fiber | null { const isObject = typeof newChild === 'object' && newChild !== null; if (isObject) { // object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE switch (newChild.$typeof) { case REACT_ELEMENT_TYPE: // 调用 reconcileSingleElement 处理 // ...其他case } } if (typeof newChild === 'string' || typeof newChild === 'number') { // 调用 reconcileSingleTextNode 处理 } if (isArray(newChild)) { // 调用 reconcileChildrenArray 处理 } // 以上都没有命中,删除节点 return deleteRemainingChildren(returnFiber, currentFirstChild); }
首先是单点diff
function reconcileSingleElement( returnFiber: Fiber, currentFirstChild: Fiber | null, element: ReactElement ): Fiber { const key = element.key; let child = currentFirstChild; // 首先判断是否存在对应DOM节点 while (child !== null) { // 上一次更新存在DOM节点,接下来判断是否可复用 // 首先比较key是否相同 if (child.key === key) { // key相同,接下来比较type是否相同 switch (child.tag) { // ...省略case default: { if (child.elementType === element.type) { // type相同则表示可以复用 // 返回复用的fiber return existing; } // type不同则跳出switch break; } } // 不能复用的节点,被标记为删除 deleteRemainingChildren(returnFiber, child); break; } else { // key不同,将该fiber标记为删除 deleteChild(returnFiber, child); } child = child.sibling; } // 创建新Fiber,并返回 ...省略 }
多节点 Diff
对于同级有多个元素的节点,Diff 算法要处理的情况相对复杂,但可以总结归纳为三种情况,第一种为节点新增或减少,第二种为节点更新,第三种为节点位置发生变化。
多节点 Diff 第一轮遍历
第一轮遍历的目的是处理更新节点,处理流程图如图所示,其中的 newChildren 为被转译后的 JSX 对象,orderFiber 为链表结构的 current Fiber 树。
// 更新之前 <div key="id1" className="a">1</div> <div key="id2" className="b">2</div> // 更新之后情况1 newChildren与oldFiber都遍历完 <div key="id1" className="aa">1</div> <div key="id2" className="bb">2</div> // 更新之后情况2 newChildren没遍历完,oldFiber遍历完 <div key="id1" className="aa">1</div> <div key="id2" className="bb">2</div> <div key="id3" className="cc">3</div> // 更新之后情况3 newChildren遍历完,oldFiber没遍历完 <div key="id1" className="aa">1</div>
结果一:可能 newChildren 遍历完,或 oldFiber 遍历完,或他们同时遍历完。
- newChildren 与 oldFiber 同时遍历完,如更新之后情况 1。
这是最理想的情况,只需在第一轮遍历进行组件更新,此时 Diff 结束。
- newChildren 没遍历完,oldFiber 遍历完,如更新之后情况 2。
因为 oldFiber 树遍历完,所以已有的 DOM 节点都复用了,同时 newChildren 没遍历完,表示有新加入的节点,我们只需要遍历剩下的 newChildren,并生成新的 workInProgress fiber 节点,依次标记 Placement,此时 Diff 结束。
- newChildren 遍历完,oldFiber 没遍历完,如更新之后情况 3。
newChildren 遍历完,同时 oldFiber 没遍历完,意味着更新之后的节点比更新之前的节点数量少了,即有节点被删除了。所以需要遍历剩下的 oldFiber,依次剩余的 olderFiber 节点标记 Deletion,此时 Diff 结束。
结果二:因为 key 不同跳出的遍历,表示此时的 newChildren 没有遍历完,oldFiber 也没有遍历完。
例如
//更新之前 <div key="id1">1</div> <div key="id2">2</div> <div key="id3">3</div> //更新之后 <div key="id1">1</div> <div key="id3">3</div> <div key="id2">2</div>
newChildren 与 oldFiber 都没遍历完,这意味着有节点在这次更新中改变了位置,需要进行第二轮遍历。
多节点 Diff 第二轮遍历
因为第二轮遍历需要处理改变位置的节点,所以为了方便找到拥有同一个 key 的 newChildren 和 olderFiber 节点,react 将所有还未处理的 oldFiber 存入以 key 为 key,oldFiber 为 value 的 Map 中。
const existingChildren = mapRemainingChildren(returnFiber,oldFiber)
接下来遍历剩余的 newChildren,通过 newChildren[i].key 就能很快在 existingChildren 中找到 key 相同的 oldFiber 节点。
因为有节点的位置发生了移动,所以需要选一个参照的节点,来判断其他的节点是否移动,React 选定最后一个可复用的节点为参照物,记下其在 oldFiber 中的位置索引,用变量 lastPlacedIndex 表示。
由于更新中节点是按 newChildren 的顺序排列,所以在遍历 newChildren 过程中,每个遍历到的可复用节点一定是当前遍历到的所有可复用节点中最靠右的那个。
用变量 oldIndex 表示遍历到的可复用节点在 oldFiber 中的位置索引,如果 oldIndex < lastPlacedIndex,代表本次更新该节点需要被移动(标记为 Placement),oldIndex > lastPlacedIndex 代表该节点不需要被移动。
其中,lastPlacedIndex 初始为 0,更新 lastPlacedIndex 规则为:每遍历一个可复用的节点,如果 oldIndex >= lastPlacedIndex,则更新 lastPlacedIndex 为 oldIndex,否则,不更新。
下面通过一个例子阐述下整个 Diff 过程,为了简化 Diff 过程,其中的 ABCD 表示 DOM 树的节点,字母的值表示节点的 key。
如上图所示,app 节点下有顺序为 ABCD 四个节点,若将 app 节点下变为 ADBE,Diff 算法会经历下面的过程。
1)第一轮遍历
第一轮遍历 newChildren 开始,节点 A 可复用,此时的 workInProgress Fiber 树为
继续遍历下一个节点,因 newChildren 第二个节点 D 和 oldFiber 中下一个节点 B 的 key 不相同跳出第一轮遍历,进行第二轮遍历。
第二轮遍历
此时的 olderFiber 和 newChildren 如下:
此时 lastPlacedIndex 为 0,Map 为{B:B,C:C,D:D},接下来继续遍历 newChildren 的下一个节点为 D,key 为 D 的节点在 Map 中存在,D 节点的 oldIndex 为 3,oldIndex > lastPlacedIndex,D 节点可以复用 ,位置也不需要移动。
此时的 workInProgress Fiber 树为:
更新 lastPlacedIndex 为 3,此时 Map 为{B:B,C:C},继续遍历 newChildren 下一个节点 B,key 为 B 的节点在 Map 中存在,B 节点的 oldIndex 为 1,oldIndex < lastPlacedIndex,所以 B 节点可复用,但是需要移动,所以为 B 节点打上 Placement 标签。
此时的 workInProgress Fiber 树为:
lastPlacedIndex 依旧为 3,此时 Map 为{C:C},继续遍历 newChildren 下一个节点 E,key 为 E 的节点在 Map 中不存在,此时就需要新建 E 节点,并为 E 节点打新增标签。
此时的 workInProgress Fiber 树为:
newChildren 遍历完成,此时 Map 为{C:C},下一步将 older Fiber 中的 C 节点打上 Deletion 标签,然后依次收集打上标签的节点,首先会将同级节点上需要删除的节点收集起来,然后依次收集其他打上标签的节点,收集好的节点连接成链表结构,如下图: