Sue-教你React的Diff算法,包教包会

115 阅读13分钟

本人才疏学浅,难免会有错漏,如有错漏,请各位大佬给予指正。感谢

前置知识(不详细介绍)

虚拟Dom(Virtual DOM)

它是指一个虚拟的树形结构,用于表示浏览器中的实际DOM元素。这个虚拟DOM的树形结构通常是轻量级的JavaScript对象的形式,通过对它进行操作,最后一次性的渲染到实际的DOM,从而减少浏览器的重绘和重排操作,提高Web应用程序的性能

fiber

Fiber 对象

每个组件都被视为一个 Fiber 对象。Fiber 对象是一个轻量级的 JavaScript 对象,用于表示组件的状态和关系,并且可以被用来进行渲染、协调和布局等操作。

Fiber 树

React Fiber 中的所有 Fiber 对象都被组织成了一棵 Fiber 树,这个树形结构反映了组件之间的关系和渲染顺序。

Fiber 树特点

  1. 单链表结构:Fiber 树中的每个节点都只有一个指针,指向其兄弟节点或子节点。这种结构可以提高遍历的效率,同时还可以方便进行渲染优先级的调度。(render - commit过程,决定了不能和vue 的diff那样双端比较)
  2. 优先级调度:每个 Fiber 节点都包含了一个优先级字段,用于确定当前节点在任务队列中的优先级。这样,在任务队列中高优先级的节点可以先被处理,以保证用户界面的流畅性和响应速度。
  3. 双缓存机制:React Fiber 引入了双缓存机制,即有两个 Fiber 树,一棵当前正在显示的树(current tree),另一棵待更新的树(work-in-progress tree)。这样,React 可以在后台更新 Fiber 树,避免了在用户界面上进行不必要的计算和操作。

概念

一个DOM节点最多有4个节点相关联

  1. currentFiber ——当前显示DOM节点对应的Fiber节点(老)
  2. workInProgress Fiber ——接下来要显示DOM节点对应的Fiber节点(新)
  3. DOM节点本身
  4. JSX对象 —— render返回的结果(就是修改后的代码)

总结:diff算法就是对比1、4,生成2

目标:通过diff算法比较两棵Fiber树(一棵旧的,一棵新的),以尽可能地高效地旧的Fiber树更新成新的Fiber树

内容:

  • 步骤1:了解比较两棵树的根节点,有多少种情况,每一个情况的处理方案是什么
  • 步骤2:Diff算法的具体实现(单节点、多节点)
  • 步骤3:多节点情况下的两次遍历

diff情况

当对比两棵树时,React 首先比较两棵树的根节点。不同类型的根节点元素会有不同的形态。


Q: 首先我们要知道两棵树的根节点对比下来有多少种情况,每种情况的处理方案是怎么样的

1.对比不同类型的元素

处理方案: 当根节点为不同类型的元素时,React 会拆卸整个原有的树,生成新的树

eg: <a> 变成 <img><Button> 变成 <div><span>变成<div>等等

// 老
<div>
  <Counter />
</div>
// 新
<span>
  <Counter />
</span>

上述示例: 节点<div>变成了<span>, 所以整个组件也会被卸载,状态也会被销毁。当建立一棵新的树时,对应的 DOM 节点会被创建以及插入到 DOM 中

(1)步骤一: 卸载原有的树(卸载时)

  • i . 销毁Dom节点(即<div><Counter>都会被销毁)
  • ii . 组件实例执行componentWillUnmount()

(2)步骤二: 生成新的树(新建时)

  • i . 组件实例执行 UNSAFE_componentWillMount() 方法
  • ii . 紧接着 componentDidMount() 方法 (即<span><Counter>是装载得新组件)

2. 对比同一类型的元素

处理方案: 当对比相同类型的元素时,React 会保留 DOM 节点,仅更新有所改变的属性

eg: 像相同标签更改 classnamestyletitle等等

// 老
<div className="before" title="stuff" />
// 新
<div className="after" title="stuff" />

