Lecture du code source de l'algorithme React Diff

Algorithme différentiel

1. Conception

1. Qu'est-ce que l'algorithme Diff ?

DiffL'algorithme est basé sur le DOM virtuel, qui consiste à discuter de la manière de générer des correctifs de mise à jour de l'arborescence DOM après les modifications de l'arborescence DOM virtuelle. Il compare les modifications des nouveaux et anciens arbres DOM virtuels, applique le correctif de mise à jour au DOM réel et termine la mise à jour de la vue avec le coût minimum

2. Algorithme de traversée

L'algorithme de traversée fait référence à la visite de chaque nœud de l'arbre à tour de rôle le long d'un certain itinéraire de recherche. Il existe généralement deux types : le parcours en profondeur d'abord et le parcours en largeur d'abord.

Le parcours en profondeur commence à partir du nœud racine et traverse verticalement le long du sous-arbre gauche jusqu'à ce qu'un nœud feuille soit trouvé. Revenez ensuite au nœud précédent et parcourez le nœud de sous-arbre droit jusqu'à ce que tous les nœuds accessibles soient traversés.

La traversée en largeur commence à partir du nœud racine, traverse le niveau de l'arbre binaire verticalement sur la base de la traversée horizontale des nœuds de niveau de l'arbre binaire.

ReactL' diffalgorithme utilise un algorithme de parcours en profondeur d'abord. Parce que la traversée en largeur d'abord peut provoquer un désordre de synchronisation du cycle de vie des composants, et l'algorithme de traversée en profondeur d'abord peut résoudre ce problème.

3. Stratégie d'optimisation

Bien que la traversée en profondeur d'abord garantisse que la synchronisation du cycle de vie des composants n'est pas chaotique, l' diffalgorithme apporte également un sérieux goulot d'étranglement des performances. Il est mentionné dans la coordination de la documentation React que même dans l'algorithme le plus avancé, les arbres avant et arrière sont complètement comparés les uns aux autres. La complexité de l'algorithme pour est O(n^3), où nest le nombre d'éléments dans l'arbre. Tout comme le schéma d'optimisation courant en informatique, Reactune méthode très classique est utilisée pour réduire la complexité de O(n)diviser pour régner, c'est-à-dire de décomposer le problème grâce à l'idée astucieuse de "diviser pour régner".

Afin de réduire la complexité de l'algorithme, leReact prédéfinira trois restrictions :diff

  1. Uniquement sur les éléments frères diff. Si DOM节点l'on croise la hiérarchie entre deux mises à jour, alors Reactaucune tentative ne sera faite pour la réutiliser.

  2. 两个不同类型的元素会产生出不同的树。如果元素由 div 变为 pReact 会销毁 div 及其子孙节点,并新建p及其子孙节点。

  3. 开发者可以通过 key prop 来暗示哪些子元素在不同的渲染下能保持稳定

4. Diff 函数入口

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

image.png 从同级的节点数量来看可以将Diff分为两类:

  1. 单节点 Diff,当newChild类型为objectnumberstring,代表同级只有一个节点
  2. 多节点 Diff,当newChild类型为Array,同级有多个节点

5. 环境准备

我直接通过 create-react-app 创建项目,打开控制台,找到 react-dom.development.js 文件,找到 reconcileSingleElement 函数,打上断点

鼠标放在控制台,ctrl + p,输入想要查找的文件

image.png

// App.js
function App() {
  const [num, setNum] = useState(0);
  const a = (
    <ul>
      <li>0</li>
      <li>1</li>
      <li>1</li>
    </ul>
  )
  const b = (
    <ul>
      <p>1</p>
    </ul>
  )
  return (
    <div className="App" onClick={() => {setNum(num + 1)}}>
      {num % 2 === 0 ? a : b }
    </div>
  );
}

二、单节点 Diff

image.png

对于单个节点,以类型 object 为例,会进入 reconcileSingleElementreconcileSingleElement 接受四个参数含义分别是

  • returnFiber: current Fiber 的父节点
  • currentFirstChild:current Fiber(DOM 节点对应的 Fiber 节点)
  • newChild: JSX对象(更新到页面的数据)
  • lanes: 更新的优先级

一个DOM节点在某一时刻最多会有4个节点和它相关。

  1. current Fiber。如果该DOM节点已在页面中,current Fiber代表该DOM节点对应的Fiber节点
  2. workInProgress Fiber。如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber代表该DOM节点对应的Fiber节点
  3. DOM节点本身。
  4. JSX对象。即ClassComponentrender方法的返回结果,或FunctionComponent的调用结果。JSX对象中包含描述DOM节点的信息。 Diff算法的本质是对比1和4,生成2。

