图解 React Diff 算法

847 阅读9分钟

本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

React Diff 算法

在我们了解 diff 算法之前,先了解一下几个点。

  1. diff 算法相关的节点。

  2. diff 算法中用到的 tag

  3. diff 算法的瓶颈以及优化措施。

diff 算法相关的节点

diff 中会涉及到 4 种节点:

  1. jsx 对象。

  2. Dom 节点本身。

  3. current fiber 。当前页面中显示的 Dom 元素所对应的 fiber 节点。

  4. 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 在节点比较的时候做了一下的性能优化:

  1. 不会进行跨层级的比较,只会在同一层级进行比较。

  2. 必须是同一类型的才会进行比较。比如 更新之前是 div,更新之后是 p 则不会进行 diff 比较,p 对应的 fiber 直接标记上 Placement。 divfiber 节点直接标记上 Deletion

  3. 开发者可以在元素上增加 key 属性,来告诉 React 在比较过程中,复用节点的位置发生变化。如果没有 key ,则根据规则2,直接处理,但是如果有 key 则会遍历同级的 fiber 节点,查找存在相同 key ,并且类型也一样的节点,如果找到则可以复用。

Diff 算法解析

diff 流程概览.png

从上面省略后的代码可以看出来,我们将节点的比较分为复杂节点与简单节点。两者的区别就是是否为纯文本节点,如果是纯文本节点,执行 reconcileSingleTextNode , 如果不是则进入复杂节点的比较,这其中又分为 单一节点与多节点的比较。单一节点执行 placeSingleChild,多节点执行 reconcileChildrenArray 。下面我们进行详细的描述。

流程概览

react diff 流程概览.jpg

纯文本节点

源码

carbon (2).png

首先会进行判断,如果 current fiber 存在并且 current fibertag 是根节点,那么此时我们可以复用这个 textNode ,调用 deleteRemainingChildren 将其他的兄弟节点标记为 ChildDeletion

如果不能复用的话,则删除兄弟节点,创建新的 fiber 节点。

单节点

源码

单节点源码.png

分析

在单节点中我们分为 3中情况:

  1. jsx 对象不存在对应的 current fiber

  2. current fiber 存在,并且 key 相同。

  3. jsxcurrent fibertype 不同,调用 deleteRemainingChildren。跳出本轮循环。

  4. jsxcurrent fibertype 相同,调用 deleteRemainingChildren 。返回 useFiber 复用上一次的节点。

  5. current fiber存在,并且 key 不相同。则创建新的 fiber 节点。

流程图

单节点 diff.jpg

3种情况详解

1、current fiber 不存在的情况,根据 element.type 创建对应的 fiber 节点。

  • 如果 typeREACT_FRAGMENT_TYPEfragment ,则通过 createFiberFromFragment 创建新的 fiber 节点。

  • 否则调用 createFiberFromElement 创建新的 fiber 节点。

2、 current fiber 存在,判断 jsx current fiberkeytype 的情况。

  • 判断 key 是否相等。

  • 如果 key 不相同,调用 deleteChild ,删除当前的节点,进入下一个循环. child = child.sibing.

  • 如果 key 相同,判断 jsx 对象的 typecurrent 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>;

多节点

源码

多节点源码.png

分析

在多节点的比较中,我们可以分为几种情况:

  1. 节点的更新。

  2. 节点的新增。

  3. 节点的删除。

  4. 节点位置的变化。

在上述几种情况中,节点更新最为频繁,所以 react 官方将多节点的 diff 分为两次循环:

第一次循环:针对于节点更新的情况,如果遇到不能够复用的情况,则跳出循环,或者新的 fiber 节点遍历结束跳出循环。

第二次循环:针对 oldFiber 没有处理完, newFiber 也没有处理完,其中包括节点位置移动的情况。这个时候会遍历剩下的 fiber 节点。

流程图

流程图 (1).jpg

详细分析
变量分析:
  1. resultingFirstChilddiff 完以后的第一个 fiber 节点。是链表结构的头。

  2. previousNewFiber 中间变量保存遍历过程中的 newChild

  3. oldFiber 存储当前 diff 节点的 current fiber 节点。

  4. lastPlacedIndex . 用于在节点存在移动情况下,比较位置的基准节点。

  5. newIdx newChild 的节点在数组中的位置。

  6. nextOldFiber 下一次需要比较的 current fiber 节点。

