React Diff原理
目录
前言
本文是React源码学习系列第五篇,该系列整体都基于React18.0.0版本源码。旨在学习过程中记录一些个人的理解。该篇介绍React Diff原理。
概念
在beginWork中没有命中优化策略的FiberNode会根据所处阶段的不同(mount或update)进入mountChildFibers或者reconcileChildFibers,它们的区别是是否追踪副作用(是否标记flags)。这一流程统称为reconcile流程。reconcile的本质是对比current Fiber Tree和JSX对象(VDOM),生成workInProgress Fiber Tree的过程。这一过程的核心算法就是Diff算法。
Diff算法本身会带来性能损耗,React文档中提到,即使在最前沿的算法中,将前后两棵树完全的对比复杂度为O(n3),其中n为树中元素的数量。为了降低算法复杂度,React的Diff算法会预设三个限制。
- 只对同级元素进行Diff。如果一个DOM元素在前后两次更新中跨越了层级,React不会尝试复用它。
- 两个不同类型的元素会产生不同的树,元素类型变更,React会销毁变更前元素及其子孙元素,并新建更新后元素及其子孙元素。
- 开发中可以通过key属性来帮助Diff。
例子
<!--更新前-->
<div>
<p key="jay">jay</p>
<h3 key="guo">guo</h3>
</div>
<!--更新后-->
<div>
<h3 key="guo">guo</h3>
<p key="jay">jay</p>
</div>
如果没有key,React会认为div的第一个子元素由p变更为h3,第二个子元素由h3变更为p。这符合限制2。当有了key来帮助前后对应关系后,React会知道元素可以复用,只需要调换顺序。
入口函数
// 保留核心代码
const reconcileChildFibers = ChildReconciler(true);
const mountChildFibers = ChildReconciler(false);
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) {
// 初次渲染除了HostRootFiber其它都走这里
if (current === null) { // 不追踪副作用
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else { // 追踪副作用
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}
reconcileChildFibers
// 保留核心代码
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
// 单个非文本节点
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return // 省略处理逻辑
}
// array类型
if (isArray(newChild)) {
return // 省略处理逻辑
}
// iterator
if (getIteratorFn(newChild)) {
return // 省略处理逻辑
}
}
// 文本节点
if (
(typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number'
) {
return // 省略处理逻辑
}
// 没有匹配到的打上删除标记
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
根据限制1,“只对同级别元素进行Diff”,可以将Diff流程分为两类。
- 更新后只有一个元素。
- 更新后有多个元素。
单节点Diff
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
// 单个非文本节点
case REACT_ELEMENT_TYPE:
return placeSingleChild(reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes)
);
}
}
placeSingleChild
placeSingleChild方法,根据是否最终副作用,决定是否给新Fiber打上Placement标记。
- HostRootFiber因为初始化的时候就有alternate,所以无论是mount还是update它的child(newFiber)都会打上Placement标记.
- 从而实现初次渲染的时候只插入一次DOM。
function placeSingleChild(newFiber: Fiber): Fiber {
if (shouldTrackSideEffects && newFiber.alternate === null) {
newFiber.flags |= Placement;
}
return newFiber;
}
reconcileSingleElement
// 保留关键代码
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const key = element.key;
let child = currentFirstChild;
// 遍历寻找可复用的老Fiber节点
while (child !== null) {
// key是否相同
if (child.key === key) {
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
// 忽略FRAGMENT
} else {
// 找到key、type相同的老Fiber节点
if (child.elementType === elementType) {
// 给老节点的其他兄弟节点打上删除标记。
deleteRemainingChildren(returnFiber, child.sibling);
// 根据老Fiber clone一个新Fiber
// useFiber最终调用createWorkInProgress,clone一个Fiber
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber
return existing;
}
}
// key相同、但是类型不同,说明老节点都不可以复用,给所有老节点都打上删除标记,并退出循环,走后面新建fiber逻辑
deleteRemainingChildren(returnFiber, child);
break;
} else {
// key不相同 打上删除标记
deleteChild(returnFiber, child);
}
// 继续下一个节点,判断是否可复用
child = child.sibling;
}
// 老节点没有找到可复用的,会走到这里,新建一个fiber并返回
if (element.type === REACT_FRAGMENT_TYPE) {
// 忽略REACT_FRAGMENT_TYPE
} else {
// 实际调用createFiber创建一个新的Fiber
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
}
reconcileSingleTextNode
新的children为单个文本节点走reconcileSingleTextNode方法。该方法内部判断是否可以复用老节点。
// 保留关键代码
function reconcileSingleTextNode(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
textContent: string,
lanes: Lanes,
): Fiber {
// 老节点是文本
if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
// 给所有兄弟Fiber打上删除标记
deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
// 复用老节点
const existing = useFiber(currentFirstChild, textContent);
existing.return = returnFiber;
return existing;
}
// 老节点不是文本,不能复用,给所有老节点都打上删除标记
deleteRemainingChildren(returnFiber, currentFirstChild);
// 新建一个新的fiber
const created = createFiberFromText(textContent, returnFiber.mode, lanes);
created.return = returnFiber;
return created;
}
- 首先判断key是否相同
- key相同在判断type是否相同。
- key相同,type不同说明没有可复用节点,全部打上删除标记。
- 都不相同,继续匹配,直到所有元素都匹配完。
多节点Diff
多节点Diff一定属于以下一种或者多种。
- 节点位置没有变化。
- 节点增删。
- 节点移动。
设计思路
根据以上三种情况,一种常见的Diff算法设计思路是:
- 判断当前节点是属于哪种情况。
- 如果是增删,执行增删操作。
- 如果位置没有变化,执行相应逻辑。
- 如果有移动,执行移动逻辑。
这个方案的前提是所有操作的优先级相同,但通常在日常开发中,“节点移动”较少发生,所以Diff算法会优先判断其他情况。基于这个理念,Diff算法的整体逻辑会经历两次遍历。
- 第一次遍历遍历尝试逐个复用节点。
- 第二轮遍历处理剩下的节点。
第一次遍历
// 参与比较的current Fiber
let oldFiber = currentFirstChild;
// 最后一个可复用oldFiber的位置索引
let lastPlacedIndex = 0;
// jsx对象索引
let newIdx = 0;
// 下一个oldFiber
let nextOldFiber = null;
// 老节点为链表,新节点为虚拟DOM数组
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// ...
}
- 遍历newChildren,将newChildren[newIdx]和oldFiber比较判断是否可复用。
- 如果可复用,继续步骤1,如果不可复用:2.1:key不相同,直接跳出循环,第一次遍历结束;2.2:key相同,type不相同,给oldFiber打上Deletion标记,继续步骤1;
- 如果newChlidren或者oldFiber遍历完,跳出循环,第一次遍历结束。
第二次遍历
进入第二次遍历有3种情况。
1.oldFiber遍历完了,newChildren未遍历完。 2.newChildren遍历完了,oldFiber未遍历完。 3.newChlidren和oldFiber都未遍历完。 情况3:
情况1
给未遍历完的newChildren节点打上Placement标记。
if (oldFiber === null) {
// 剩余的新节点都执行新建操作
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;
}
return resultingFirstChild;
}
情况2
给未遍历完的oldFiber节点都打上Deletion标记。
if (newIdx === newChildren.length) {
// 把剩余的老节点打上删除标记
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
情况3
产生这种情况的原因是节点发生了移动。导致不能按照顺序遍历完新老节点。将未处理的oldFiber生成一个Map, key为Fiber.key或者Fiber.index,value为Fiber。
mapRemainingChildren
// 保留核心代码
function mapRemainingChildren(
returnFiber: Fiber,
currentFirstChild: Fiber,
): Map<string | number, Fiber> {
const existingChildren: Map<string | number, Fiber> = new Map();
let existingChild = currentFirstChild;
while (existingChild !== null) {
if (existingChild.key !== null) {
existingChildren.set(existingChild.key, existingChild);
} else {
existingChildren.set(existingChild.index, existingChild);
}
existingChild = existingChild.sibling;
}
return existingChildren;
}
遍历未处理的newChildren。
for (; newIdx < newChildren.length; newIdx++) {
const matchedFiber = existingChildren.get(newChild.key === null ? newIdx : newChild.key) || null;
}
当找到可以复用的oldFiber后,如何确定位置有没有变化?判断的参考物是lastPlacedIndex变量,即“最后一个可复用oldFiber的位置索引”。 因为newChildren中jsx对象的顺序代表“本次更新后对应FiberNode的顺序”,所以在遍历newChildren过程中,每个新生成的wip FiberNode一定是当前最靠右的节点。 可以分两种情况:
- oldFiber.index < lastPlacedIndex
- oldFiber.index >= lastPlacedIndex
情况1
说明oldFiber在lastPlacedIndex左边。已知wip FiberNode是目前最右边的一个元素,不会在lastPlacedIndex的左边,所以oldFiber发生了移动,需要打上Placement标记。
情况2
说明oldFiber不在lastPlacedIndex左边。与wip FiberNode位置一致,不需要移动。
placeChild
判断“是否移动”逻辑在placeChild方法。
// 插入或者移动Fiber 这个方法很重要
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number, // 最后一个可复用老节点的索引。
newIndex: number, // 新children数组索引为止,因为数组是从左往右遍历的,当前新节点位置肯定是最右边的位置
): number {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
return lastPlacedIndex;
}
const current = newFiber.alternate;
// 说明是复用的老节点
if (current !== null) {
const oldIndex = current.index;
// 可复用老节点索引小于lastPlacedIndex,而新节点肯定是当前最右边一个,说明该老节点需要移动,打上标记。
if (oldIndex < lastPlacedIndex) {
// 这个节点需要移动 打上Placement标记
newFiber.flags |= Placement;
return lastPlacedIndex;
} else { // 否则不需要移动,并把oldIndex赋值给lastPlacedIndex
// 这个节点不需要移动。
return oldIndex;
}
} else {
// 没有复用 打上Placement标记
newFiber.flags |= Placement;
return lastPlacedIndex;
}
}
例子
// 更新前
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
</ul>
// 更新后
<ul>
<li key="d">d</li>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
</ul>
第一轮遍历开始:
d(后)与a(前)比较,不能复用,跳出第一轮遍历,此时lastPlacedIndex=0;此时新节点包含dabc,老节点包含abcd。属于情况3。老节点保存到map中。
第二轮遍历开始:
- d在map中找到,可以复用,oldFiber.index = 3; 由于oldFiber.index > lastPlacedIndex。位置不变,同时把oldFiber.index赋值给lastPlacedIndex。此时lastPlacedIndex=3。
- a在map中找到,可以复用,oldFiber.index = 0; 由于oldFiber.index < lastPlacedIndex。标记a移动。lastPlacedIndex不变还是3。
- b在map中找到,可以复用,oldFiber.index = 1; 由于oldFiber.index < lastPlacedIndex。标记b移动。lastPlacedIndex不变还是3。
- c在map中找到,可以复用,oldFiber.index = 2; 由于oldFiber.index < lastPlacedIndex。标记c移动。lastPlacedIndex不变还是3。
第二轮遍历结束。最终abc三个节点打上Placement标记,可以看到abcd变成dabc,看上去只需要把d移动到最前面,实际上React是保持d不变,将abc分别移动到d后面。所以开发时尽量避免将节点从后面移动到前面。
收尾工作
两次遍历完成后,如果oldFiberMap中还有节点,代表这些节点无法复用,需要打上Deletion标记。
代码实现
// 保留关键代码
// diff数组子节点
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes,
): Fiber | null {
let resultingFirstChild: Fiber | null = null;
let previousNewFiber: Fiber | null = null;
// 参与比较的current Fiber
let oldFiber = currentFirstChild;
// 最后一个可复用oldFiber的位置索引
let lastPlacedIndex = 0;
// jsx对象索引
let newIdx = 0;
// 下一个oldFiber
let nextOldFiber = null;
// 老节点为链表,新节点为虚拟DOM数组
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
// 1.key相同返回fiber节点,
// 1.1 如果type相同返回老节点
// 1.2 type不同返回新节点
// 2.key不同返回null
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
// newFiber为null意味着当前节点是因为key不同不能复用,立即跳出循环。
if (newFiber === null) {
if (oldFiber === null) {
// 旧节点为null, 恢复为上一次的节点
oldFiber = nextOldFiber;
}
break;
}
// 需要追踪副作用
if (shouldTrackSideEffects) {
// newFiber.alternate为null说明是新建节点,说明是key相同、type不同创建了新fiber节点
if (oldFiber && newFiber.alternate === null) {
// 存在老节点且key相同,但是因为type不同不能复用,给旧节点打上删除标记(具体哪种情况后面在看)
deleteChild(returnFiber, oldFiber);
}
}
// placeChild很重要
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// resultingFirstChild 保存第一个子节点
resultingFirstChild = newFiber;
} else {
// 后面的节点以链表形式保存
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
// 第一次循环结束,有三种情况
// 情况1:新节点遍历完了
if (newIdx === newChildren.length) {
// 把剩余的老节点打上删除标记
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
// 情况2:老节点遍历完了
if (oldFiber === null) {
// 剩余的新节点都执行新建操作
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;
}
return resultingFirstChild;
}
// 情况3:新老节点都没遍历完
// 把老节点创建一个map
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) { // 说明是复用的
// 从map里面删除,避免后面打上删除标记
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
if (shouldTrackSideEffects) {
// 两轮循环结束后,map里面的是没有匹配到的节点,打上删除标记
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
// 返回第一个子节点
return resultingFirstChild;
}
其他方法
useFiber
useFiber方法,调用createWorkInProgress clone一个新的Fiber节点。
// 保留关键代码
// 复用老Fiber
function useFiber(fiber: Fiber, pendingProps: mixed): Fiber {
const clone = createWorkInProgress(fiber, pendingProps);
clone.index = 0;
clone.sibling = null;
return clone;
}
updateSlot
// 保留关键代码
function updateSlot(
returnFiber: Fiber,
oldFiber: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
const key = oldFiber !== null ? oldFiber.key : null;
// 新节点是非空文本节点 没有key
if (
(typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number'
) {
// 旧节点有key 说明不是文本节点,不能复用
if (key !== null) {
return null;
}
// 老节点有可能是没有key的非文本节点,所以updateTextNode会继续判断是否可服复用
return updateTextNode(returnFiber, oldFiber, '' + newChild, lanes);
}
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
if (newChild.key === key) {
// key相同 判断是否可复用,updateElement会继续判断type是否一致。
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
// key不同, 不能复用
return null;
}
}
}
if (isArray(newChild) || getIteratorFn(newChild)) {
if (key !== null) { // 旧节点有key 新节点是数组或者Iterator 不能复用
return null;
}
return updateFragment(returnFiber, oldFiber, newChild, lanes, null); // 忽略Fragment相关
}
}
return null;
}
updateTextNode
判断文本节点是否可复用。
// 保留关键代码
// 处理文本节点
function updateTextNode(
returnFiber: Fiber,
current: Fiber | null,
textContent: string,
lanes: Lanes,
) {
// 旧节点不存在或者旧节点不是文本节点 新建一个fiber
if (current === null || current.tag !== HostText) {
const created = createFiberFromText(textContent, returnFiber.mode, lanes);
created.return = returnFiber;
return created;
} else {
// 复用旧节点
const existing = useFiber(current, textContent);
existing.return = returnFiber;
return existing;
}
}
updateElement
// 保留关键代码
function updateElement(
returnFiber: Fiber,
current: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const elementType = element.type;
// 存在有旧节点 继续判断节点类型是否一致
if (current !== null) {
if (current.elementType === elementType) {
const existing = useFiber(current, element.props);
existing.ref = coerceRef(returnFiber, current, element);
existing.return = returnFiber;
return existing;
}
}
// 不存在旧节点,或者节点类型不一样 新建一个fiber
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, current, element);
created.return = returnFiber;
return created;
}
updateFromMap
// 保留关键代码
function updateFromMap(
existingChildren: Map<string | number, Fiber>,
returnFiber: Fiber,
newIdx: number,
newChild: any,
lanes: Lanes,
): Fiber | null {
if (
(typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number'
) {
// 文本节点没有key,直接去updateTextNode里面判断,如果都是文本即可复用,否则新建
const matchedFiber = existingChildren.get(newIdx) || null;
return updateTextNode(returnFiber, matchedFiber, '' + newChild, lanes);
}
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
const matchedFiber =
existingChildren.get(
newChild.key === null ? newIdx : newChild.key,
) || null;
return updateElement(returnFiber, matchedFiber, newChild, lanes);
}
}
if (isArray(newChild) || getIteratorFn(newChild)) {
const matchedFiber = existingChildren.get(newIdx) || null;
return updateFragment(returnFiber, matchedFiber, newChild, lanes, null);
}
}
return null;
}
createChild
根据类型创建对应的Fiber节点。
// 保留核心代码
function createChild(
returnFiber: Fiber,
newChild: any,
lanes: Lanes,
): Fiber | null {
if (
(typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number'
) {
// 创建文本节点
const created = createFiberFromText(
'' + newChild,
returnFiber.mode,
lanes,
);
created.return = returnFiber;
return created;
}
// 单节点
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
const created = createFiberFromElement(
newChild,
returnFiber.mode,
lanes,
);
created.ref = coerceRef(returnFiber, null, newChild);
created.return = returnFiber;
return created;
}
}
// 数组或者是Iterator
if (isArray(newChild) || getIteratorFn(newChild)) {
const created = createFiberFromFragment(
newChild,
returnFiber.mode,
lanes,
null,
);
created.return = returnFiber;
return created;
}
throwOnInvalidObjectType(returnFiber, newChild);
}
return null;
}
deleteRemainingChildren
删除剩余的子Fiber
// 保留关键代码
function deleteRemainingChildren(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
): null {
// 不需要打标记
if (!shouldTrackSideEffects) {
return null;
}
let childToDelete = currentFirstChild;
while (childToDelete !== null) {
deleteChild(returnFiber, childToDelete);
childToDelete = childToDelete.sibling;
}
return null;
}
deleteChild
// 保留核心代码
function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
if (!shouldTrackSideEffects) { // 不需要打标记
return;
}
const deletions = returnFiber.deletions; // 父节点上要删除的fiber
if (deletions === null) {
returnFiber.deletions = [childToDelete]; // 父节点挂上要删除的子节点
returnFiber.flags |= ChildDeletion; // 打上删除子节点标记
} else {
deletions.push(childToDelete);
}
}
总结
为了降低Diff算法的复杂度,React做了如下限制。
- 只对同级元素进行Diff。如果一个DOM元素在前后两次更新中跨越了层级,React不会尝试复用它。
- 两个不同类型的元素会产生不同的树,元素类型变更,React会销毁变更前元素及其子孙元素,并新建更新后元素及其子孙元素。
- 开发中可以通过key属性来帮助Diff。
- 鉴于在日常开发中,“节点移动”较少,在Diff实现的时候才用了两轮遍历,优先考虑按照顺序复用的大多数情况。
参考
React设计原理 - 卡颂。
转载自:https://juejin.cn/post/7397334706822348800