image.png

reconcileSingleElement 函数中我们可以看到,将 currentFistChild 赋值给 child, 如果 child 等于 null 就直接创建新的 Fiber 节点并返回,那什么情况下 child 会为 null?

child(也就是 current Fiber代表该DOM节点对应的Fiber节点) 为 null, 也就代表 DOM 节点不存在,在 mount(挂载)阶段 DOM 节点就不存在,或者更新前就没有 DOM 节点,那 child 自然就为 null

image.png

image.png

child 不为 null,进入循环,先对比其 key 是否相同,这里有个 TODO 先跳过。

key 不同,那就无法复用,就会调用 deleteChild 函数将 fiber 节点标记为删除,然后赋值 child 为 child.sibling,继续循环

为什么要继续赋值为 child.sibling?

// 更新前
<li key="0">0</li>
<li key="1">1</li>

// 更新后
<li key="1">1</li>

是考虑像这种更新前是多个节点,后面的兄弟节点可以复用,在前面节点不匹配的情况下,我们可以继续对比后面的兄弟节点是否可以复用

image.png

key 相同,就继续对比其 type 是否相同

  • key 相同,type 也相同

    • 其它多余的兄弟 Fiber 标记未删除,复用该 Fiber

image.png

  • key 相同,type 不同

    • 将该 Fiber 及其兄弟 Fiber 标记为删除,跳出循环,创建新 Fiber 返回

image.png

deleteRemainingChildren

image.png

If key === null and child.key === null, then this only applies to the first item in the list.
译: key === null 和 child.key === null,那么这只适用于列表中的第一项。

// 更新前
<li>0</li>
<p>1</p>

// 更新后
<p>1</p>

再看这个 TODO,就是更新前后节点都没有设置 key 的情况(key 默认值为 null),如下,那么 li 和 p 的 key 相同,继续对比 type, type 不同将标记上出并跳出循环,不再对比后面兄弟节点,所以说只适用于列表中的第一项

三、多节点 Diff

多节点 Diff reconcileChildFibersnewChild 参数类型为 Array,在 reconcileChildFibers 函数中回调用 reconcileChildrenArray

image.png

  • returnFiber: current Fiber 的父节点
  • currentFirstChild:current Fiber(DOM 节点对应的 Fiber 节点)
  • newChild: JSX对象(更新到页面的数据)
  • lanes: 更新的优先级

1. 多节点 Diff 的几种情况

  • 节点更新
    • 属性更新
    • 类型更新
  • 节点增删
  • 节点移动

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

image.png

进入 reconcileChildrenArray 函数,开始一堆注释大概就是说,这个算法不能通过双指针优化,因为同级的Fiber节点是由sibling指针链接形成的单链表,即不支持双指针遍历...

  • currentFirstChild: 通过 sibling 指针链接的单链表
  • newChildren: 数组

基于以上原因,Diff算法的整体会经历两轮遍历:

  • 第一轮遍历:处理更新的节点。

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

2. 第一轮遍历

image.png

变量描述

  • resultingFirstChild: 返回的结果 workInProgress Fiber(如果该 DOM 节点 将在本次更新中渲染到页面中,workInProgress Fiber代表该 DOM 节点对应的 fiber)

  • previousNewFiber: 多节点 diff, 会创建多个 fiber 节点,节点通过 sibling 连接,previousNewFiber 则做为中间变量,用来对我们创建 Fiber 进行连接

image.png

  • oldFiber: current Fiber

  • lastPlacedIndex: 用来判断节点是否标记 Placement,上一个未移动节点的索引位置

  • newIdx: 当前遍历到的 newChildren 的索引

  • nextOldFiber: 存储 oldFiber 的下一个 oldFiber

// 更新前
const a = (
  <ul>
    <li key="0">0</li>
    <li key="1">1</li>
    <li key="2">2</li>
  </ul>
);
// 更新后
const b = (
  <ul>
    <li key="0">0</li>
    <div key="1">1</div>
    <li key="3">3</li>
  </ul>
);

image.png

image.png

reconcileChildrenArray 方法打上断点,进入第一轮遍历,开始执行 updateSolt 函数

image.png

updateSolt 函数中,会判断 key 是否相同

  • key 不同,则返回 null,跳出循环,第一轮遍历结束

image.png

  • key 相同,则执行 updateElement 函数,在 updateElement 函数 判断 type 是否相同type 相同则复用,不同则新建

image.png

