Diff算法概览
在beginWork中会使用Diff算法,对于Diff算法的本质是用来对比Current Fiber与JSX对象,来生成workInProgress Fiber。
对于Diff算法中,将两棵树完全比对的算法的复杂度为O(n3),其中n是树中元素的数量,对于O(n3)的时间复杂度开销代价过大,所以在React中为了降低时间复杂度,对diff会预设三个限制,限制如下:
- 只会对同级元素进行diff。如果Dom节点在更新前后跨越了层级,那么React则不会进行复用之前的元素节点。
- 更新前后元素节点发生变化。React会销毁更新前的元素节点和其对应的子节点,并新建更新后的元素节点和子节点
- 可以通过key值来保证更新前后元素进行复用,而不是被销毁重建
Diff算法的实现
对于Diff算法的入口函数是从reconcileChildFibers来开始的,通过newChild的类型来调用处理不同的处理函数,源码如下:
// 根据newChild类型选择不同diff函数处理
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
): Fiber | null {
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
// object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// 调用 reconcileSingleElement 处理
return placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
expirationTime,
),
);
// // ...省略其他case
}
}
if (typeof newChild === 'string' || typeof newChild === 'number') {
// 调用 reconcileSingleTextNode 处理
return placeSingleChild(
reconcileSingleTextNode(
returnFiber,
currentFirstChild,
'' + newChild,
expirationTime,
),
);
// ...省略
}
if (isArray(newChild)) {
// 调用 reconcileChildrenArray 处理
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
expirationTime,
);
// ...省略
}
// 一些其他情况调用处理函数
// ...省略
// 以上都没有命中,删除节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
从上面的代码可以看出,当newChild为object此时为Dom节点,string和number时,此时为文本节点,当为array的时候此时为同级有多个节点
单节点Diff
通过上述的源码我们可以看出,对于单个Dom节点的Diff的函数为reconcileSingleElement,下面我们来分析一下这个函数的源码
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement
): Fiber {
const key = element.key;
let child = currentFirstChild;
// 首先判断是否存在对应DOM节点
while (child !== null) {
// 上一次更新存在DOM节点,接下来判断是否可复用
// 来比较key和type
// 首先比较key是否相同
if (child.key === key) {
// 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 {
// key不同,将该fiber标记为删除
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 创建新Fiber,并返回 ...省略
}
对于单节点的Diff,首先判断key是否相同,然后判断type是否相同,如果key和type都相同时才会对这个节点进行复用。
当dom节点存在并且key相同和type不同时则执行deleteRemainingChildren将child和兄弟fiber都标记删除。当dom节点存在,key不相同的时候直接将对应的dom删除。
例子如下:
当前页面有3个li标签,我们要全部删除,再插入一个p标签。
// 当前页面显示的
ul > li * 3
// 这次需要更新的
ul > p
由于本次更新时只有一个p,属于单一节点的Diff,会走上面介绍的代码逻辑。
在reconcileSingleElement中遍历之前的3个fiber(对应的DOM为3个li),寻找本次更新的p是否可以复用之前的3个fiber中某个的DOM。
当key相同且type不同时,代表我们已经找到本次更新的p对应的上次的fiber,但是p与li type不同,不能复用。既然唯一的可能性已经不能复用,则剩下的fiber都没有机会了,所以都需要标记删除。
当key不同时只代表遍历到的该fiber不能被p复用,后面还有兄弟fiber还没有遍历到。所以仅仅标记该fiber删除。
多节点diff
情况列举
我们的newChild为Array类型,此时对应调用的函数为reconcileChildrenArray,对于多节点diff时,会有以下情况出现,例如:
- 节点更新
// 之前
<ul>
<li key="0" className="before">0<li>
<li key="1">1<li>
</ul>
// 更新之后 情况1 —— 节点属性变化
<ul>
<li key="0" className="after">0<li>
<li key="1">1<li>
</ul>
// 更新之后 情况2 —— 节点类型更新
<ul>
<div key="0">0</div>
<li key="1">1<li>
</ul>
- 节点新增或减少
// 更新之前
<ul>
<li key="0">0<li>
<li key="1">1<li>
</ul>
// 更新之后 情况1 —— 新增节点
<ul>
<li key="0">0<li>
<li key="1">1<li>
<li key="2">2<li>
</ul>
// 更新之后 情况2 —— 删除节点
<ul>
<li key="1">1<li>
</ul>
- 节点位置变化
// 之前
<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算法
对于React团队发现在日常开发中对于更新组件的频率,会比新增和删除的频率更高,所以在diff算法里,判断更新的优先级会更高。
对于Vue2的diff算法使用了双指针,React的diff算法没有使用双指针,是因为更新的jsx对象的newChildren为数组的形式,但是和newChildren中每个组件比较的是current fiber,对fiber的兄弟节点是通过silbing来相连的,我们通过下标来去获取下一个newChildren项,但是对于fiber只能通过fiber.silbing来获取对应的项,所以没有使用双指针法来进行diff。
源码
此时对应调用的函数为reconcileChildrenArray,源码如下:
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes,
): Fiber | null {
/* * returnFiber:currentFirstChild的父级fiber节点
* currentFirstChild:当前执行更新任务的WIP(fiber)节点
* newChildren:组件的render方法渲染出的新的ReactElement节点
* lanes:优先级相关
* */
// resultingFirstChild是diff之后的新fiber链表的第一个fiber。
let resultingFirstChild: Fiber | null = null;
// resultingFirstChild是新链表的第一个fiber。
// previousNewFiber用来将后续的新fiber接到第一个fiber之后
let previousNewFiber: Fiber | null = null;
// oldFiber节点,新的child节点会和它进行比较
let oldFiber = currentFirstChild;
// 存储固定节点的位置
let lastPlacedIndex = 0;
// 存储遍历到的新节点的索引
let newIdx = 0;
// 记录目前遍历到的oldFiber的下一个节点
let nextOldFiber = null;
// 该轮遍历来处理节点更新,依据节点是否可复用来决定是否中断遍历
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// newChildren遍历完了,oldFiber链没有遍历完,此时需要中断遍历
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber; oldFiber = null;
} else {
// 用nextOldFiber存储当前遍历到的oldFiber的下一个节点
nextOldFiber = oldFiber.sibling;
}
// 生成新的节点,判断key与tag是否相同就在updateSlot中
// 对DOM类型的元素来说,key 和 tag都相同才会复用oldFiber
// 并返回出去,否则返回null
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
// newFiber为 null说明 key 或 tag 不同,节点不可复用,中断遍历
if (newFiber === null) {
if (oldFiber === null) {
// oldFiber 为null说明oldFiber此时也遍历完了
// 是以下场景,D为新增节点
// 旧 A - B - C
// 新 A - B - C - D oldFiber = nextOldFiber;
}
break;
}
if (shouldTrackSideEffects) {
// shouldTrackSideEffects 为true表示是更新过程
if (oldFiber && newFiber.alternate === null) {
// newFiber.alternate 等同于 oldFiber.alternate
// oldFiber为WIP节点,它的alternate 就是 current节点
// oldFiber存在,并且经过更新后的新fiber节点它还没有current节点,
// 说明更新后展现在屏幕上不会有current节点,而更新后WIP
// 节点会称为current节点,所以需要删除已有的WIP节点
deleteChild(returnFiber, oldFiber);
}
}
// 记录固定节点的位置
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 将新fiber连接成以sibling为指针的单向链表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
// 将oldFiber节点指向下一个,与newChildren的遍历同步移动
oldFiber = nextOldFiber;
}
// 处理节点删除。新子节点遍历完,说明剩下的oldFiber都是没用的了,可以删除.
if (newIdx === newChildren.length) {
// newChildren遍历结束,删除掉oldFiber链中的剩下的节点
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
// 处理新增节点。旧的遍历完了,能复用的都复用了,所以意味着新的都是新插入的了
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
// 基于新生成的ReactElement创建新的Fiber节点
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
// 记录固定节点的位置lastPlacedIndex
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 将新生成的fiber节点连接成以sibling为指针的单向链表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
return resultingFirstChild;
}
// 执行到这是都没遍历完的情况,把剩余的旧子节点放入一个以key为键,值为oldFiber节点的map中
// 这样在基于oldFiber节点新建新的fiber节点时,可以通过key快速地找出oldFiber
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 节点移动
for (; newIdx < newChildren.length; newIdx++) {
// 基于map中的oldFiber节点来创建新fiber
const newFiber = updateFromMap( existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes, );
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
// 因为newChildren中剩余的节点有可能和oldFiber节点一样,只是位置换了,
// 但也有可能是是新增的.
// 如果newFiber的alternate不为空,则说明newFiber不是新增的。
// 也就说明着它是基于map中的oldFiber节点新建的,意味着oldFiber已经被使用了,所以需
// 要从map中删去oldFiber
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
}
}
// 移动节点,多节点diff的核心,这里真正会实现节点的移动
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 将新fiber连接成以sibling为指针的单向链表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
if (shouldTrackSideEffects) {
// 此时newChildren遍历完了,该移动的都移动了,那么删除剩下的oldFiber
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
return resultingFirstChild;
}
两轮遍历
所以React的diff算法的整体逻辑会经历两轮的遍历。
-
对于第一轮的遍历会处理需要更新的节点,判断是否需要复用,如果可以复用,则i++,继续判断下一个是否可以进行复用。直到遍历到不能复用时或者遍历完毕后((
i === newChildren.length - 1
或者oldFiber.sibling === null
)直接退出第一轮的遍历。- 对于不可以复用,分为两种情况,第一种是key值不同导致不可以复用,第二种情况是key相同但type不同导致不可复用,并将oldFiber标记为DELETION,并继续遍历。
-
对于第二轮遍历来讲主要是把老Fiber放在map里,然后遍历剩余的newChildren是否与map进行匹配,对于第一轮未遍历处理完毕的情况都会放在第二轮进行处理。对于第一轮遍历的结果可以分为如下的情况:
- newChildren与oldFiber同时遍历完毕(最好的情况)。只需要第一轮遍历,遍历完毕后diff更新完毕。
- newChildren没遍历完毕,对于oldFiber遍历完毕。因为oldFiber都遍历完毕,所以对于已有的Dom节点都进行了复用,此时newChildren没遍历完说明有新增的节点,此时需要将newChildren所生成的workInProgress Fiber依次标记为Placement(新增)
- oldFiber没遍历完毕,对于newChildren遍历完毕。对于上述情况说明我们的节点被删除了一部分,所以需要遍历剩下的oldFiber,来将其标记为Deletion
- oldFiber和newChildren都没有遍历完毕。当前情况意味着,有些节点在本次更新中存在移动和删除等等。这个是重点需要讲述的,
通过上述讲述来讲,我们明白了对于新增、删除和更新是如何判断的,接下来我们研究一下对于newChildren和oldFiber都没遍历完毕的情况。
标记移动
为了快速找到key对应的oldFiber,需要将所有还未处理的oldFiber存入进map中,此时对应执行的函数为mapRemainingChildren。使得后续我们可以通过key来匹配对应的oldFiber。
既然我们的目标是寻找移动的节点,那么我们需要明确:节点是否移动是以什么为参照物?我们的参照物是:最后一个可复用的节点在oldFiber中的位置索引(用变量lastPlacedIndex表示)。由于本次更新中节点是按newChildren的顺序排列。在遍历newChildren过程中,每个遍历到的可复用节点一定是当前遍历到的所有可复用节点中最靠右的那个,即一定在lastPlacedIndex对应的可复用的节点在本次更新中位置的后面。
那么我们只需要比较遍历到的可复用节点在上次更新时是否也在lastPlacedIndex对应的oldFiber后面,就能知道两次更新中这两个节点的相对位置改变没有。
我们用变量oldIndex表示遍历到的可复用节点在oldFiber中的位置索引。如果oldIndex < lastPlacedIndex,代表本次更新该节点需要向右移动,将节点标记为Placement。
lastPlacedIndex初始为0,每遍历一个可复用的节点,如果oldIndex >= lastPlacedIndex,则lastPlacedIndex = oldIndex。
上述逻辑也就是placeChild函数中的逻辑,placeChild源码如下:
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number,
newIndex: number,
): number {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
// Noop.
return lastPlacedIndex;
}
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// This is a move.
newFiber.effectTag = Placement;
return lastPlacedIndex;
} else {
// This item can stay in place.
return oldIndex;
}
} else {
// This is an insertion.
newFiber.effectTag = Placement;
return lastPlacedIndex;
}
}
例子:
// 之前
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节点被标记为移动