通俗易懂的React原理(五):子节点的diff算法

代码以React v19.1.0为例,https://github.com/facebook/react/tree/v19.1.0 。下文中展示的代码可能省略了部分不影响主题逻辑的代码,例如dev环境下才执行的代码、水合时执行的代码

本篇我们继续来看beginWork里面的部分,依旧是以函数组件为例。

reconcileChildren

看完了执行组件自身的部分,我们接下来要看reconcileChildren部分了。

执行组件自身,返回了他的jsx,也就是它的子组件。而我们都知道,jsx语法会被babel处理成ReactElement对象。那么reconcileChildren的工作,就是把这些ReactElement转成FiberNode,并且和当前正在构建的workInProgress这棵树连接起来。换个角度说,reconcileChildren是为workInProgress树中当前遍历到的FiberNode,构建子FiberNode的。

reconcileChildren的入参包括了当前workInProgress对应的current树中的节点,当前workInProgress节点,当前workInProgress的子节点(ReactElement形式),渲染优先级。它生成当前workInProgress的子节点(以FiberNode形式),并将子节点和workInProgress连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) {
if (current === null) {
// If this is a fresh new component that hasn't been rendered yet, we
// won't update its child set by applying minimal side-effects. Instead,
// we will add them all to the child before it gets rendered. That means
// we can optimize this reconciliation pass by not tracking side-effects.
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
// If the current child is the same as the work in progress, it means that
// we haven't yet started any work on these children. Therefore, we use
// the clone algorithm to create a copy of all the current children.

// If we had any progressed work already, that is invalid at this point so
// let's throw it out.
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}

这里根据当前组件是首次mount,还是后续update,分别执行了两个方法。而这两个方法其实都来自createChildReconciler方法,区别只是mount不需要追踪副作用,把新增的节点插进去就行了。而update的时候是需要的,需要和原来的节点对比,做什么操作。

1
2
export const mountChildFibers: ChildReconciler = createChildReconciler(false);
export const reconcileChildFibers: ChildReconciler = createChildReconciler(true);

createChildReconciler

我们接下来看一下createChildReconciler方法的实现。

1
2
3
4
5
6
7
8
9
10
11
function createChildReconciler(shouldTrackSideEffects: boolean){
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null{
// xxx
}
return reconcileChildFibers
}

它在内部定义了一个reconcileChildFibers方法,最终return了这个方法。并且根据shouldTrackSideEffects参数的不同,reconcileChildFibers中间的很多逻辑也都有所不同,具体等后面深入的时候会讲到。

我们观察reconcileChildFibers的声明,以及前面调用它的时候,可以看到它的入参是当前workInProgress节点,对应的current节点的第一个子节点,自身的子节点(ReactElement形式),以及渲染优先级。它最终会返回workInProgress的子节点(FiberNode形式),也可能不返回,就说明没有子节点了。

工具方法

除了reconcileChildFiberscreateChildReconciler内部还定义了许多工具方法,用于协助创建FiberNode。为了方便后续深入学习,我们先简要了解一下这些工具方法。

useFiber

1
2
3
4
5
6
7
8
function useFiber(fiber: Fiber, pendingProps: mixed): Fiber {
// We currently set sibling to null and index to 0 here because it is easy
// to forget to do before returning it. E.g. for the single child case.
const clone = createWorkInProgress(fiber, pendingProps);
clone.index = 0;
clone.sibling = null;
return clone;
}

useFiber方法主要用来从current树克隆FiberNode节点,通常在diff算法中判断可复用老节点时使用。所以当你看到useFiber方法的时候,你就知道是复用节点就可以了。

placeChild

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number,
newIndex: number,
): number {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
// During hydration, the useId algorithm needs to know which fibers are
// part of a list of children (arrays, iterators).
newFiber.flags |= Forked;
return lastPlacedIndex;
}
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// This is a move.
newFiber.flags |= Placement | PlacementDEV;
return lastPlacedIndex;
} else {
// This item can stay in place.
return oldIndex;
}
} else {
// This is an insertion.
newFiber.flags |= Placement | PlacementDEV;
return lastPlacedIndex;
}
}

placeChild 用于多子节点场景下判断当前子节点是否需要移动/插入。通过lastPlacedIndex去判断新的节点是否需要被打上移动的标记。这个方法只在一个场景被调用,所以它的具体逻辑,我将在调用它的地方讲解。

placeSingleChild

1
2
3
4
5
6
7
8
function placeSingleChild(newFiber: Fiber): Fiber {
// This is simpler for the single child case. We only need to do a
// placement for inserting new children.
if (shouldTrackSideEffects && newFiber.alternate === null) {
newFiber.flags |= Placement | PlacementDEV;
}
return newFiber;
}

placeSingleChild方法,就是新插入一个子节点,即给它打上插入(placement)的标记flag。

当然,排除了父组件初次mount(shouldTrackSideEffects为false),或者其子节点初次mount(newFiber.alternate会是null)的场景

deleteChild

1
2
3
4
5
6
7
8
9
10
11
12
13
function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
if (!shouldTrackSideEffects) {
// Noop.
return;
}
const deletions = returnFiber.deletions;
if (deletions === null) {
returnFiber.deletions = [childToDelete];
returnFiber.flags |= ChildDeletion;
} else {
deletions.push(childToDelete);
}
}

deleteChild方法用于删除单个子节点。删除子节点的方式,是在父节点上打上删除子节点的标记flag,并且把子节点推入deletions数组。

deleteRemainingChildren

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function deleteRemainingChildren(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
): null {
if (!shouldTrackSideEffects) {
// Noop.
return null;
}

// TODO: For the shouldClone case, this could be micro-optimized a bit by
// assuming that after the first child we've already added everything.
let childToDelete = currentFirstChild;
while (childToDelete !== null) {
deleteChild(returnFiber, childToDelete);
childToDelete = childToDelete.sibling;
}
return null;
}

deleteRemainingChildren方法用于删除从当前子节点开始的全部子节点,实现相似于删除单个子节点。

diff相关的方法

reconcileChildFibers的主要实现逻辑都在reconcileChildFibersImpl里,在它之外应该是做了对suspense的处理。而reconcileChildFibersImpl里面,则又根据子节点的类型,分了很多处理方法。

常见的子节点类型,有ReactElement(REACT_ELEMENT_TYPE),子元素数组(通过isArray判断),和普通的文本节点(字符串、数字。基本上叶子结点都是文本节点了)。其余的类型不详细讲了,我们着重讲一下上面三种。

他们首先可分为两类,即单子节点(ReactElement,文本)和多子节点(多个子节点组成的数组)。这里需要注意的是,我们是拿wip的子节点(新)去和current的子节点(旧)去比较,难免会出现新旧不一致的情况。我们这里说的单节点还是多节点,都是针对新子节点而言,与旧子节点有多少个无关,

单子节点:reconcileSingleElement

1
2
3
4
5
6
7
8
9
10
11
12
13
case REACT_ELEMENT_TYPE: {
const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
const firstChild = placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
currentDebugInfo = prevDebugInfo;
return firstChild;
}

其中,placeSingleElement上面已经介绍过了,就是插入单个节点的方法(如果父节点或者子节点是首次mount,则不需要记录插入副作用)

下面着重看一下reconcileSingleElement方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
// TODO: If key === null and child.key === null, then this only applies to
// the first item in the list.
if (child.key === key) {
const elementType = element.type;

if (child.elementType === elementType) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
coerceRef(existing, element);
existing.return = returnFiber;
return existing;
}
// Didn't match.
deleteRemainingChildren(returnFiber, child);
break;
} else {
deleteChild(returnFiber, child);
}
child = child.sibling;
}

const created = createFiberFromElement(element, returnFiber.mode, lanes);
coerceRef(created, element);
created.return = returnFiber;

return created;
}

以上是精简后的逻辑,去掉了对于React Fragment、React Lazy、以及开发环境hot reload的处理。我们可以看到,逻辑相当清晰明了。

以最复杂的,旧子节点有多个的情况去考虑(由于是链表结构,单个是多个的一种简化版本)。从旧子节点的第一个开始,一个一个遍历,去找key和新子节点相同的。如果不同,则删掉当前遍历的子节点,继续遍历。其中,如果都没有指定key,那么key都为null,也会认为key是相同的。

如果key相同,并且元素类型也相同(例如同为div),则认为匹配成功,从旧的节点clone得到新节点,并且删除掉后续的旧子节点。将clone得到的新fiber连回父节点,就结束了

如果key相同,但是元素类型不同,则认为匹配失败。用于key相同,暗示了对应关系,对应的节点都匹配失败了,即已经没有能复用的节点了。先删掉后续的所有旧节点,然后基于新节点的ReactElement形式,新生成一个Fiber Node,连回父节点。

单子节点:reconcileSingleTextNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (
(typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number' ||
typeof newChild === 'bigint'
) {
return placeSingleChild(
reconcileSingleTextNode(
returnFiber,
currentFirstChild,
'' + newChild,
lanes,
),
);
}

对于文本类型的子节点,React会通过reconcileSingleTextNode方法,得到新的子节点。然后通过前面讲过的placeSingleChild方法,判断是否需要为这个节点打上插入的标记。

所以重点逻辑还是在reconcileSingleTextNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function reconcileSingleTextNode(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
textContent: string,
lanes: Lanes,
): Fiber {
// There's no need to check for keys on text nodes since we don't have a
// way to define them.
if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
// We already have an existing node so let's just update it and delete
// the rest.
deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
const existing = useFiber(currentFirstChild, textContent);
existing.return = returnFiber;
return existing;
}
// The existing first child is not a text node so we need to create one
// and delete the existing ones.
deleteRemainingChildren(returnFiber, currentFirstChild);
const created = createFiberFromText(textContent, returnFiber.mode, lanes);
created.return = returnFiber;

return created;
}

对于文本节点而言,它们没有key这个概念。所以其实只需要判断,如果旧的子节点寸止,并且类型也是HostText,那么就可以复用。删除掉剩余的旧的子节点,从第一个旧子节点克隆一个fiber出来就好了。

如果没能满足复用的条件,那么就删除全部的旧的子节点,然后重新创造一个文本节点的Fiber。

不论怎样,得到新子节点的fiber后,将它连回父节点。

多子节点:reconcileChildrenArray

下面我们来看可能是最复杂的一个场景,就是新子节点是个数组的情况。

其实还有一种场景,就是新子节点是个可迭代的对象的场景。和数组的场景类似,有细节上的差别,我们只看数组的场景来理解核心流程。

1
2
3
4
5
6
7
8
9
10
11
if (isArray(newChild)) {
const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
const firstChild = reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
currentDebugInfo = prevDebugInfo;
return firstChild;
}

如果新子节点是数组,那么执行reconcileChildrenArray方法。

这里依旧只是以新子节点为判断依据,它是数组就会进到这个逻辑分支。而旧的子节点,最复杂的场景当然也是数组。如果不是数组的话,可以当做是一种简化的场景。

reconcileChildrenArray方法很长,并且逻辑较为复杂。我们分为三个部分来看。

第一部分:key能匹配上

第一部分,在新子节点数组中,从头开始,找出key能匹配上的最长子串

这部分的代码实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<any>,
lanes: Lanes,
): Fiber | null {
let knownKeys: Set<string> | null = null;

let resultingFirstChild: Fiber | null = null;
let previousNewFiber: Fiber | null = null;

let oldFiber = currentFirstChild;
let lastPlacedIndex = 0;
let newIdx = 0;
let nextOldFiber = null;
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}

if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
// We matched the slot, but we didn't reuse the existing fiber, so we
// need to delete the existing child.
deleteChild(returnFiber, oldFiber);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// TODO: Move out of the loop. This only happens for the first run.
resultingFirstChild = newFiber;
} else {
// TODO: Defer siblings if we're not at the right index for this slot.
// I.e. if we had null values before, then we want to defer this
// for each null value. However, we also don't want to call updateSlot
// with the previous one.
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}

// 后续部分
}