第一次遍历(更新阶段):
  1. current fiber(oldFiber) 链表与 jsx 数组,的每一项进行比较。

  2. image.png

  3. 特殊情况: jsx 中可能存在 falsy 元素所以需要进行判断。如果旧的 fiber.indexnewIdx 大,代表当前的 oldFiber 之前存在 falsy 元素。即节点位置不对应的情况。我们需要将本次 oldFiber fiber 的对比放到下次,将 oldFiber 置为 null

  4. 否则我们 nextOldFiber 就等于 oldFiber.sibling.

  5. 接下来调用 updateSlot 方法判断当前的节点是否可以被复用,保存在变量 newFiber 中。

  6. 如果 newFiber == null 代表节点不可以复用,则直接退出第一轮循环。 这里也有一个特殊情况与上面相对应,oldFiber 节点不可以复用并且 oldFiber == nulloldFiber 重新赋值为 nextOldFiber

如果 newFiber !== null ,一直到 newIdx == newChildren.length ,新的节点全部遍历完成,退出第一轮循环。

carbon (3).png

在不跳出循环的情况下, 并且是更新的情况下,oldFiber 存在并且 newFiber 没有复用,则删除 oldFiber

总结:

在第一轮遍历中,我们判断oldFiber.indexnewChild[newIdx] 之间比较。

结束的条件有两种:

  1. 没有可以复用的节点,即 newFiber == null

  2. newFiber 全部都可以复用,正常遍历完成。

记录最新的更新位置。

  1. 方便第二次遍历中,节点位置变化的情况。
第一次循环完,第二次循环前

carbon (5).png

  1. 新的 newChildren 遍历完,我们删除其余的 oldFiber 节点,返回第一个 diff 之后链表的头。

carbon (6).png

  1. 如果 oldFiber == null ,代表旧的 fiber 已经遍历完, newChild 剩下的元素全部重新创建,返回第一个 diff 之后链表的头。
第二次循环

到这里还没有 return 代表 newChild 没有遍历完,oldFiber 也没有遍历完,意味着此时存在以下几种可能:

  1. 节点的位置变化。

  2. 节点的新增。

  3. 节点的删除。

接下来我们看过程:

  1. 为了能够在提升效率,我们将 oldFiber 中剩余的节点放到 new Map 结构中,结构中的 keyoldFiberkey ,没有 key 则使用 index。结构中的 valueoldFiber 节点。

  2. 遍历 newChildren ,从 newChildren 中取出来 key ,没有 key,使用 newIdx 从 上面 new Map 的结构中取出 oldFiber , 赋值给 _newFiber2

  3. 如果 _newFiber2 !== null 并且 _newFiber2.alternate !== null 代表节点可以复用,new Map 中对应的元素标记为删除。

carbon (7).png

在可复用的情况下,我们之前提到的 lastPlaceIndex 就起到作用了,在之前更新阶段我们拿到最后一次更新的 oldFiber 的节点位置,在第二次循环中,我们需要判断当前遍历的 newChildren[newIdx] 对应 oldFiber.index 的位置 与 lastPlaceIndex 位置比较,如果 oldIndex < lastPlacedIndex ,那代表本次的 newFiber 需要移动。否则不需要移动,直接返回 oldIndex

carbon (8).png

newChildrren 遍历完成,将剩余的 new Map 的元素标记为删除。

总结:

  1. 在二次循环针对,节点新增,节点删除,节点位置的变化。

  2. 在位置变化中,通过 new Map 结构存储 oldFiber 剩余的节点。

  3. 遍历 newChildren 数组中剩余的元素,去 new Map 结构中获取对应的节点。

  4. 如果有对应的节点,证明节点是可服用并且是移动过的节点,将 oldFiber 标记删除,并在新的 fiber 上判断是否需要移动。

  5. 是否移动的标准就是当前 oldIndexlastPlacedIndex 的大小。

  6. oldIndexlastPlaceIndex 小,则标记为需要插入。

  7. 否则,不需要做任何处理。