React Diff 算法解析

97 阅读4分钟

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的来源:

  1. 如果workInProgress节点对应的是类组件(ClassComponent), 通过class的render方法得到。
  2. 如果workInProgress节点对应的是函数组件FunctionComponent, 通过组件函数运行后得到。
  3. 其它情况大都来自workInProgress.pendingProps.children, 也就是props中的children属性。

Diff 分类

Diff 分为: 单节点 Diff和多节点 Diff。

这里的单节点和多节点指的是: nextChildrenReactElement对象(一个)或ReactElement数组(多个)。

<div>
  <div>单节点</div>
</div>

<div>
  <div>多节点</div>
  <div>多节点</div>
  <div>多节点</div>
</div>

单节点 Diff

  1. current子节点为空

20220805121527

  1. current的子节点中有可复用的节点, 当FiberNodeReactElementtypekey都相同,我们认为其可以复用。 可复用的节点并不代表对应的dom节点完全不改变,有可能会有属性的更新,通常会被打上Update标签。

20220805140308

  1. current的子节点中没有可复用的节点

20220805121651

多节点 Diff

但是React团队发现,在日常开发中,相较于新增和删除,更新组件发生的频率更高。所以Diff会优先判断当前节点是否属于更新。

Diff算法的整体逻辑会经历两轮遍历:

第一轮遍历:处理更新的节点。 第二轮遍历:处理剩下的不属于更新的节点。

第一轮遍历

  1. 依次比较nextChildrencurrent的子节点, 判断DOM节点是否可复用。
  2. 如果可复用, 则继续。
  3. 如果不可复用,分两种情况:
    • key不同导致不可复用,立即跳出整个遍历,第一轮遍历结束。
    • key相同type不同导致不可复用,会将oldFiber标记为DELETION,并继续遍历。
  4. 如果nextChildren遍历完或者current的子节点遍历完,跳出遍历,第一轮遍历结束。

第二轮遍历

对于第一轮遍历的结果,我们分别讨论:

nextChildrencurrent的子节点同时遍历完

那就是最理想的情况, 只需第一轮遍历,此时Diff结束。

nextChildren没遍历完,current的子节点遍历完

已有的DOM节点都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的nextChildren为生成的新的FiberNode依次标记Placement

nextChildren遍历完,current的子节点没遍历完

意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的oldFiber,依次标记Deletion

nextChildrencurrent的子节点都没遍历完

这是Diff算法最精髓也是最难懂的部分,我们单独讲解。

nextChildrencurrent的子节点都没遍历完

  1. 将未遍历完的current的子节点保存到一个 Map中,用 key 作为索引, 并且需要记录oldFibercurrent的子节点中的位置(称为oldIndex)。
  2. 遍历未完的nextChildren, 通过ReactElement.key去 Map 中寻找是否有可以复用的节点。
    1. 如果没有找到,则创建新的FiberNode,标记Placement
    2. 如果找到,则比较oldFiberoldIndexlastPlacedIndex, lastPlacedIndex表示最后一个可复用的节点在current的子节点中的位置索引。,如果oldIndex > lastPlacedIndex则代表该可复用节点不需要移动,只需更新lastPlacedIndex(lastPlacedIndex = oldIndex); oldIndex < lastPlacedIndex, 则 该节点需要向右移动,标记为Placement

我们来看一个例子:

旧列表(current子节点): abcde
新列表(nextChildren): abecd

20220805152944

dom节点如何修改

对于dom节点我们通常需要做的操作有:

  • 更新(更新属性)
  • 新增
  • 删除
  • 移动

其中dom节点新增移动对应Placement, 更新对应Update, 删除对应Deletion

Placement节点使用的appendChildinsertBefore来操作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

20220806105029

2. 在render阶段的completeWork阶段将标记的FiberNode加入副作用队列

20220806105835

需要注意的是: 正常副作用队列的处理是在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>

我们来手动模拟一下这个过程:

  1. 处理副作用队列中第一个FiberNode, 它是Deletion, 所以删除对应DOM.
D.remove();
  1. 处理副作用队列中第二个FiberNode, 它是Placement.
parent.appendChild(B);

我这里做了很多简化,实际过程复杂得多,我是为了建立比较直观的理解,关于mutation阶段的详细的介绍可以参考React技术揭秘-mutation

参考链接: