Diff比较的是什么?
function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) { ... }
在React中, diff由reconcileChildren函数实现, 我们来看看它的参数:
- workInProgress: 当前处理中的
FiberNode。 - current: 老的fiber树中对应的
FiberNode,可能为null。 - nextChildren:
JSX对象或数组,JSX也就是ReactElement。
renderLanes跟优先级相关,这里不用关注。
Diff 算法 其实是比较老的 FiberNode 和新的 ReactElement 来生成新的 FiberNode 的一个过程:
nextChildren的来源:
- 如果
workInProgress节点对应的是类组件(ClassComponent), 通过class的render方法得到。 - 如果
workInProgress节点对应的是函数组件FunctionComponent, 通过组件函数运行后得到。 - 其它情况大都来自
workInProgress.pendingProps.children, 也就是props中的children属性。
Diff 分类
Diff 分为: 单节点 Diff和多节点 Diff。
这里的单节点和多节点指的是: nextChildren是ReactElement对象(一个)或ReactElement数组(多个)。
<div>
<div>单节点</div>
</div>
<div>
<div>多节点</div>
<div>多节点</div>
<div>多节点</div>
</div>
单节点 Diff
current子节点为空
current的子节点中有可复用的节点, 当FiberNode和ReactElement的type和key都相同,我们认为其可以复用。 可复用的节点并不代表对应的dom节点完全不改变,有可能会有属性的更新,通常会被打上Update标签。

current的子节点中没有可复用的节点
多节点 Diff
但是React团队发现,在日常开发中,相较于新增和删除,更新组件发生的频率更高。所以Diff会优先判断当前节点是否属于更新。
Diff算法的整体逻辑会经历两轮遍历:
第一轮遍历:处理更新的节点。 第二轮遍历:处理剩下的不属于更新的节点。
第一轮遍历
- 依次比较
nextChildren和current的子节点, 判断DOM节点是否可复用。 - 如果可复用, 则继续。
- 如果不可复用,分两种情况:
key不同导致不可复用,立即跳出整个遍历,第一轮遍历结束。key相同type不同导致不可复用,会将oldFiber标记为DELETION,并继续遍历。
- 如果
nextChildren遍历完或者current的子节点遍历完,跳出遍历,第一轮遍历结束。
第二轮遍历
对于第一轮遍历的结果,我们分别讨论:
nextChildren和current的子节点同时遍历完
那就是最理想的情况, 只需第一轮遍历,此时Diff结束。
nextChildren没遍历完,current的子节点遍历完
已有的DOM节点都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的nextChildren为生成的新的FiberNode依次标记Placement。
nextChildren遍历完,current的子节点没遍历完
意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的oldFiber,依次标记Deletion。
nextChildren与current的子节点都没遍历完
这是Diff算法最精髓也是最难懂的部分,我们单独讲解。
nextChildren与current的子节点都没遍历完
- 将未遍历完的
current的子节点保存到一个 Map中,用 key 作为索引, 并且需要记录oldFiber在current的子节点中的位置(称为oldIndex)。 - 遍历未完的
nextChildren, 通过ReactElement.key去 Map 中寻找是否有可以复用的节点。- 如果没有找到,则创建新的
FiberNode,标记Placement。 - 如果找到,则比较
oldFiber的oldIndex和lastPlacedIndex,lastPlacedIndex表示最后一个可复用的节点在current的子节点中的位置索引。,如果oldIndex > lastPlacedIndex则代表该可复用节点不需要移动,只需更新lastPlacedIndex(lastPlacedIndex = oldIndex);oldIndex < lastPlacedIndex, 则 该节点需要向右移动,标记为Placement。
- 如果没有找到,则创建新的
我们来看一个例子:
旧列表(current子节点): abcde
新列表(nextChildren): abecd
dom节点如何修改
对于dom节点我们通常需要做的操作有:
- 更新(更新属性)
- 新增
- 删除
- 移动
其中dom节点的新增和移动对应Placement, 更新对应Update, 删除对应Deletion。
对Placement节点使用的appendChild和insertBefore来操作DOM, 如果节点之前不存在就是新增, 如果存在就是移动, 下面例子是移动的情况:
<!-- 改变前: -->
<div class="parent">
<div>A</div>
<div>B</div>
<div>C</div>
<div>
<!-- 改变后: -->
<div class="parent">
<div>A</div>
<div>C</div>
<div>B</div>
<div>
parent.appendChild(B);
<!-- 改变前: -->
<div class="parent">
<div>A</div>
<div>B</div>
<div>C</div>
<div>
<!-- 改变后: -->
<div class="parent">
<div>B</div>
<div>A</div>
<div>C</div>
<div>
parent.insertBefore(A, C);
注: 是使用appendChild还是insertBefore取决于节点是否有兄弟节点。
完整流程
我们一个一个例子来说明:
1. 在render阶段的beginWork阶段标记FiberNode
2. 在render阶段的completeWork阶段将标记的FiberNode加入副作用队列
需要注意的是: 正常副作用队列的处理是在completeWor,阶段, 但是该节点(被删除)会脱离workInProgess fiber树, 不会再进入completeWor,阶段, 所以在beginWork阶段提前加入副作用队列。
所以在beginWork阶段, 如果是需要删除的fiber, 除了自身打上Deletion之外, 还要将其添加到父节点的副作用队列中。
3. 在commit阶段的mutation阶段处理副作用队列修改DOM
对应的DOM改变前后状态如下:
<!-- 改变前: -->
<div class="parent">
<div>A</div>
<div>B</div>
<div>C</div>
<div>D</div>
<div>
<!-- 改变后: -->
<div class="parent">
<div>A</div>
<div>C</div>
<div>B</div>
<div>
我们来手动模拟一下这个过程:
- 处理
副作用队列中第一个FiberNode, 它是Deletion, 所以删除对应DOM.
D.remove();
- 处理
副作用队列中第二个FiberNode, 它是Placement.
parent.appendChild(B);
我这里做了很多简化,实际过程复杂得多,我是为了建立比较直观的理解,关于mutation阶段的详细的介绍可以参考React技术揭秘-mutation。
参考链接: