Diff 算法
一、概念
1. Diff 算法是什么
Diff 算法是建立在虚拟 DOM 的基础上的,是探讨的就是虚拟 DOM 树发生变化后,生成 DOM 树更新补丁的方式。它通过对比新旧两棵虚拟 DOM 树的变更差异,将更新补丁作用于真实 DOM,以最小成本完成视图更新
2. 遍历算法
遍历算法指沿着某条搜索路线,依次对树的每个节点做访问。通常分为两种:深度优先遍历和广度优先遍历。
深度优先遍历,是从根节点出发,沿着左子树方向进行纵向遍历,直到找到叶子节点为止。然后回溯到前一个节点,进行右子树节点的遍历,直到遍历完所有可达节点。
广度优先遍历,则是从根节点出发,在横向遍历二叉树层段节点的基础上,纵向遍历二叉树的层次。
React 的 diff 算法采用了深度优先遍历算法。因为广度优先遍历可能会导致组件的生命周期时序错乱,而深度优先遍历算法就可以解决这个问题。
3. 优化策略
虽然深度优先遍历保证了组件的生命周期时序不错乱,但传统的 diff 算法也带来了一个严重的性能瓶颈,React文档协调中提到,即使在最前沿的算法中,将前后两棵树完全比对的算法的复杂程度为 O(n^3),其中n是树中元素的数量。正如计算机科学中常见的优化方案一样,React 用了一个非常经典的手法将复杂度降低为 O(n),也就是分治,即通过“分而治之”这一巧妙的思想分解问题。
为了降低算法复杂度, React 的 diff 会预设三个限制:
-
只对同级元素进行
diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用它。 -
两个不同类型的元素会产生出不同的树。如果元素由
div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。 -
开发者可以通过
keyprop 来暗示哪些子元素在不同的渲染下能保持稳定
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的后面。
从这点可以看出,考虑性能,我们要尽量减少将节点从后面移动到前面的操作。