我们直接从循环开始看,里面第一个判断,if (oldFiber.index > newIdx),正常情况下,不会进入到这个分支,因为新旧节点通常情况下是一起向后推进的,他们的index会保持同样的节奏递增。只有少数场景会进入第一个分支,例如旧子节点中有null,导致相邻的兄弟节点,index相差2而不是1。

那么下面我们肯定能看出来了,updateSlot是一个比较重要的方法,他得到了新的子节点数组中,一个子节点的Fiber Node。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function updateSlot(
returnFiber: Fiber,
oldFiber: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
// 前略
case REACT_ELEMENT_TYPE: {
if (newChild.key === key) {
const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
const updated = updateElement(
returnFiber,
oldFiber,
newChild,
lanes,
);
currentDebugInfo = prevDebugInfo;
return updated;
} else {
return null;
}
}
// 后略
}

这里指定了唯一的新子节点,和唯一的旧子节点去比较。如果他们的key没匹配上,那么直接return null给新子节点。如果key匹配上了,就进入updateElement的逻辑。

updateElement方法里面,还是区分了子节点的各种类型,这里我们还是只看函数组件这种场景。因为逻辑很简单就不放源码了,感兴趣可以自己去看,就是类型相同,那么就复用节点。类型不同,那么就重新生成节点(但是不会立刻就删除旧节点)

接下来继续看第一部分的代码,如果新子节点是null,比如本来新子节点就是null,或者刚才updateSlot里Key没匹配上,那么就会跳出第一部分了。

如果没有跳出,继续往下走,那么当父节点是update,而不是mount(即shouldTrackSideEffects为true)的时候,如果新子节点不是从旧子节点复用得到的,那么就会标记删除旧子节点。

接下来,lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);。当我们得到新子节点数组中的一个节点的时候,我们就会调用placeChild方法。对于新节点,我们会通过alternate指针,拿到对应的旧节点,从而拿到oldIndex,得知它在旧子节点数组中的顺序。

由于在第一阶段,我们是按顺序去推进新旧两个列表的,所以不会出现oldIndex<lastPlacedIndex的场景,而是每次进入,都命中第二个分支,去更新lastPlacedIndex。换一个角度来理解,如果新旧子节点,key都能一一对应上,那么也不需要移动了,因为原本顺序就是对的。如下图所示,所有新节点都不需要被打上插入标记。

以上就是第一部分的内容,在key都能匹配得上,且newFiber不是null的时候,一直往下遍历,就是第一部分的逻辑。

而一旦key有不匹配了,或者newFiber是null,那么第一部分的逻辑就结束了。

这是顺序匹配阶段。 当新旧子节点依次遍历、且 key 能一一对应时,就能快速复用旧 Fiber 来生成新 Fiber。如果整个循环都顺利完成,就进入第二部分;如果中途因为 key 不匹配而中断,则转入第三部分。

第二部分:新旧数组长度不一致时的处理

接下来是第二部分的逻辑,是处理正常走完第一部分,而不是中途跳出循环的场景的。

因为新旧子节点可能长度不一致,所以一方遍历完了,另一方可能还有没被遍历到的节点。第二部分,就是去处理另一方被剩下的,没遍历到的子节点的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
if (newIdx === newChildren.length) {
// We've reached the end of the new children. We can delete the rest.
deleteRemainingChildren(returnFiber, oldFiber);
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
}

if (oldFiber === null) {
// If we don't have any more existing children we can choose a fast path
// since the rest will all be insertions.
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// TODO: Move out of the loop. This only happens for the first run.
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
return resultingFirstChild;
}

如果新子节点已经遍历完了,那么就把旧子节点余下的部分删掉。

如果旧子节点已经遍历完了,那么对于剩余的新子节点,每个节点都要重新生成Fiber Node,并连回父节点上。然后通过placeChild方法,给新的子节点打上插入的标记(因为旧子节点不存在,父组件又是update,所以新子节点算是插入)。

然后再把新的子节点们通过sibling指针连起来,最后返回第一个子节点,就结束了