上述示例: 节点都为<div>,所以为相同类型节点,会保留Dom节点,只更新className属性


// 老
<span style={{color: 'red', fontWeight: 'bold'}} />
// 新
<span style={{color: 'green', fontWeight: 'bold'}} />

上述示例: 节点都为<span>,所以为相同类型节点,会保留Dom节点。 这里更新 style中的color 属性,所以只需要修改 DOM 元素上的 color 样式,无需修改 fontWeight

在处理当前节点之后,React会继续对子节点进行递归

3. 对比同一类型的组件元素

eg: 像 <ul><li><ol><li>等普通元素,需要注意<table><tr><td>不属于这个范畴,<table>元素和普通的元素的渲染方式有很大的不同,React内部做特殊处理,这里不进行拓展。

(1)步骤一: render前

  • i . 组件实例保持不变,以此保证state渲染时不变。
  • ii . 更新组件的props,以保证与最新的元素保持一致。
  • iii . 调用UNSAFE_componentWillReceiveProps()UNSAFE_componentWillUpdate() 和 componentDidUpdate() 方法

(2)步骤二: render() —— 执行diff,这里有两种情况

  • 默认情况:对子节点进行递归
  • 优化情况:key匹配

默认情况: 对子节点进行递归

React同时遍历两个子元素的列表;当产生差异时,生成一个 mutation

mutation 是以一种存储标记的形式存储在内存中的,删除、新增、更新等标记

i. 在子元素列表末尾新增元素

// 老
<ul>
  <li>first</li>
  <li>second</li>
</ul>
// 新
<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul

上述示例:<ul><li>类型相同,为组件元素的对比。React 会同时遍历两个子元素的列表

  1. 先遍历匹配老的第一个<li>first</li>和新的第一个<li>first</li>,相同不处理。
  2. 然后匹配老的第二个<li>second</li>和新的第二个<li>second</li>,相同处理。
  3. 最后匹配发现新的有<li>third</li>老的没有,即第三个元素的 <li>third</li> 为新增元素。(同理老的有,新的没有,即为删除元素)

ii. 在子元素列表头部新增元素

// 老
<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>
// 新
<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

上述示例:<ul><li>类型相同,为组件元素的对比。React 会同时遍历两个子元素的列表

  1. 先遍历匹配老的第一个<li>Duke</li>和新的第一个<li>Connecticut</li>,不匹配,后续遍历也不匹配。React并不会意识到应该保留<li>Duke</li> 和 <li>Villanova</li>,而是会重建每一个子元素

这种情况下:末尾新增元素,更新开销比较小;头部新增元素,更新开销比较大

优化情况:key匹配

为了解决上述问题,React 引入了 key 属性。当子元素拥有 key 时,就可以直接匹配原有的树了。这就是我们一直强调key值得重要性

// 老
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>
// 新
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

上述示例:由于子元素拥有key值,通过遍历,就很明确知道'2014' key 的元素是新元素,带着 '2015' 以及 '2016' key 的元素仅仅移动了

注意:需要规范使用key,eg:需要元素重新排序,使用数组下标,diff就会变慢。这里就不进行赘述了

# diff情况总结

根节点对比会出现以下3种情况以及它们的diff处理方案:

  1. 不同类型的元素(组件) —— 拆卸原有的树,生成新的树
  2. 同一类型的元素 —— 保留DOM节点,仅更新有所改变的属性
  3. 同一类型的组件元素
    • i. 默认情况:同时遍历两棵树的子节点,一旦差异,生成一个mutation(生成对应的标记)。
    • ii. 优化情况:使用key,会直接比较key值去定位,判定差异结果。

上述就是: 对比根节点产生的3种情况和对应的处理方案,接下来我们来看 diff方法的具体实现


Diff的预制限制——只对同级元素进行对比

Diff的入口函数是reconcileChildFibers(),该函数的目的是: 根据newChild(即JSX对象)类型调用不同的处理函数

