React-深度解读React中diff算法

26 阅读5分钟

前言

在 React 中,Diff 算法是实现高性能 UI 更新的功臣。它将传统树 Diff 的 O(n3)O(n^3) 复杂度直接优化到了 O(n)O(n)。而在react16 Fiber 架构引入后,这一过程变得更加精妙——它不再是简单的树对比,而是新虚拟 DOM 数组旧 Fiber 链表之间的“博弈”。

一、 核心设计思想:三条“军规”

React Diff 算法基于三个预设前提,这也是其高效的原因:

  1. 同层级对比 (Level Diff) :只比较同一层级的节点,不跨层比较。如果一个节点位置变了,React 会销毁它并在新位置重建,而不会去层级中寻找。
  2. 节点类型判断 (Type Diff) :类型不同(如 div 换成 span),直接视为新树,销毁旧的及其所有子节点。
  3. 稳定 Key 标识 (Key Diff) :通过唯一的 key 识别节点的稳定性,实现节点复用。

二、 算法实现流程:单节点与多节点

React 会根据新生成的 ReactElement 子节点数量,分为两种 Diff 模式:

1. 单节点 Diff (Single Node)

单节点diff是指新的虚拟dom树父节点下只有一个子元素,而旧的dom树同一层级的dom节点下可能有1个或者多个子元素的情况。当新节点只有一个时,逻辑相对简单。React 会在旧 Fiber 链表中寻找 key 和节点类型 type 都匹配的节点。

  • 复用成功keytype 均相同。直接复用 Fiber,并将旧节点的兄弟节点全部标记删除。
  • 彻底重建key 相同但 type 不同,说明结构已变,删除旧节点及其兄弟,创建新节点。
  • 继续寻找key 不同,直接创建一个新节点,并将同一层级所有旧节点均删除。

2. 多节点 Diff (Multiple Nodes)

多节点 diff 是核心难点,多节点diff主要通过一轮或者两轮遍历,尽可能多的复用key和type都相同的节点

第一轮遍历:从头开始遍历新节点子组件和旧的Fiber链表

主要寻找位置未变的节点。

  • 按索引同时遍历新数组和旧 Fiber 链表。
  • 如果 keytype 匹配,则复用并继续。
  • 一旦发现 key 或者type不匹配,则表明节点不能复用,直接中断第一遍遍历,进入到第二轮遍历。

第二轮遍历:核心是通过移动、删除、新增来处理节点非连续性的复用

处理第一轮剩下的“乱序”节点。

  1. 构建映射表:首先根据剩余旧的fiber链表,构建一个Map,其中key为键,旧fiber节点为值,接着设置一个lastPlacedIndex表示最后一个被复用且不需要移动的节点在旧fiber链表中的索引位置,初始值为0。

  2. 遍历数组 :接着遍历新节点数组,对与当前节点首先判断它的key是否存在于构建的Map结构中

    • 不存在:表示无复用节点,直接新建节点
    • 存在:首先找到当前节点在旧fiber链表中对应的索引oldIndex,然后比较oldIndexLastPlacedIndex,根据比较结果会有两种情况:
      • 如果oldIndex >= lastPlacedIndex,说明该节点在旧集合中的位置相对靠后,不需要移动
      • 如果oldIndex < lastPlacedIndex,说明该节点在旧集合中位于当前参考节点之前,但现在排到了后面,因此需要被移动(标记为Placement
      • 无论是否需要移动,都会将lastPlacedIndex更新为oldIndex与lastPlacedIndex中的较大值Math.max(oldIndex, lastPlacedIndex)
      • 遍历完旧fiber链表后,如果发现还存在多余节点则直接删除

三、 案例推演

旧fiber链表:a->b->c->d->e,新节点数组a->b->e->c->x->y image.png

第一轮遍历:发现a,b节点可复用,同一索引下c、e不同,这时遍历中断,进入第二轮遍历

image.png

第二轮遍历先根据剩余的旧fiber链表构建map结构

existingFiberMap = {
  'c': fiberC, // oldIndex=2
  'd': fiberD, // oldIndex=3
  'e': fiberE  // oldIndex=4
}
  • 设置lastPlacedIndex =0,发现节点e存在于map结构中,这时e在旧链表中的索引为4,且oldIndex>lastPlacedIndex,说明节点可以复用且不需要移动,这时将lastPlacedIndex=Math.max(oldIndex, lastPlacedIndex)= 4,并将e从map中删除

image.png

继续遍历下一个节点c,这时lastPlacedIndex为4,map结构如下:

existingFiberMap = {
  'c': fiberC, // oldIndex=2
  'd': fiberD, // oldIndex=3
}
  • 这时遍历到节点c,现节点c存在于map结构中,这时c在旧链表中的索引为2,oldIndex < lastPlacedIndex,说明节点可以复用但是需要移动,再将lastPlacedIndex=Math.max(oldIndex, lastPlacedIndex)= 4,并将c从map中删除

image.png

继续遍历下一个节点x,这时lastPlacedIndex为4,map结构如下:

existingFiberMap = {
  'd': fiberD, // oldIndex=3
}

遍历到下一节点x,发现x不存在于map结构中,说明没有可复用节点,这时需要新增一个x节点。

image.png

继续遍历下一个节点y,这时lastPlacedIndex为4,map结构如下:

existingFiberMap = {
  'd': fiberD, // oldIndex=3
}

遍历到下一节点y,发现y不存在于map结构中,说明没有可复用节点,这时需要新增一个y节点。

image.png

遍历完成,发现map结构中还存在d节点,直接全部删除。

最终结果

  • a,b,e节点可直接复用,且无需移动
  • c节点可以直接复用,但是需要移动
  • x,y节点需要新建并插入到c节点之后
  • d节点需要删除

四、 总结

  • Diff 是浅层的:React 依赖稳定的 key。如果你在更新时改变了 key,React 会认为是全新的节点,从而触发不必要的重新挂载。
  • 不要跨层级移动 DOM:因为同层对比机制,跨层级移动会导致“销毁 A 层 -> 在 B 层创建”,性能代价较高。