Diff 算法是什么
diff 是通过 JS 层面的计算,来对比两个虚拟 DOM 中变化的部分,并只针对该部分进行原生 DOM 操作,而非重新渲染整个页面,从而保证了每次操作更新后页面的高效渲染。
在 React 中 diff 算法通过对比两个虚拟 DOM ,返回一个 patch 对象,即补丁对象,在通过特定的操作解析 patch 对象,完成页面的重新渲染。
在上一节的 Fiber 架构提到,在 render 阶段更新 Fiber 节点时,我们会调用 reconcileChildFibers 对比 current Fiber 和 jsx 对象构建 workInProgress Fiber,这里 current Fiber 是指当前 dom 对应的 fiber 树,jsx 是 class 组件 render 方法或者函数组件的返回值。
在 reconcileChildFibers 中会根据 newChild 的类型来进入单节点的 diff 或者多节点 diff
这里需要明确的是 diff 算法的本质是通过 current Fiber 树与 class 组件 render 方法或者函数组件的返回的 jsx 对象进行比对生成 workInProgress Fiber 树,最终渲染到页面;
Diff 算法策略
在传统的 diff 算法中复杂度会达到 O(n^3),比如说我们页面有 1000 个元素,那么则需要对比 10 亿次,效率十分低下,不能满足前端渲染所需要的效率。为了解决这个问题,React 中定义了三种策略,在对比时,根据策略只需遍历一次树就可以完成对比,将复杂度降到了 O(n):
- 策略一:只对同级元素进行 Diff。如果一个 DOM 节点在前后两次更新中跨越了层级,那么 React 不会尝试复用他;
- 策略二:两个不同类型的元素会产生出不同的树。如果元素由 div 变为 p,React 会销毁 div 及其子孙节点,并新建 p 及其子孙节点。
- 策略三:对于同一层级的一组节点,会使用具有唯一性的 key 来区分是否需要创建,删除,或者是移动。
Diff 是如何实现的
在 render 阶段更新 Fiber 节点时,我们会调用 reconcileChildFibers 对比 current Fiber 和 jsx 对象构建workInProgress Fiber;
diff 的入口函数是 reconcileChildren:
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) {
if (current === null) {
// current === null,说明是创建,不是更新,调用 mountChildFibers函数根据子元素创建 Fiber
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
// 对 current 树和 workInProgress 树进行 diff 算法对比,找出差异部分
workInProgress.child = reconcileChildFibers(
workInProgress, // workInProgress Fiber 树
current.child, // current 树上的当前 Fiber 节点的子节点
nextChildren, // jsx 生成的 React element 元素
renderLanes, // 渲染的 lane 优先级集合
);
}
}
可以看到这个函数首先判断 workInProgress 树上的 Fiber 节点对应的 current 树上的 Fiber 节点是否存在:
- 如果等于 null,说明是 mount 阶段(首屏渲染),然后调用 mountChildFibers 函数根据调用 render 函数生成的 React elements 构建 workInprogress 树。
- 如果不等于 null,说明此时是页面存在 update,然后会调用 reconcileChildFibers 函数,对 current 树和 workInProgress 树进行 diff 算法对比,找出差异部分进行更新。
diff 算法对比的过程在 reconcileChildFibers 函数,我们来看一下源码:
在 reconcileChildFibers 中会根据 newChild (即 JSX 对象)的类型来进入单节点的 diff 或者多节点 diff;
// 根据 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 处理
// // ...省略其他case
}
}
if (typeof newChild === 'string' || typeof newChild === 'number') {
// 调用 reconcileSingleTextNode 处理
// ...省略
}
if (isArray(newChild)) {
// 调用 reconcileChildrenArray 处理
// ...省略
}
// 一些其他情况调用处理函数
// ...省略
// 以上都没有命中,删除节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
完整的代码点击这里;
可以看到会根据 render 函数新生成的 React element 的类型分为两类:
- 当newChild类型为 object、number、string,代表同级只有一个节点;
- 当newChild类型为 Array,同级有多个节点;
单节点 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是否相同
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 不同直接标记删除节点,然后新建节点;
- key 相同 type 不同,标记删除该节点和兄弟节点,然后新创建节点;
整个流程如下:
这里说明一下后面两个情况:
diff 算法是通过比较 key,然后比较 type 是否复用,如果,key 相同,证明找到了之前的元素,但是 type 不用,说明他是不能复用了,就不用去遍历他的兄弟节点,因为 key 已经匹配到了该 Fiber,其他兄弟节点的 key 肯定不同;而 key 不同,只删除该 Fiber 是因为,有可能他的兄弟节点的 key 跟该元素的 key 相同,所以,只标记该 fiber 节点删除;
小例子
比如下面例子:
当前页面有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
reconcileChildFibers 的 newChild 参数类型为 Array,在 reconcileChildFibers 函数内部会进入下面的逻辑:
if (isArray(newChild)) {
// 调用 reconcileChildrenArray 处理
// ...省略
}
可以看到多个节点的 diff 过程主要是在 reconcileChildrenArray 函数中实现的:
/* * returnFiber:currentFirstChild 的父级 fiber 节点
* currentFirstChild:当前执行更新任务的 WIP(fiber)节点
* newChildren:组件的 render 方法渲染出的新的 ReactElement 节点 - jsx 对象通过 createElement 创建的数组对象
* lanes:优先级相关
* */
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes,
): Fiber | null {
// resultingFirstChild 是 diff 之后的新 fiber 链表的第一个 fiber。
let resultingFirstChild: Fiber | null = null;
// 用来链接下一个 Fiber 节点变量
let previousNewFiber: Fiber | null = null;
// diff 算法遍历的当前存在的 fiber 节点
let oldFiber = currentFirstChild;
// 新创建的节点在 dom 中的索引位置,用来处理节点位置变化的
let lastPlacedIndex = 0;
// 遍历 jsx 索引
let newIdx = 0;
// oldFiber 的下一个 fiber sibling
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 则说明当前的节点不是更新的场景,中止这一轮循环
if (newFiber === null) {
// oldFiber 为 null 说明 oldFiber 此时也遍历完了
// 是以下场景,D 为新增节点
// 旧 A - B - C
// 新 A - B - C - D oldFiber = nextOldFiber;
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
// shouldTrackSideEffects 为 true 表示是更新过程
if (shouldTrackSideEffects) {
// 表示没有复用 oldFiber 节点
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) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
// 处理新增节点。旧的遍历完了,能复用的都复用了,所以意味着新的都是新插入的了
if (oldFiber === null) {
// 旧的遍历完了,意味着剩下的都是新增的了
for (; newIdx < newChildren.length; newIdx++) {
// 首先创建 newFiber
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
// 记录固定节点的位置 lastPlacedIndex
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 再将 newFiber 连接成以 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++) {
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;
}
同级多个节点的 Diff,是以下三种情况中的一种或多种:
- 节点更新
- 节点新增或删除
- 节点位置变化
React 团队发现,在日常开发中,相较于新增和删除,更新组件发生的频率更高。所以 Diff 会优先判断当前节点是否属于更新。
基于上面原因在源码中多节点 diff 有三个 for 循环遍历(并不意味着所有更新都有经历三个遍历,进入循环体有条件,也有条件跳出循环):
- 第一轮遍历处理节点的更新(包括 props 更新和 type 更新和删除);
- 第二轮遍历处理其他的情况(节点新增);
- 第三轮遍历处理位节点置改变;
第一轮遍历
该轮遍历来处理节点更新,依据节点是否可复用来决定是否中断遍历:
// resultingFirstChild 是 diff 之后的新 fiber 链表的第一个 fiber。
let resultingFirstChild: Fiber | null = null;
// 用来链接下一个 Fiber 节点变量
let previousNewFiber: Fiber | null = null;
// diff 算法遍历的当前存在的 fiber 节点
let oldFiber = currentFirstChild;
// 新创建的节点在 dom 中的索引位置,用来处理节点位置变化的
let lastPlacedIndex = 0;
// 遍历 jsx 索引
let newIdx = 0;
// oldFiber 的下一个 fiber sibling
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 则说明当前的节点不是更新的场景,中止这一轮循环
if (newFiber === null) {
// oldFiber 为 null 说明 oldFiber 此时也遍历完了
// 是以下场景,D 为新增节点
// 旧 A - B - C
// 新 A - B - C - D oldFiber = nextOldFiber;
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
// shouldTrackSideEffects 为 true 表示是更新过程
if (shouldTrackSideEffects) {
// 表示没有复用 oldFiber 节点
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) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
由于和 newChildren 中每个组件进行比较的是 current fiber,同级的 Fiber 节点是由 sibling 指针链接形成的单链表,即不支持双指针遍历。即 newChildren[0] 与 fiber 比较,newChildren[1]与fiber.sibling比较;
第一轮遍历步骤如下:
- let i = 0,遍历 newChildren,将 newChildren[i] 与 oldFiber 比较,判断 DOM 节点是否可复用。
- 如果可复用,i++,继续比较 newChildren[i] 与 oldFiber.sibling,可以复用则继续遍历。
- 如果不可复用,分两种情况:
- key 不同导致不可复用,立即跳出整个遍历,第一轮遍历结束。
- key 相同 type 不同导致不可复用,会将 oldFiber 标记为 DELETION,并继续遍历;
- 如果 newChildren 遍历完(即 i === newChildren.length - 1)或者 oldFiber 遍历完(即 oldFiber.sibling === null),跳出遍历,第一轮遍历结束。
当遍历结束后,会有两种结果:
- 步骤 3 跳出的遍历:此时 newChildren 没有遍历完,oldFiber 也没有遍历完。
- 步骤 4 跳出的办理:可能 newChildren 遍历完,或 oldFibe r遍历完,或他们同时遍历完。
第二轮遍历
第二轮遍历考虑四种情况:
- newChildren 和 oldFiber 都遍历完:多节点 diff 过程结束;只需在第一轮遍历进行组件更新;
// 生成新的节点,判断 key 与 tag 是否相同就在 updateSlot 中
// 对 DOM 类型的元素来说,key 和 tag 都相同才会复用 oldFiber
// 并返回出去,否则返回 null
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
- newChildren 没遍历完,oldFiber 遍历完,已有的 DOM 节点都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的 newChildren 为生成的 workInProgress fiber 依次标记 Placement;
// 处理新增节点。旧的遍历完了,能复用的都复用了,所以意味着新的都是新插入的了
if (oldFiber === null) {
// 旧的遍历完了,意味着剩下的都是新增的了
for (; newIdx < newChildren.length; newIdx++) {
// 首先创建 newFiber
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
// 记录固定节点的位置 lastPlacedIndex
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 再将 newFiber 连接成以 sibling 为指针的单向链表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
return resultingFirstChild;
}
- newChildren 遍历完,oldFiber 没遍历完,意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的 oldFiber,依次标记 Deletion。
// 处理节点删除。新子节点遍历完,说明剩下的 oldFiber 都是没用的了,可以删除.
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
- newChildren 和 oldFiber 都没遍历完,则进入节点移动的逻辑;
// 节点移动
for (; newIdx < newChildren.length; newIdx++) {
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;
}
}
第三轮遍历
主要逻辑在 placeChild 函数中,由于有节点改变了位置,所以不能再用位置索引 i 对比前后的节点,需要通过使用key 进行比对查找位置。
为了快速的找到 key 对应的 oldFiber,我们将所有还未处理的 oldFiber 存入以 key 为 key,oldFiber 为 value 的Map 中。
// 这样在基于 oldFiber 节点新建新的 fiber 节点时,可以通过 key 快速地找出 oldFiber
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
接下来遍历剩余的 newChildren,通过 newChildren[i].key 就能在 existingChildren 中找到 key 相同的 oldFiber。
现在关键是我们怎么找出需要移动的节点,以及如何移动,是往前移动还是往后移动。这就需要一个参照物。比如:
案例 1
上面的节点只有 B 节点位置发生了变化;我们可以找一个参照物,最后一个可复用的节点在更新前(oldFiber) 中的位置索引(代码中变量 lastPlacedIndex 表示),只有更新前的位置( oldIndex) >= lastPlacedIndex,则lastPlacedIndex = oldIndex。因为 lastPlacedIndex 为参考的一个标准,始终指向可复用的最后一个也就是更新前位置最靠后的一个节点的索引,只有这样,才能准确判断其他节点是移动还是不需要移动;
- 更新前与更新后 A 是可以复用的节点,所以我们取 lastPlacedIndex 为 0;
- 遍历到 C 节点。发现更新后 C 与更新前 B 的节点不一样(key 不一致)所以跳出循环;并将更新前剩余的还没有利用到的节点 BCD 保存在 existingChildren 中(map 对象中);
- 接着进入第二次遍历,遍历 newChildren;找到更新后第二个位置的 C 在更新前的位置 index = 2,index > lastPlacedIndex 说明之前 C 就是在 A 的后面,所以不需要移动;将 lastPlacedIndex 设置为 2;以及 existingChildren 中保存的 C 删除;
- 继续遍历剩余 newChildren,更新后的 B 节点在更新前 index 为 1,发现 index < lastPlacedIndex ;说明更新前的 B 节点本来是要在 C 之前的。但是现在要复用 B,就只需要将 B 节点往后移动即可,然后调用 placeChild方法,进行位置移动;注意这里的 lastPlacedIndex 仍然为 2;以及 existingChildren 中保存的 B 删除;
- 继续遍历剩余 newChildren,更新后的 D 节点在更新前 index 为 3,发现 index > lastPlacedIndex ;D节点不会进行移动,删除 existingChildren 中保存的 D;第二次遍历结束;
最终 B 节点进行了移动,ACD 节点没有移动;
案例 2
如果存在下面情况:
更新前节点顺序是 ABCD,更新后是 DABC
- newChild 中第一个位置的 D 和 oldFiber 第一个位置的A,key 不相同不可复用,将 oldFiber 中的 ABCD 保存在 map 中,此时 lastPlacedIndex = 0;
- 接着进入第二次遍历,newChild 中第一个位置的 D 在oldFiber中的 index=3 > lastPlacedIndex=0 不需要移动,此时 lastPlacedIndex=3;
- newChild 中第二个位置的 A 在 oldFiber 中的 index=0 < lastPlacedIndex=3,移动节点到最后;
- newChild 中第三个位置的 B 在 oldFiber 中的 index=1 < lastPlacedIndex=3,移动节点到最后;
- newChild 中第四个位置的 C 在 oldFiber 中的 index=2 < lastPlacedIndex=3,移动节点到最后;
可以看到,我们以为从 ABCD 变为 DABC,只需要将 D 移动到前面。但实际上 React 保持 不变,将 ABC 分别移动到了 D 的后面。
从这点可以看出,考虑性能,我们要尽量减少将节点从后面移动到前面的操作。
下面是移动节点的源码:
function placeChild(newFiber, lastPlacedIndex, newIndex) {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
return lastPlacedIndex;
}
var current = newFiber.alternate;
if (current !== null) {
var oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
//oldIndex小于lastPlacedIndex的位置 则将节点插入到最后
newFiber.flags = Placement;
return lastPlacedIndex;
} else {
return oldIndex;//不需要移动 lastPlacedIndex = oldIndex;
}
} else {
// 新增插入
newFiber.flags = Placement;
return lastPlacedIndex;
}
}
参考
https://juejin.cn/post/6971634204186509319
https://juejin.cn/post/6919302952486174733
https://react.iamkasong.com/diff/prepare.html