Algorithme différentiel
1. Conception
1. Qu'est-ce que l'algorithme Diff ?
Diff
L'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.
React
L' diff
algorithme 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' diff
algorithme 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ù n
est le nombre d'éléments dans l'arbre. Tout comme le schéma d'optimisation courant en informatique, React
une 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
-
Uniquement sur les éléments frères
diff
. SiDOM节点
l'on croise la hiérarchie entre deux mises à jour, alorsReact
aucune tentative ne sera faite pour la réutiliser. -
两个不同类型的元素会产生出不同的树。如果元素由
div
变为p
,React
会销毁div
及其子孙节点,并新建p
及其子孙节点。 -
开发者可以通过
key
prop 来暗示哪些子元素在不同的渲染下能保持稳定
4. Diff 函数入口
diff
的入口是 reconcileChildFibers
函数,该函数会根据newChild
(即JSX对象
)类型调用不同的处理函数。
从同级的节点数量来看可以将Diff分为两类:
- 单节点 Diff,当
newChild
类型为object
、number
、string
,代表同级只有一个节点 - 多节点 Diff,当
newChild
类型为Array
,同级有多个节点
5. 环境准备
我直接通过 create-react-app 创建项目,打开控制台,找到 react-dom.development.js 文件,找到 reconcileSingleElement
函数,打上断点
鼠标放在控制台,ctrl + p,输入想要查找的文件
‘
// 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
对于单个节点,以类型 object
为例,会进入 reconcileSingleElement
,reconcileSingleElement
接受四个参数含义分别是
- returnFiber: current Fiber 的父节点
- currentFirstChild:current Fiber(DOM 节点对应的 Fiber 节点)
- newChild: JSX对象(更新到页面的数据)
- lanes: 更新的优先级
一个
DOM节点
在某一时刻最多会有4个节点和它相关。
current Fiber
。如果该DOM节点
已在页面中,current Fiber
代表该DOM节点
对应的Fiber节点
。workInProgress Fiber
。如果该DOM节点
将在本次更新中渲染到页面中,workInProgress Fiber
代表该DOM节点
对应的Fiber节点
。DOM节点
本身。JSX对象
。即ClassComponent
的render
方法的返回结果,或FunctionComponent
的调用结果。JSX对象
中包含描述DOM节点
的信息。Diff算法
的本质是对比1和4,生成2。
在 reconcileSingleElement
函数中我们可以看到,将 currentFistChild
赋值给 child
, 如果 child 等于 null 就直接创建新的 Fiber 节点并返回,那什么情况下 child 会为 null?
child
(也就是 current Fiber
代表该DOM节点
对应的Fiber节点
) 为 null
, 也就代表 DOM 节点不存在,在 mount(挂载)阶段 DOM 节点就不存在,或者更新前就没有 DOM 节点,那 child 自然就为 null
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>
是考虑像这种更新前是多个节点,后面的兄弟节点可以复用,在前面节点不匹配的情况下,我们可以继续对比后面的兄弟节点是否可以复用
key 相同,就继续对比其 type 是否相同
-
key 相同,type 也相同
- 其它多余的兄弟 Fiber 标记未删除,复用该 Fiber
-
key 相同,type 不同
- 将该 Fiber 及其兄弟 Fiber 标记为删除,跳出循环,创建新 Fiber 返回
deleteRemainingChildren
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 reconcileChildFibers
的 newChild
参数类型为 Array
,在 reconcileChildFibers
函数中回调用 reconcileChildrenArray
- returnFiber: current Fiber 的父节点
- currentFirstChild:current Fiber(DOM 节点对应的 Fiber 节点)
- newChild: JSX对象(更新到页面的数据)
- lanes: 更新的优先级
1. 多节点 Diff 的几种情况
- 节点更新
- 属性更新
- 类型更新
- 节点增删
- 节点移动
React团队
发现,在日常开发中,相较于新增
和删除
,更新
组件发生的频率更高。所以Diff
会优先判断当前节点是否属于更新
。
进入 reconcileChildrenArray
函数,开始一堆注释大概就是说,这个算法不能通过双指针优化,因为同级的Fiber节点
是由sibling
指针链接形成的单链表,即不支持双指针遍历...
- currentFirstChild: 通过
sibling
指针链接的单链表 - newChildren: 数组
基于以上原因,Diff算法
的整体会经历两轮遍历:
-
第一轮遍历:处理
更新
的节点。 -
第二轮遍历:处理剩下的不属于
更新
的节点。
2. 第一轮遍历
变量描述
-
resultingFirstChild: 返回的结果
workInProgress Fiber
(如果该DOM 节点
将在本次更新中渲染到页面中,workInProgress Fiber
代表该DOM 节点
对应的fiber
) -
previousNewFiber: 多节点
diff
, 会创建多个fiber
节点,节点通过 sibling 连接,previousNewFiber
则做为中间变量,用来对我们创建Fiber
进行连接
-
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>
);
在 reconcileChildrenArray
方法打上断点,进入第一轮遍历,开始执行 updateSolt
函数
在 updateSolt
函数中,会判断 key
是否相同
- key 不同,则返回
null
,跳出循环,第一轮遍历结束
- key 相同,则执行
updateElement
函数,在updateElement
函数判断 type 是否相同
,type 相同
则复用,不同
则新建
回到第一轮遍历,type 不同
返回的 newFiber.laernate
的值 null
,该 oldFibler
判断标记为删除
进入 placeChild
函数,是对需要移动节点进行标记
判断 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
- 意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的
-
oldFiber 遍历完
- 意味着本次更新有新节点插入,我们只需要遍历剩下的
newChildren
生成的workInProgress fiber
依次标记Placement
- 意味着本次更新有新节点插入,我们只需要遍历剩下的
newChildren 没遍历完,oldFiber 也没遍历完
了快速的找到key
对应的oldFiber
,将所有还未处理的oldFiber
存入以key
为key,oldFiber
为value的Map
中。
接下来遍历剩余的newChildren
,通过newChildren[i].key
就能在existingChildren
中找到key
相同的oldFiber
,剩下的判断逻辑同上
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
的后面。
从这点可以看出,考虑性能,我们要尽量减少将节点从后面移动到前面的操作。