本篇文章我们来了解一下Diff算法的实现过程。
相关概念
我们先来看看在React Diff的过程中涉及到的一些相关概念。
React中的各种节点
假设当前存在一个DOM节点,触发了一次更新,那么在协调的过程中,会有四种节点和该节点相关联:
-
该
DOM节点
本身。 -
workInProgress fiber
,更新过程中产生的workInProgress Tree中的fiber节点(即current fiber.alternate)。 -
current fiber
,在页面中已经渲染了的DOM节点对应的fiber节点(即workInProgress fiber.alternate,也就是上一次更新中产生的workInProgress fiber)。 -
ReactElement
,更新过程中,ClassComponent的render方法或FunctionComponent的调用结果。ReactElement中包含描述DOM节点的信息。
Diff算法可以理解为更新的过程中产生了新的ReactElement, 在f协调过程中将ReactElement与对应的current fiber进行对比后, 产生了workInProgress fiber。
双缓冲机制
在探索React源码:初探React fiber一文中我们介绍过, 双缓存机制是一种在内存中构建并直接替换的技术。在render的过程中就使用了这种技术。
在React中同时存在着两棵fiber tree
。一棵是在屏幕上显示的dom对应的fiber tree,称为current fiber tree
,而还有一棵是当触发新的更新任务时,React在内存中构建的fiber tree,称为workInProgress fiber tree
。
current fiber tree
和workInProgress fiber tree
中的fiber节点通过alternate属性进行连接。
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
React应用的根节点中也存在current属性,利用current属性在不同fiber tree的根节点之间进行切换的操作,就能够完成current fiber tree与workInProgress fiber tree之间的切换。
在协调阶段,React利用diff算法
,将产生update的React element
与current fiber tree
中的节点进行比较,并最终在内存中生成workInProgress fiber tree。此时Renderer会依据workInProgress fiber tree将update渲染到页面上。同时根节点的current属性会指向workInProgress fiber tree,此时workInProgress fiber tree就变为current fiber tree。
effectTag
effectTag正是用于保存要执行DOM操作的具体类型的。 effectTag通过二进制表示:
//...
// 意味着该Fiber节点对应的DOM节点需要插入到页面中。
export const Placement = /* */ 0b000000000000010;
//意味着该Fiber节点需要更新。
export const Update = /* */ 0b000000000000100;
export const PlacementAndUpdate = /* */ 0b000000000000110;
//意味着该Fiber节点对应的DOM节点需要从页面中删除。
export const Deletion = /* */ 0b000000000001000;
//...
Diff的瓶颈
在React官网的协调一文提到:
在某一时间节点调用 React 的
render()
方法,会创建一棵由 React 元素组成的树。在下一次 state 或 props 更新时,相同的render()
方法会返回一棵不同的树。React 需要基于这两棵树之间的差别来判断如何高效的更新 UI,以保证当前 UI 与最新的树保持同步。此算法有一些通用的解决方案,即生成将一棵树转换成另一棵树的最小操作次数。然而,即使使用最优的算法,该算法的复杂程度仍为 O(n 3 ),其中 n 是树中元素的数量。
React如何解决Diff的瓶颈
React 在以下三个假设的基础之上提出了一套 O(n) 的启发式算法:
-
只对同级元素进行Diff;
-
两个不同类型的元素会产生出不同的树;
-
开发者可以通过设置 key 属性,来告知渲染哪些子元素在不同的渲染下可以保存不变;
React Diff 的实现
React Diff的入口函数为reconcileChildren
,reconcileChildren
内部会通过current === null 区分当前fiber节点是mount还是update,再分别执行不同的工作:
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes
) {
if (current === null) {
// 对于mount的组件
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
// 对于update的组件
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}
mountChildFibers
与reconcileChildFibers
的都是通过ChildReconciler生成的。
export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);
他们的不同点在于shouldTrackSideEffects
的参数的值不一样,shouldTrackSideEffects
为true时会为生成的fiber节点收集effectTag
属性,反之不会进行收集effectTag
属性。
function ChildReconciler(shouldTrackSideEffects) {
//...
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes,
): Fiber | null {
//...
}
function reconcileChildrenIterator(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildrenIterable: Iterable<*>,
lanes: Lanes,
): Fiber | null {
//...
}
function reconcileSingleTextNode(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
textContent: string,
lanes: Lanes,
): Fiber {
//...
}
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
//...
}
function reconcileSinglePortal(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
portal: ReactPortal,
lanes: Lanes,
): Fiber {
//...
}
// This API will tag the children with the side-effect of the reconciliation
// itself. They will be added to the side-effect list as we pass through the
// children and the parent.
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
//...
}
return reconcileChildFibers;
}
ChildReconciler
内部定义了许多协调相关的函数,并将reconcileChildFibers
作为函数的返回值。也就是说ChildReconciler
内部定义reconcileChildFibers
才是React Diff真正的入口函数。reconcileChildFibers
内部会根据newChild
(即新生成的ReactElement
)的类型和$$typeof
属性调用不同的处理函数。
// 根据newChild进入不同Diff函数处理
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
): Fiber | null {
//...
if (typeof newChild === 'object' && newChild !== null) {
// object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// 调用 reconcileSingleElement 处理
// ...
}
}
if (typeof newChild === 'string' || typeof newChild === 'number') {
// 调用 reconcileSingleTextNode 处理
// ...
}
if (isArray(newChild)) {
// 调用 reconcileChildrenArray 处理
// ...
}
if (getIteratorFn(newChild)) {
// 调用 reconcileChildrenIterator处理
// ...
}
// ...
// 如果以上分支都没有命中,则删除节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
我们可以根据newChild
中节点的数量把Diff分为两种类型:
- 当newChild只有一个节点,也即newChild类型为object、number、string时,我们会进入单节点的Diff
- 当newChild有多个节点,也即类型为Array时,进入多节点的Diff
单节点的Diff
单节点指的是newChild
为单一节点,但此节点在current fiber tree中对应的层级具有多少个节点我们是不确定的,因此我们可以根据此节点在current fiber tree中对应的层级的节点数量分为三种场景:
- 对应层级存在单个旧节点
旧: A
新: A'
- 对应层级存在多个旧节点
旧: A -> B -> C
新: A'
- 对应层级无旧节点
旧: null
新: A'
场景1和场景2我们可以归为一类(即在当前层级中,current fiber在sibling方向的链表中至少存在一个节点),场景3单独为一类(当前层级无current fiber节点)。
当前层级的current fiber在sibling方向的链表不为空
此时我们需要循环遍历当前层级在sibling
方向的链表的所有current fiber
节点,判断是否可以复用(key相同且type相同)当前层级的current fiber
节点的stateNode(即复用fiber节点对应的DOM节点),可以则以current fiber
作为副本生成workInProgress fiber
。当前层级所有的current fiber
节点都无法复用时,直接生成一个新的fiber节点作为workInProgress fiber
。
当前层级的current fiber在sibling方向的链表为空
由于不存在对应的current fiber
节点,所以直接生成一个新的fiber节点作为workInProgress fiber
。
reconcileSingleElement
的具体实现
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const key = element.key;
let child = currentFirstChild;
//循环遍历当前这一层级的current fiber节点
while (child !== null) {
if (child.key === key) {
// key相同, 判断当前 current fiber.elementType 是否等于 ReactElement.type
switch (child.tag) {
//...
default: {
if (child.elementType === element.type) {
// type相同,表示可以复用当前fiber节点, 由于是单节点,所以如果当前节点存在兄弟节点, 需要给兄弟节点打上Deletion effectTag(删除兄弟节点)
deleteRemainingChildren(returnFiber, child.sibling);
// 构造workInProgress fiber节点,构造过程中会复用当前fiber节点的stateNode, 即复用DOM对象
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
return existing;
}
break;
}
}
// key相同但type不相同,则找到了当前节点在上次更新时对应的节点,但两个节点的type已经不一致,并且后续节点也没有可能进行复用,所以删除当前current fiber节点以及节点的剩余兄弟节点
deleteRemainingChildren(returnFiber, child);
break;
} else {
// key不一致则给当前current fiber节点打上Deletion effectTag,删除当前节点,并继续往下一个兄弟节点遍历,尝试匹配
deleteChild(returnFiber, child);
}
child = child.sibling;
}
//DOM节点不可复用 新建fiber作为ReactElement对应的WorkInProgress Fiber
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
注意,在为current fiber
节点不匹配并打上Deletion effectTag
时, 会同时将其添加到父节点的effectList
中(正常effectList的收集是在completeWork
中进行的, 但是被删除的节点会脱离fiber
树, 无法进入completeWork
的流程, 所以在beginWork
阶段提前加入父节点的effectList);
多节点的Diff
当newChild
为多节点时,以下的几种场景中会触发多节点的Diff。
- 节点更新
//节点属性发生变更;
//旧
<div key="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
//新
<div key="a" className="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
//节点的类型发生变更;
//旧
<div key="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
//新
<span key="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
- 节点的数量发生改变;
//删除节点
//旧
<div key="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
//新
<div key="a">a</div>
//新增节点
//旧
<div key="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
//新
<div key="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
<div key="d">d</div>
<div key="e">e</div>
- 节点的位置发生改变;
//旧
<div key="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
<div key="e">e</div>
//新
<div key="a">a</div>
<div key="c">c</div>
<div key="b">b</div>
<div key="e">e</div>
我们在reconcileChildFibers
的分析中可以看到,React使用两个函数reconcileChildrenArray
(针对数组类型)和reconcileChildrenIterator
(针对可迭代类型)来进行多节点的diff,我们重点看看reconcileChildrenArray
。
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes,
): Fiber | null {
//...
}
其中参数currentFirstChild
指的是current fiber tree
在该层级中的第一个fiber,通过 fiber.sibling
我们可以得到一条从左到右串联currentFirstChild
在当前层级的所有fiber
节点的链表,我们使用oldFiber
来表示这条链表。
参数newChildren
是一个数组,包含了当前层级的所有ReactElement
对象。
reconcileChildrenArray
会对比这两个序列之间的差异,并生成workInProgress fiber tree
在当前层级的fiber
节点链表,并使用resultingFirstChild
作为头节点。对比的过程会经历两轮的遍历。
第一轮遍历
-
遍历比较
newChildren
与oldFiber
,判断DOM节点是否可复用。 -
如果可复用,则复用
oldFiber
生成newFiber
并加入到以resultingFirstChild
作为头节点的序列(后续我们使用newFiberList
表示此序列)中,两个序列都跳到下一个节点。 -
如果不可复用,分两种情况进行处理:
- 若key不一致,则立即终止第一轮遍历。
- 若key相同但type不同,则删除当前oldFiber节点,生成新的fiber节点加入到
newFiberList
中,两个序列都跳到下一个节点,继续遍历。
-
当两个序列中的任意一个序列完成遍历(即
newIdx >= newChildren.length - 1 || oldFiber.sibling === null
),则结束第一轮遍历。
第二轮遍历
第一轮遍历后,会产生四种结果。
oldFiber
与newChildren
都完成遍历了
此时本轮diff已经完成了。这种情况是最理想的情况,只需要进行第一轮遍历中对应的更新操作。
oldFiber
遍历完了,但newChildren
还没遍历完。
假设newFiberList
中所有节点都可以是可以直接复用的,那么当前场景和我们之前讨论过的新增节点的场景是一致的。
//新增节点
//旧
<div key="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
//新
<div key="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
<div key="d">d</div>
<div key="e">e</div>
这意味着未遍历完的newChildren
节点都是新增的,我们需要遍历剩下的newChildren
,生成对应的workInProgress fiber
并标记上Placement。
此处只是假设第一轮遍历中所有加入到
newFiberList
的节点都是可以复用的,便于大家理解,但前面我们有分析过,在第一轮遍历中能够加入到newFiberList
的节点有两种:
- 可以直接复用的节点;
- key相同但type不同的节点;
newChildren
遍历完了,但oldFiber
还没遍历完
我们再次假设加入到newFiberList
中所有节点都可以是可以直接复用的,这次就和我们之前讨论过的删除节点的场景是一致的了。
//旧
<div key="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
//新
<div key="a">a</div>
此时newChildren
节点都已经找到了可复用的节点,我们需要遍历剩下的oldFiber
,并标记上Deletion。
newChildren与oldFiber都没完成遍历
这意味着这次更新中有可能存在节点改变了位置。我们以前面提到的位置移动的例子来进行分析。
//旧
<div key="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
<div key="e">e</div>
//新
<div key="a">a</div>
<div key="c">c</div>
<div key="b">b</div>
<div key="e">e</div>
经过第一轮遍历后,key值为a的节点复用oldFiber
节点生成newFiber
节点,并加入到了newFiberList
中。在进行第二个节点的比较时,新节点c和旧节点b的key值不一致,此时我们会终止第一轮遍历,进入第二轮遍历。
由于可能存在移动的节点,所以节点的索引值不能够找出两个对应的节点,我们需要一个找到新旧节点对的方法。这时,节点的key
值就起作用了。我们前面提到:
开发者可以通过设置 key 属性,来告知渲染哪些子元素在不同的渲染下可以保存不变;
React会将还未处理的oldFiber节点存入以节点key为key,oldFiber为value的Map中。
//existingChildren 是一个以节点key为key,oldFiber为value的Map
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
然后继续遍历newChildren
序列,并通过newChildren
节点的key来查询existingChildren
中是否存在key相同的oldFiber
节点。
接下来我们需要通过第二轮遍历去标记哪些节点可以复用,哪些节点需要移动。此时我们需要一个参照索引来判断节点是否需要移动。
我们以newFiberList
中最靠右的节点对应的oldFiber
在oldFiber
链表中的位置索引oldIndex
作为参照索引,并使用lastPlacedIndex
表示参照索引。
上面分析的例子中,在结束第一轮遍历后,lastPlacedIndex就是newFiberList
的最右一个节点a的oldIndex
,也就是0。
然后开始第二轮的遍历
newChildren
节点c
newChildren
节点c通过existingChildren
查询到了oldFiber
节点c,发现oldFiber
节点c的索引oldIndex
为 2
,大于lastPlacedIndex=0
。
由lastPlacedIndex
的定义可知,若当前的oldIndex
大于lastPlacedIndex
,意味着oldFiber
节点c的位置是在newFiberList
的最右一个节点对应的oldFiber
节点的右边。同时,由于newChildren
序列是按顺序遍历的,所以当前的newChildren
生成的newFiber
节点一定是在newFiberList
的最右一个节点对应的newFiber
节点的右边。因此,节点c更新前后的位置没有发生。
此时,因为节点c的oldIndex
> lastPlacedIndex
,所以我们把lastPlacedIndex
的值更新为节点c的oldIndex=2
。同时把节点c生成的newFiber
加入到newFiberList
中,继续遍历。
newChildren
节点b
newChildren
节点b通过existingChildren
查询到了oldFiber
节点b,发现oldFiber
节点b的索引oldIndex
为 1
,小于lastPlacedIndex=2
。oldFiber
节点b的位置是在newFiberList
的最右一个节点对应的oldFiber
节点的左边。但newChildren
节点b生成的newFiber
节点一定是在newFiberList
的最右一个节点对应的newFiber
节点的右边的。因此,节点b更新前后的位置会发生变化。
此时,因为节点b的oldIndex
< lastPlacedIndex
,所以lastPlacedIndex
不变,并把节点b生成的newFiber
标记Placement
,表示该节点更新前后的位置会发生变化,最后把newFiber
加入到newFiberList
中,继续遍历。
newChildren
节点d
newChildren
节点d通过existingChildren
查询到了oldFiber
节点d,发现oldFiber
节点d的索引oldIndex
为 3
,大于lastPlacedIndex=3
。 处理逻辑与节点c一致,不再展开。
diff完成,我们也得到了该层级中完整的newFiberList
,作为workInProgress fiber tree
在该层级的fiber
节点。
我再来看看另一个例子
//旧
A->B->C->D
//新
A->D->B->C
我们可以看到,节点D时不会移动的,只有当节点需要的整体位置需要向右移动时(本例子中就是节点B、节点C),节点的位置才会发生变更,因此我们要尽量减少将节点从后面移动到前面的操作(这回导致原本位于他前面的节点需要往右移动)。
reconcileChildrenArray
的具体实现
最后我们来看看reconcileChildrenArray
是如何实现上面的逻辑的。
/* * returnFiber:当前层级的节点的父节点,即currentFirstChild的父级fiber节点
* currentFirstChild:当前层级的第一个current fiber节点
* newChildren:当前层级的ReactElement节点
* lanes:优先级相关
* */
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes,
): Fiber | null {
//..
// workInProgress fiber tree在此层级的第一个fiber节点,即该层级上在fiber.sibling方向的链表的头节点,在上面的分析中我们使用newFiberList来表示此序列
let resultingFirstChild: Fiber | null = null;
// previousNewFiber用来将后续的新生成的workInProgress fiber连接到newFiberList中
let previousNewFiber: Fiber | null = null;
// oldFiber节点,用于访问workInProgress fiber tree在此层级上在fiber.sibling方向的链表
let oldFiber = currentFirstChild;
// 用于存储newFiberList中最右边的节点在oldFiber链表中的index
let lastPlacedIndex = 0;
// 存储遍历到newChildren的索引
let newIdx = 0;
// 存储当前oldFiber的下一个节点
let nextOldFiber = null;
// 第一轮遍历
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
// 尝试生成新的节点,如果key不同, 返回null,key相同, 再比较type是否一致。type一致则执行useFiber(update逻辑),type不一致则运行createXXX(insert逻辑)
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
if (newFiber === null) {
// newFiber为 null说明节点不可复用,中断当前遍历
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
if (shouldTrackSideEffects) {
// shouldTrackSideEffects 为true 代表当前为update阶段
if (oldFiber && newFiber.alternate === null) {
// 此时为key相同但type不同,newFiber.alternate为空,删除掉oldFiber
deleteChild(returnFiber, oldFiber);
}
}
//更新记录lastPlacedIndex
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
//将newFiber添加到newFiberList中
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
if (newIdx === newChildren.length) {
// newChildren遍历完了,删除oldFiber链中未遍历的节点
deleteRemainingChildren(returnFiber, oldFiber);
//...
//结束此次diff
return resultingFirstChild;
}
if (oldFiber === null) {
// oldFiber遍历完了,新增newChildren未遍历的节点到newFiberList中
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
//...
//结束本次diff
return resultingFirstChild;
}
// newChildren和oldFiber都没有遍历完,将oldFiber剩余序列加入到一个map中,在第二次遍历的过程中能够通过key值找到对应的oldFiber
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 第二轮遍历
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
// 如果newFiber是通过复用创建的, 则清理map中对应的老节点
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
}
}
// 判断当前节点是否需要改变位置,并更新记录lastPlacedIndex
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 添加newFiber到newFiberList中
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
if (shouldTrackSideEffects) {
//删除未遍历的oldFiber
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
//...
//结束本次diff
return resultingFirstChild;
}
//此函数用于记录节点在此次更新中是否需要移动,并返回新的lastPlacedIndex
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number,
newIndex: number,
): number {
newFiber.index = newIndex;
//...
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// 此节点需要移动
newFiber.flags |= Placement;
return lastPlacedIndex;
} else {
// 此节点不需要移动,返回oldIndex作为lastPlacedIndex的值
return oldIndex;
}
} else {
// 这是插入的逻辑
newFiber.flags |= Placement;
return lastPlacedIndex;
}
}