这是收尾处理阶段。 针对新旧子节点数量不一致的情况:

  • 如果新子节点先遍历完 → 删除剩余的旧子节点;
  • 如果旧子节点先遍历完 → 剩余的新子节点全部新建 Fiber 并打上插入标记。

第三部分:中途跳出循环后,第二次循环

第二部分是完整走完第一部分后的处理。如果中途跳出了第一部分,那么第二部分的两个if分支都不会被进入。那么就会进入到第三部分,

第三部分会从第一部分终端的newIndex开始,进行第二次循环,俗称“慢匹配”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Add all children to a key map for quick lookups.
const existingChildren = mapRemainingChildren(oldFiber);

// Keep scanning and use the map to restore deleted items as moves.
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
// The new fiber is a work in progress, but if there exists a
// current, that means that we reused the fiber. We need to delete
// it from the child list so that we don't add it to the deletion
// list.
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) {
// Any existing children that weren't consumed above were deleted. We need
// to add them to the deletion list.
existingChildren.forEach(child => deleteChild(returnFiber, child));
}

return resultingFirstChild;

首先,将所有还没遍历到的旧节点,添加到map里面。如果旧节点有key,那么就用key来当做map的key。如果旧节点没有key,那么就用index当做map的key。然后把fiber本身当做value,就这样把(key,value)对存进map里。

然后去遍历跳出循环后的新子节点,对于每个新子节点,从map里去找和它对应的旧子节点(依旧是有key用key,无key用index)。构建哈希表,并且通过哈希表查找,这里就是“慢匹配”额外的开销。

在找到旧节点后,其实是和第一部分顺序匹配的后续逻辑一样,都是根据旧节点去得到新节点。看新旧节点的类型是否一致,决定是复用,还是重新创建。

在生成newFiber后,需要通过placeChild方法,把新生成的节点视情况是否打上插入标记。这里的逻辑就比较复杂了,因为新和旧的顺序完全无法保证,是通过map里key来对应的,而不是index。

这里就涉及到placeChild真正复杂的场景了。因为此前,新旧节点都是一一对应的。而到了第三部分,旧节点是从map里用key取出来的,就没办法保证顺序了。所以,这时候就需要给部分新的子节点打上插入标记。

那么如何判断是否需要打插入标记,就是问题的关键了。如果说要当成两个数组的最小编辑距离来看,时间复杂度就有点不能接受了。而React则是采用了一种线性时间复杂度的贪心算法,其逻辑如下:在新的子节点数组中,一步步构建出出符合旧节点数组顺序的子序列,子序列中的这些节点就是不需要移动的,而不在子序列中的节点,则需要打上插入的标记(实际是需要移动这些节点,但是React 并不区分「这是全新的节点」还是「这是原来就有的节点、只是换了个位置」,只是把这个dom放到正确的位置上)。

我们可以看结合placeChild的源码和下面这张图,来表示一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number,
newIndex: number,
): number {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
// During hydration, the useId algorithm needs to know which fibers are
// part of a list of children (arrays, iterators).
newFiber.flags |= Forked;
return lastPlacedIndex;
}
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// This is a move.
newFiber.flags |= Placement | PlacementDEV;
return lastPlacedIndex;
} else {
// This item can stay in place.
return oldIndex;
}
} else {
// This is an insertion.
newFiber.flags |= Placement | PlacementDEV;
return lastPlacedIndex;
}
}

上图中,红色的节点表示被添加到子序列中的节点。而蓝色的则是没能被添加到子序列中,而被打上插入标记的节点。

我们可以看到,react在遍历新子节点的时候,每遍历到一个子节点,都会看一下他能否加入到子序列的尾部,还保持原来的顺序。例如,[a,d]就是符合[a,b,c,d,e]顺序的一个子序列,所以d被加入进去了。对应的就是lastPlacedIndex被更新为d的oldIndex。而下一个新的子节点,c,没能被加入到子序列中,因为[a,d,c]不是符合[a,b,c,d,e]的顺序的子序列,所以新子节点c被打上了插入标记,b也同理。最后,[a,d,e]是符合[a,b,c,d,e]的,所以最后,a,d,e三个子节点是不需要移动的,而c和b则被打上了插入的标记,会被移动位置。这就是我认为diff算法中,最复杂的一个逻辑了。