// 根据newChild类型选择不同diff函数处理
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
): Fiber | null {

  const isObject = typeof newChild === 'object' && newChild !== null;

  // 1.object类型
  if (isObject) {
    // object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 调用 reconcileSingleElement 处理
      // // ...省略其他case
    }
  }

 // 2.string和number类型 
  if (typeof newChild === 'string' || typeof newChild === 'number') {
    // 调用 reconcileSingleTextNode 处理
    // ...省略
  }

 // 3.array类型
  if (isArray(newChild)) {
    // 调用 reconcileChildrenArray 处理
    // ...省略
  }

  // 一些其他情况调用处理函数
  // ...省略

  // 以上都没有命中,删除节点
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

根据同级的节点数量将Diff分为两类:

  1. 当newChild类型为object、number、string,代表同级只有一个节点,即单节点
  2. 当newChild类型为Array,同级有多个节点

单节点diff

上述,我们知道 newChild类型为object、number、string为单节点。

以类型object为例子,调用了reconcileSingleElement()

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  
  // 1. 判断是否存在对应DOM节点
  while (child !== null) {
    // 上一次更新存在DOM节点,接下来判断是否可复用?
    // 2. 比较key是否相同
    if (child.key === key) {

      // 2.1 key相同,接下来比较type是否相同

      switch (child.tag) {
        // ...省略case
        default: {
          if (child.elementType === element.type) {
            // type相同则表示可以复用 
            // 返回复用的fiber
            return existing;
          }
          
          // type不同则跳出switch
          break;
        }
      }
      // 代码执行到这里代表:key相同但是type不同
      // 将该fiber及其兄弟fiber标记为删除
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // 2.2 key不同,将该fiber标记为删除
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  // 创建新Fiber,并返回 ...省略
}

单节点(20).jpg 上述是和我们讲的diff情况是一致的,我们要判断是否进行复用,就是key和type都要相同,有一个不相同就直接是diff情况1——类型不同,直接删除重建。 需要注意的是:当key相同,type不同是把整个节点以及他的兄弟节点都标记为删除。而key不同 仅将节点删除

多节点diff

上述,我们了解到newChild <ul><li> 为多节点,多节点的参数类型为Array,执行reconcileChildrenArray

同级多节点的diff,我们归纳为

  • 1.节点更新
// 老
<ul>
  <li key="0" className="before">0<li>
  <li key="1">1<li>
</ul>
// 新 - 节点属性发生变化
<ul>
  <li key="0" className="after">0<li>
  <li key="1">1<li>
</ul>
// 新 - 节点类型变化
<ul>
  <div key="0">0</div>
  <li key="1">1<li>
</ul>
  • 2.节点新增或减少
// 老
<ul>
  <li key="0">0<li>
  <li key="1">1<li>
</ul>
// 新 - 节点新增
<ul>
  <li key="0">0<li>
  <li key="1">1<li>
  <li key="2">2<li>
</ul>
// 新 - 节点减少
<ul>
  <li key="0">0<li>
</ul>
  • 3.节点位置变化
// 老
<ul>
  <li key="0">0<li>
  <li key="1">1<li>
</ul>
// 新
<ul>
  <li key="1">1<li>
  <li key="0">0<li>
</ul>

Diff思路为

  1. 新增 —— 执行新增逻辑
  2. 删除 —— 执行删除逻辑
  3. 更新 —— 执行更新逻辑(相较于新增和删除,更新组件发生的频率更高 所以Diff会优先判断当前节点是否属于更新)

fiber树是链表,为了解决这个的对比,react团队提供了 2轮遍历的思路

  1. 第一次遍历: 处理更新的节点
  2. 第二次遍历: 处理非更新的节点

第一轮遍历

未命名文件(21).jpg

第一次遍历流程:

    1. 遍历newChildren,将newChildren[i]与oldFiber比较
    1. 判断DOM节点是否可复用,就是判断key、type是否相同
    • 如果key相同,type相同,可复用,继续遍历
    • 如果key相同,type不同,不可复用,标记DELETION,继续遍历
    • 如果key不同,type不同,不可复用,直接跳出整个循环
    1. 直到newChildren或者oldFiber遍历完,跳出循环,遍历结束

这种时候只有3种可能性

  1. newChildren和oldFiber都没有遍历完,意思是走了3-3的情况,key和type不同直接跳出循环
  2. newChildren和oldFiber任一方走完了。那就意味着新增或者删除
  3. newChildren和oldFiber都同时走完,证明更新
没有遍历完
// 老
<ul>
  <li key="0">0<li>
  <li key="1">1<li>
  <li key="2">2<li>
</ul>
// 新
<ul>
  <li key="0">0<li>
  <li key="2">2<li>
  <li key="1">1<li>
</ul>

遍历 新的newChildren 
1. 第一个节点,老 <li key="0">0<li>  和新 <li key="0">0<li>,一样,可复用,i++,继续遍历
2. 第二个节点,老 <li key="1">1<li>  和新 <li key="2">2<li>,key不一样,直接跳出循环,介绍

这时候:(结束)
newFiber:key === 2、key === 1都是未遍历上的
oldFiber:key === 1、key === 2都是未遍历上的
newChildren和oldFiber任一方走完了
// 老
<li key="0" className="a">0</li>
<li key="1" className="b">1</li>
            
// 新 情况1 —— newChildren没遍历完,oldFiber遍历完
// newChildren剩下 key==="2" 未遍历
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>
<li key="2" className="cc">2</li>
遍历newChildren
1.第一个节点,type为:<li>  key为 0 ,和oldFiber[i]相同,i++,继续遍历
2.第二个节点,type为:<li>  key为 1 ,和oldFiber[i]相同,i++,继续遍历,
3.第三个节点,发现 newChildren还没遍历完成,而oldFiber遍历完

这时候:(结束)
newFiber:key === 2都是未遍历上的
oldFiber:无

---
// 老
<li key="0" className="a">0</li>
<li key="1" className="b">1</li>        

// 新 情况2 —— newChildren遍历完,oldFiber没遍历完
// oldFiber剩下 key==="1" 未遍历
<li key="0" className="aa">0</li>
遍历newChildren
1.第一个节点,type为:<li>  key为 0 ,和oldFiber[i]相同,i++,继续遍历
2.第二个节点,发现oldFiber还没遍历完成,而newChildren遍历完

这时候:(结束)
newFiber:无
oldFiber:key === 1都是未遍历上的
newChildren和oldFiber同时走完了
// 老
<li key="0" className="a">0</li>
<li key="1" className="b">1</li>
            
// 新 情况1 —— newChildren与oldFiber都遍历完
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>

遍历newChildren
1.第一个节点,type为:<li>  key为 0 ,和oldFiber[i]相同,i++,继续遍历
2.第二个节点,type为:<li>  key为 1 ,和oldFiber[i]相同,i++,遍历结束

这时候:(结束)
newFiber:无
oldFiber:无

第二轮遍历

1. newChildren 和 oldFiber 同时遍历完

不需要第二轮的遍历,直接进行 update,diff结束

2. newChildren没遍历完,oldFiber遍历完

意味:新增,我们只需要把剩下的newChildren标记上新增的标记Placement,进行新增

3. newChildren遍历完,oldFiber没遍历完

意味:删除,我们只需要把剩下的oldFiber标记上删除的标记Deletion,进行删除

4. newChildren与oldFiber都没遍历完

意味着有节点更新了位置

  1. 有节点更新,如果用索引就不可以了,所以我们要使用key,为了方便使用key,我们把oldFiber的key存在Map中
  2. 遍历newChildren,去oldFiber的map上比较是否存在
    • 不存在,证明新增,标记上新增的标记Placement,进行新增。
    • 存在,证明更新了位置

位置更新: 移动都是后面往前面移动

  1. 我们需要标记最后一个可复用的节点在oldFiber中的位置索引(用变量lastPlacedIndex表示)
  2. 如果oldIndex < lastPlacedIndex,代表本次更新该节点需要向右移动;
  3. 如果oldIndex >= lastPlacedIndex,则lastPlacedIndex = oldIndex

可能比较懵,看完例子就明白了

Demo

1.jpg

2.jpg

3.jpg

4.jpg

// 之前
abcd

// 之后
acdb

===第一轮遍历开始===
a(之后)vs a(之前)  
key不变,可复用
此时 a 对应的oldFiber(之前的a)在之前的数组(abcd)中索引为0
所以 lastPlacedIndex = 0;

继续第一轮遍历...

c(之后)vs b(之前)  
key改变,不能复用,跳出第一轮遍历
此时 lastPlacedIndex === 0;
===第一轮遍历结束===

===第二轮遍历开始===
newChildren === cdb,没用完,不需要执行删除旧节点
oldFiber === bcd,没用完,不需要执行插入新节点

将剩余oldFiber(bcd)保存为map

// 当前oldFiber:bcd
// 当前newChildren:cdb

继续遍历剩余newChildren

key === c 在 oldFiber中存在
const oldIndex = c(之前).index;
此时 oldIndex === 2;  // 之前节点为 abcd,所以c.index === 2
比较 oldIndex 与 lastPlacedIndex;

如果 oldIndex >= lastPlacedIndex 代表该可复用节点不需要移动
并将 lastPlacedIndex = oldIndex;

如果 oldIndex < lastplacedIndex 该可复用节点之前插入的位置索引小于这次更新需要插入的位置索引,
代表该节点需要向右移动

在例子中,oldIndex 2 > lastPlacedIndex 0,
则 lastPlacedIndex = 2;
c节点位置不变

继续遍历剩余newChildren

// 当前oldFiber:bd
// 当前newChildren:db

key === d 在 oldFiber中存在
const oldIndex = d(之前).index;
oldIndex 3 > lastPlacedIndex 2 // 之前节点为 abcd,所以d.index === 3
则 lastPlacedIndex = 3;
d节点位置不变

继续遍历剩余newChildren

// 当前oldFiber:b
// 当前newChildren:b

key === b 在 oldFiber中存在
const oldIndex = b(之前).index;
oldIndex 1 < lastPlacedIndex 3 // 之前节点为 abcd,所以b.index === 1
则 b节点需要向右移动
===第二轮遍历结束===

最终acd 3个节点都没有移动,b节点被标记为移动

diff 算法到这里就结束了。

总结

  1. diff算法的目的:标记出最短最高效的操作dom方法。commit阶段才进行更新。

  2. 节点分为:单节点和多节点。都需要先判断是否可进行复用,如果不可,直接删除,新建

  3. 单节点:分为 新增、删除、更新。

    • 当前节点child与老节点的key不同,仅将当前fiber标记删除,重新新建
    • 当前节点child与老节点的key相同,type不同,把当前fiber和其兄弟fiber都标记删除,重新新建
    • 当前节点child与老节点的key相同,type相同,可复用,标记复用
  4. 多节点:分为 新增、删除、更新。。

    • [第一轮遍历] (同时遍历newChild和oldFiber)
    • [第二轮遍历],以下是第一轮遍历的结果
      • 1.新增,oldFiber遍历完成,newChild未遍历完成,标记新增
      • 2.删除,oldFiber未遍历完成,newChild遍历完成,标记删除
      • 3.更新-情况1,oldFiber和newChild都遍历完成, 标记更新
      • 4.更新-情况2,oldFiber和newChild都未遍历完成,意味有节点更新了位置
        • (1)把oldfiber的key存成MAP,遍历newChildren,初始化i = 0、lastPlaceIndex = 0
        • (2)newChildren[i] 在oldfiber MAP是否存在
          • I.不存在,标记新增,走新增逻辑
          • II.存在,证明是移动
            • 如果oldIndex < lastPlacedIndex,代表该节点需要向右移动
            • 如果oldIndex >= lastPlacedIndex,代表该节点不需要移动,lastPlacedIndex = oldIndex