回到第一轮遍历,type 不同返回的 newFiber.laernate 的值 null,该 oldFibler 判断标记为删除

image.png

进入 placeChild 函数,是对需要移动节点进行标记

image.png

image.png

判断 current(newFiber.laernate) 是否存在

  • 不存在: 说明这个 fiber 是新建的 fiber,标记为 Placement,好在 commit 阶段插入到页面

  • 存在: 说明这个 fiber 是复用了之前的 oldFiber,如果 oldIndex 小于 lastPlaceIndex 则标记 Placement,否则不动

    • oldIndex:oldFiber 在同级节点的索引

    • lastPlaceIndex:lastPlaceIndex 初始值是 0,在上面的逻辑可以看出只有未标记 Placement(节点不移动) 的操作,lastPlaceIndex 的值才会变化,所以 lastPlaceIndex 代表上一个未移动节点的索引位置

那为什么 oldIndex 小于 lastPlaceIndex 节点需要移动?

更新中节点是按newChildren的顺序排列,newFiber 肯定是我们已遍历节点中最靠后的,因为标记为 Placement 是要执行插入操作的,它插入到前中后,都无所谓,所以我们可以先忽略掉他们,而我们需要关注 oldFiber 是不是目前可复用节点中最靠后

  • oldIndex 小于 lastPlaceIndex,说明 oldFiber 在上一个未移动节点前面,更新前后位置更改,需要移动

  • oldIndex 大于等于lastPlaceIndex,说明 oldFiber 在上一个未移动节点后面,更新前后位置不变,不需要移动

按照目前逻辑在第一轮遍历是不会出现 oldIndex < lastPlaceIndex 的情况,在第二轮遍历中我们就可以看到它的作用

3. 第二轮遍历

第一轮遍历结束有以下三种情况

  • key 值不同不可复用,立即跳出遍历

    • newChildren 没遍历完,oldFiber 也没遍历完,还需要继续进行对比...
  • newChildren 遍历完

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

image.png

  • oldFiber 遍历完

    • 意味着本次更新有新节点插入,我们只需要遍历剩下的 newChildren 生成的 workInProgress fiber 依次标记 Placement

image.png

newChildren 没遍历完,oldFiber 也没遍历完

image.png

了快速的找到key对应的oldFiber,将所有还未处理的oldFiber存入以key为key,oldFiber为value的Map中。

接下来遍历剩余的newChildren,通过newChildren[i].key就能在existingChildren中找到key相同的oldFiber,剩下的判断逻辑同上

image.png

4. Demo

4.1 Demo1

在Demo中我们简化下书写,每个字母代表一个节点,字母的值代表节点的key


// 之前
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节点被标记为移动

4.2 Demo2

// 之前
abcd

// 之后
dabc

===第一轮遍历开始===
d(之后)vs a(之前)  
key改变,不能复用,跳出遍历
===第一轮遍历结束===

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

将剩余oldFiber(abcd)保存为map

继续遍历剩余newChildren

// 当前oldFiber:abcd
// 当前newChildren dabc

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

继续遍历剩余newChildren

// 当前oldFiber:abc
// 当前newChildren abc

key === a 在 oldFiber中存在
const oldIndex = a(之前).index; // 之前节点为 abcd,所以a.index === 0
此时 oldIndex === 0;
比较 oldIndex 与 lastPlacedIndex;
oldIndex 0 < lastPlacedIndex 3
则 a节点需要向右移动

继续遍历剩余newChildren

// 当前oldFiber:bc
// 当前newChildren bc

key === b 在 oldFiber中存在
const oldIndex = b(之前).index; // 之前节点为 abcd,所以b.index === 1
此时 oldIndex === 1;
比较 oldIndex 与 lastPlacedIndex;
oldIndex 1 < lastPlacedIndex 3
则 b节点需要向右移动

继续遍历剩余newChildren

// 当前oldFiber:c
// 当前newChildren c

key === c 在 oldFiber中存在
const oldIndex = c(之前).index; // 之前节点为 abcd,所以c.index === 2
此时 oldIndex === 2;
比较 oldIndex 与 lastPlacedIndex;
oldIndex 2 < lastPlacedIndex 3
则 c节点需要向右移动

===第二轮遍历结束===

可以看到,我们以为从 abcd 变为 dabc,只需要将d移动到前面。

但实际上React保持d不变,将abc分别移动到了d的后面。

从这点可以看出,考虑性能,我们要尽量减少将节点从后面移动到前面的操作。

参考

React 技术揭秘

Je suppose que tu aimes

Origine juejin.im/post/7118580620195790855
conseillé
Classement