最后,如果父组件是update而不是mount,就把map里的旧子组件全部删掉,因为新子组件里没有与它们对应的,说明他们在新一次渲染中不存在了。

这是慢匹配阶段。 一旦第一部分顺序匹配中断,就会把所有剩余的旧子节点放入 Map,再逐个用新子节点去查找对应的旧节点。能复用则复用,不能复用则新建,并通过 placeChild 判断是否需要移动。

key的作用

前面我们说过了,useFiber就是从旧节点clone Fiber出来,相当于是复用。而createFiberFromElement则是重新创建Fiber Node。那么这两个的区别,究竟有多大呢?

我们可以看下useFiber的实现,它里面保留了旧Fiber上面的很多字段

1
2
3
4
5
6
7
8
workInProgress.flags = current.flags & StaticMask;
workInProgress.childLanes = current.childLanes;
workInProgress.lanes = current.lanes;

workInProgress.child = current.child;
workInProgress.memoizedProps = current.memoizedProps;
workInProgress.memoizedState = current.memoizedState;
workInProgress.updateQueue = current.updateQueue;

而相应的,createFiberFromElement自然这些字段就都没有了。

那么这给我们带来了什么思考呢?我们知道,决定是执行useFiber还是createFiberFromElement,基本就是看key是否相同,和节点类型是否一致。那么我们经常看到React在开发环境下的一个warning,提示我们列表渲染需要给子元素加上key。那么是否加key,可能对列表造成什么影响呢?

我们以这个demo里的代码来做个演示,我们本地启动这个demo,然后选择Key Property Demo

我们有一个固定的list

1
2
3
4
5
6
[
{ name: "a" },
{ name: "b" },
{ name: "c" },
{ name: "d" },
]

然后分别有四个场景,每个场景都是依据这个list,去map渲染出各自的child。然而,在每个child内部,又有着child自己维护的state。这个count是每个child各自独有的,应该是互相区分的。

1
2
3
4
5
6
7
8
9
10
const Child = ({ name }: { name: string }) => {
const [count, setCount] = useState(0);
return (
<div
onClick={() => {
setCount((count) => count + 1);
}}
>{`${name}:${count}`}</div>
);
};

然后四个场景,分别是

  1. 无key
  2. 以数组index作为key
  3. 用完全随机数作为key
  4. 用独特的值,这里是用name作为key

我们首先,点击各个按钮,得到下面这个状态

然后我们点击“反转列表”的按钮,变成了下面的状态

可以看到,只有第四个场景下的值是正确的,count依旧能和name对应起来。

前两个场景中,由于key使用的不正确,导致Fiber被错误的复用。Fiber复用的时候,会保留内部的hooks的状态,而它的子文本节点却在后来被单独更新掉。从而导致state维护的count,和name不能保持对应关系。

而第三个场景也是很搞笑,由于它的key是个随机数,每一次他重新渲染,所有的子节点都没办法复用,从而全部重新创建,进而丢失了hooks上的全部值。所以每一次重新渲染,第三个场景子元素的count全部都会归零。

如果你点击的是“浅拷贝重新赋值”的按钮,列表的实际内容没有变化,第三个场景也依旧会出现问题,因为子节点依旧无法匹配成功。而由于这次节点顺序没有变化,第一、二个场景是表现正确的。

所以我们可以看到,如果你列表的子组件,内部是有状态的,并且顺序是可能发生改变的(包括反转、中间插入等等),那么有一个唯一的key是非常有必要的,不然就会发生状态错位的问题。

但是如果你的列表的子组件并没有状态,甚至可能只是渲染一个文本,那么是否复用错误,或许并没有那么重要。因为即使div被错误的复用了,它的下一级的文本节点还是会被正常更新。

以上,可能就是了解了diff算法原理后,对我们带来的一些启示


通俗易懂的React原理(五):子节点的diff算法
https://miku03090831.github.io/2025/08/24/通俗易懂的React原理(五):子节点的diff算法/
作者
qh_meng
发布于
2025年8月24日
许可协议