React 源码漂流记:React 调和器核心源码解读(九)
# 目录
# 前言
在上文中,我们探讨了在 Commit
阶段的三个核心步骤中的三个核心函数 commitBeforeMutationEffects
、 commitMutationEffects
和 commitLayoutEffects
。此三个函数的主要作用就是同步执行 layout
步骤中的生命周期函数和副作用,为浏览器开始绘制视图作准备。截止到上文,从整体流程上来看,React 调和过程的 Commit
阶段就已经完成了,浏览器得到时间切片绘制了视图,新的渲染成果得以落地。
在本文中,我们将重点关注 Commit
阶段的针对 DOM 节点更新的 mutation
操作的细节,所有提交的 mutation
操作最终都要落实到具体的 DOM 节点上,那么从提交 mutation
操作到真正的节点更新细节还是比较复杂的,我们大概会分成两篇文章对其中的细节部分进行探讨。本篇文章我们将探讨 mutation
操作的细节原理,在下文中我们将继续探讨 HostConfig
的 DOM 操纵原理。
# commitDeletion
此函数提交了节点删除操作,其核心工作是从祖先节点开始迭代子节点对待删除的节点的子节点执行以下操作:移除 Refs
引用的联结、调用卸载相关的生命周期函数、删除 return 指针等。
源码如下:
// src/react/packages/react-reconciler/src/ReactFiberCommitWork.new.js
function commitDeletion(
finishedRoot: FiberRoot,
// 待操作的节点
current: Fiber,
// 检测到删除标记的祖先节点
nearestMountedAncestor: Fiber,
): void {
// ......
// Recursively delete all host nodes from the parent.
// Detach refs and call componentWillUnmount() on the whole subtree.
// 从父节点迭代删除所有的子节点,移除 Refs 联结,并且调用相关的卸载生命周期函数
unmountHostComponents(finishedRoot, current, nearestMountedAncestor);
// 删除节点上的 return 指针,注意其他指针如 child、sibling、alternate 指针并不会被删除
// 节点将会在下一次渲染后 GC
detachFiberMutation(current);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# unmountHostComponents
此函数找到待删除节点下的所有 Host 节点(HostComponent 或者 HostText)提交删除操作,并将之从 fiberTree 中移除。
function unmountHostComponents(
finishedRoot: FiberRoot,
current: Fiber,
nearestMountedAncestor: Fiber,
): void {
let node: Fiber = current;
let currentParent;
// 父节点是否是 FiberRoot,即是否待删除的节点是 RootFiber
let currentParentIsContainer;
// ......
while (true) {
// ......
// 如果是 HostComponent 或者 HostText 节点,则将之删除
if (node.tag === HostComponent || node.tag === HostText) {
// 提交删除待删除节点下所有的子节点
commitNestedUnmounts(finishedRoot, node, nearestMountedAncestor);
// 将待删除节点从 DOM Tree 中移除
if (currentParentIsContainer) {
removeChildFromContainer(
((currentParent: any): Container),
(node.stateNode: Instance | TextInstance),
);
} else {
removeChild(
((currentParent: any): Instance),
(node.stateNode: Instance | TextInstance),
);
}
} /*......*/else {
// 提交删除待删除的节点
commitUnmount(finishedRoot, node, nearestMountedAncestor);
// 继续向子节点捕获
if (node.child !== null) {
node.child.return = node;
node = node.child;
continue;
}
}
// 如果已经捕获冒泡完成,则退出
if (node === current) {
return;
}
// 如果无法在继续捕获,且没有兄弟节点,尝试向父节点冒泡
while (node.sibling === null) {
// 如果冒泡到根节点,则退出
if (node.return === null || node.return === current) {
return;
}
node = node.return;
// ......
}
// 如果无法继续捕获,有兄弟节点,尝试向兄弟节点冒泡
node.sibling.return = node.return;
node = node.sibling;
}
}
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
这里的总流程仍然是嵌套在一个 “捕获和冒泡” 的遍历过程之中,其核心目的是找到与 DOM 节点有关联性的 HOST 节点提交删除操作,包括 HostComponent 和 HostText 节点。具体的遍历过程不在赘述。有以下几点需要注意:
commitNestedUnmounts
: 此函数以 “捕获和冒泡” 方式遍历子节点,对每一个子节点执行commitUnmount
提交节点的删除任务。注意,这里是用于迭代删除 HOST 节点及其子节点。(注意:迭代不同于递归,迭代可以平替递归并获得更高的执行效率和更低的内存占用)。removeChildFromContainer
和removeChild
是由 HostConfig 提供,提供原生 JavaScript 删除节点。注意:只有 HOST 节点才会被执行此 DOM 删除操作,其他类型的节点不与 DOM 节点对应。commitUnmount
针对组件类型执行一些清理工作和相关生命周期的调用。
# commitUnmount
此函数针对组件类型执行清理工作和相关生命周期的调用,如函数式组件中副作用的销毁函数的调用、类组件中 componentWillUnmount
生命周期函数的调用等。其他的组件类型的处理不在赘述。
function commitUnmount(
finishedRoot: FiberRoot,
current: Fiber,
nearestMountedAncestor: Fiber,
): void {
// ......
switch (current.tag) {
case FunctionComponent:
case ForwardRef:
case MemoComponent:
case SimpleMemoComponent: {
const updateQueue: FunctionComponentUpdateQueue | null = (current.updateQueue: any);
if (updateQueue !== null) {
const lastEffect = updateQueue.lastEffect;
// 循环 updateQueue 上的 effect 环形链表
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
// 调用副作用中的销毁函数
const {destroy, tag} = effect;
if (destroy !== undefined) {
// HookInsertion 和 HookLayout 标记分别对应 useInsertionEffect 和 useLayoutEffect
if ((tag & HookInsertion) !== NoHookEffect) {
safelyCallDestroy(current, nearestMountedAncestor, destroy);
} else if ((tag & HookLayout) !== NoHookEffect) {
// ......
safelyCallDestroy(current, nearestMountedAncestor, destroy);
}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
return;
}
case ClassComponent: {
// 移除 Refs 引用的联结
safelyDetachRef(current, nearestMountedAncestor);
const instance = current.stateNode;
// 调用类组件 componentWillUnmount 函数
if (typeof instance.componentWillUnmount === 'function') {
safelyCallComponentWillUnmount(
current,
nearestMountedAncestor,
instance,
);
}
return;
}
case HostComponent:
case HostPortal:
case DehydratedFragment:
case ScopeComponent: {
// ......
return;
}
}
}
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
分析如下:
- 针对函数式组价:遍历 updateQueue 上的 effect 环形链表,执行每个 effect 的销毁函数。其中
useInsertionEffect
类型副作用是通过HookInsertion
标记辨识的,useLayoutEffect
类型副作用时通过HookLayout
标记辨识的。关于 effect 环形链表我们将在 Hook 相关章节详述。 - 针对类组件:安全的触发
componentWillUnmount
生命周期函数。所谓安全触发就是使用 tryCatch 语句捕获其中的错误。 为什么在commitUnmount
中需要强调safely
呢?这是因为删除节点的操作应当是比较宽容的,不应该阻塞后续的真正的节点删除的 DOM 操作,捕获到相关的错误之后能够向上冒泡被上级节点捕获到即可。
# detachFiberMutation
此函数删除待删除节点和对应的 alternate 节点的 return 指针。因为 return 指针被删除,被删除的节点及其下的子节点所触发的事件将不能够冒泡到上方的节点树中,且其中的产生的更新也将被检测到并抛出警告。
function detachFiberMutation(fiber: Fiber) {
// Cut off the return pointer to disconnect it from the tree.
// This enables us to detect and warn against state updates on an unmounted component.
// It also prevents events from bubbling from within disconnected components.
//
// Ideally, we should also clear the child pointer of the parent alternate to let this
// get GC:ed but we don't know which for sure which parent is the current
// one so we'll settle for GC:ing the subtree of this child.
// This child itself will be GC:ed when the parent updates the next time.
//
// Note that we can't clear child or sibling pointers yet.
// They're needed for passive effects and for findDOMNode.
// We defer those fields, and all other cleanup, to the passive phase (see detachFiberAfterEffects).
//
// Don't reset the alternate yet, either. We need that so we can detach the
// alternate's fields in the passive phase. Clearing the return pointer is
// sufficient for findDOMNode semantics.
// 断开 workInProgress fiber 和 current fiber 上的 return 指针,但是此时其父节点指向该节点的 child 指针并没有断开,
// 因此 GC 将不会在本次渲染中清理这些删除的节点,在下次渲染时(调和 FiberTree 时)将清理这些悬空的节点(无 return 指针)
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.return = null;
}
fiber.return = null;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# commitPlacement
此函数主要作用是计算待置位节点的父节点和待插入位置的兄弟节点。
function commitPlacement(finishedWork: Fiber): void {
// ......
// 查找距离移位节点的最近的 HOST 祖先节点(标记为 HostComponent、HostRoot、HostPortal)
const parentFiber = getHostParentFiber(finishedWork);
let parent;
// 是否是 HostRoot 或者 HostPortal
let isContainer;
// stateNode 表示当前 fiber 节点对应的 DOM 节点或者是组件实例对象
const parentStateNode = parentFiber.stateNode;
switch (parentFiber.tag) {
case HostComponent:
parent = parentStateNode;
isContainer = false;
break;
case HostRoot:
parent = parentStateNode.containerInfo;
isContainer = true;
break;
case HostPortal:
parent = parentStateNode.containerInfo;
isContainer = true;
break;
// ......
}
// ......
// 查找距离待移位节点最近的兄弟 HOST 节点
// 待移位的节点将插入到查找到的节点之后
const before = getHostSibling(finishedWork);
// We only have the top Fiber that was inserted but we need to recurse down its
// children to find all the terminal nodes.
if (isContainer) {
insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
} else {
insertOrAppendPlacementNode(finishedWork, before, parent);
}
}
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
分析如下:
getHostParentFiber
查找距离节点最近的 HOST 类型 的祖先节点。注意:无论是删除节点还是移位节点(包括添加节点),都是针对 HOST 节点进行操作。HOST 节点包括HostComponent
、HostRoot
、HostPortal
三种。Fiber.stateNode
在 HOST 类型的 Fiber 存储的是其相对应的 DOM 节点。getHostSibling
查找距离节点最近的 HOST 类型的兄弟节点(不能包含Placement
标记,因为其位置不稳定),如果没有兄弟节点且没有父节点或者父节点也是 HOST 节点,则返回 null,否则将继续上父节点追溯,因为非 HOST 类型的组件不对应 DOM 结构,需要向上解包装,在这种情况下查找的效率会大大降低。insertOrAppendPlacementNode
将会根据查找到的父节点和兄弟节点的执行置位操作。
# insertOrAppendPlacementNode
此函数对待置位的节点进行置位(插入或者移位)。
function insertOrAppendPlacementNode(
node: Fiber,
before: ?Instance,
parent: Instance,
): void {
const {tag} = node;
const isHost = tag === HostComponent || tag === HostText;
// 如果待置位节点是 HOST 类型的节点,有 before 节点则在 before 节点前插入,
// 否则则追加到末尾
if (isHost) {
const stateNode = node.stateNode;
if (before) {
insertBefore(parent, stateNode, before);
} else {
appendChild(parent, stateNode);
}
} /*......*/ else {
const child = node.child;
// 如果该节点不是 HOST 节点,则对组件进行解包装,取其子节点即子节点的兄弟节点依次置位
if (child !== null) {
insertOrAppendPlacementNode(child, before, parent);
let sibling = child.sibling;
while (sibling !== null) {
insertOrAppendPlacementNode(sibling, before, parent);
sibling = sibling.sibling;
}
}
}
}
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
函数中对待置位的节点是否是 HOST 类型的节点分成两种情况处理。
- 如果待置位的节点是 HOST 节点,且已经找到稳定的兄弟节点,则将该节点插入到此节点的前面,否则说明父节点下无稳定节点,则将该节点追加到末尾。
- 如果待置位的节点不是 HOST 节点,则需要对该节点进行解包装,对该节点的子节点即子节点的所有兄弟节点进行递归置位。
# commitWork
此函数对 HostComponent
、 HostText
等 HOST 类型的节点提交更新操作,同时针对函数式组件处理相关副作用。
// src/react/packages/react-reconciler/src/ReactFiberCommitWork.new.js
function commitWork(current: Fiber | null, finishedWork: Fiber): void {
// ......
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case MemoComponent:
case SimpleMemoComponent: {
// 如果是函数式组件(或者 fc-like 型组件),在组件更新之前,先执行 useInsertionEffect 的销毁函数
commitHookEffectListUnmount(
HookInsertion | HookHasEffect,
finishedWork,
finishedWork.return,
);
// 执行 useInsertionEffect 的副作用函数,see https://zh-hans.reactjs.org/docs/hooks-reference.html#useinsertioneffect
commitHookEffectListMount(HookInsertion | HookHasEffect, finishedWork);
// Layout effects are destroyed during the mutation phase so that all
// destroy functions for all fibers are called before any create functions.
// This prevents sibling component effects from interfering with each other,
// e.g. a destroy function in one component should never override a ref set
// by a create function in another component during the same commit.
// ......
// 执行 useLayoutEffect 的销毁函数
commitHookEffectListUnmount(
HookLayout | HookHasEffect,
finishedWork,
finishedWork.return,
);
return;
}
case HostComponent: {
const instance: Instance = finishedWork.stateNode;
if (instance != null) {
// Commit the work prepared earlier.
const newProps = finishedWork.memoizedProps;
// For hydration we reuse the update path but we treat the oldProps
// as the newProps. The updatePayload will contain the real change in
// this case.
const oldProps = current !== null ? current.memoizedProps : newProps;
const type = finishedWork.type;
const updatePayload: null | UpdatePayload = (finishedWork.updateQueue: any);
finishedWork.updateQueue = null;
if (updatePayload !== null) {
// 提交节点上的更新
commitUpdate(
instance,
updatePayload,
type,
oldProps,
newProps,
finishedWork,
);
}
}
return;
}
case HostText: {
// ......
const textInstance: TextInstance = finishedWork.stateNode;
const newText: string = finishedWork.memoizedProps;
// For hydration we reuse the update path but we treat the oldProps
// as the newProps. The updatePayload will contain the real change in
// this case.
const oldText: string =
current !== null ? current.memoizedProps : newText;
// 提交文本更新
commitTextUpdate(textInstance, oldText, newText);
return;
}
case ClassComponent:
case HostRoot:
case Profiler:
case SuspenseComponent:
case SuspenseListComponent:
case IncompleteClassComponent: {
// ......
return;
}
case ScopeComponent: {
// ......
break;
}
}
// ......
}
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
- 对于函数式组件而言,本函数先执行
useInsertionEffect
的销毁函数,再执行useInsertionEffect
的副作用函数。另外,执行useLayoutEffect
的销毁函数,之所以在mutation
阶段执行销毁函数,而非放到layout
阶段在其副作用函数之前执行,是为了避免兄弟组件之间相互干扰。 useInsertionEffect
在所有 DOM 突变之前同步触发,应仅限于 css-in-js 库作者使用。使用它在读取useLayoutEffect
中的布局之前将样式注入 DOM,确保在对 DOM 进行其他更改的同时操作 CSS 规则。参见 Hook API 索引 – useInsertionEffect (opens new window)。- 对于
HostComponent
而言,调用commitUpdate
提交节点的更新。对于HostText
而言,调用commitTextUpdate
函数提交文本节点的更新。这两个函数均是由HostConfig
提供,此部分 HOST 节点的更新将应用到 DOM 的更新上。
# commitHookEffectListMount
此函数执行副作用函数或者销毁函数,可用于 useEffect
、 useLayoutEffect
、 useInsertionEffect
等 Hook 中。其具体内容请参考 Hook 相关章节的详述,此处暂不赘述。
# commitDetachRef
此函数去除组件的 Ref 引用的联结。
function commitDetachRef(current: Fiber) {
const currentRef = current.ref;
if (currentRef !== null) {
// 如果 ref 是 function ref,将之置为 null
if (typeof currentRef === 'function') {
currentRef(null);
// ......
} else {
// 普通 ref,置为 null 即可
currentRef.current = null;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
# commitAttachRef
此函数添加组件的 Ref 应用的联结。
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref;
// 注意:上文 commitMutationEffectsOnFiber 中 detach 的是 current fiber 的 Ref,
// 并不是 workInProgress fiber 的 Ref,因此此时 Ref 必然不为空
if (ref !== null) {
// 获取组件实例或者 DOM 组件实例,`Fiber.stateNode` 对于 HOST 类型的组件缓存 DOM 组件实例,
// 否则则缓存组件的实例
const instance = finishedWork.stateNode;
let instanceToUse;
switch (finishedWork.tag) {
case HostComponent:
instanceToUse = getPublicInstance(instance);
break;
default:
instanceToUse = instance;
}
// ......
// function ref 传递新的 ref
if (typeof ref === 'function') {
ref(instanceToUse);
} else {
// ......
ref.current = instanceToUse;
}
}
}
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
- 对于非 HOST 组件,Ref 是缓存的是组件的实例,对于 HOST 类型组件,如
HostComponent
,Ref 上缓存的是 DOM 组件实例。注意HostText
、HostRoot
、HostPortal
没有 Ref,这是因为他们是两端的节点(根节点或者叶子节点),不存在传递 Ref 应用的需求。 - Ref 分为普通的对象式 Ref 和回调式 Ref,参见 Refs and the DOM – 回调 Refs (opens new window)
# 扩展
# “置位” 是如何解决 DOM 节点的插入和移位问题的?
在 insertOrAppendPlacementNode
函数中,我们可以看到 React 对于 HOST 节点的插入是通过 insertBefore
和 appendChild
来实现的,那么我们可能会有这样的疑问,这种方式是如何实现 “插入” 和 “移位” 的需求的呢?要弄清楚这个问题,我们需要先结合 placeChild (opens new window) 函数来看,在给同层级节点进行置位时,有如下的代码:
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
newFiber.flags |= Placement;
return lastPlacedIndex;
} else {
return oldIndex;
}
} else {
newFiber.flags |= Placement;
return lastPlacedIndex
}
2
3
4
5
6
7
8
9
10
11
12
可以看待,要添加 Placement
置位标记,有以下两种情况:
- 无可复用节点,即为 “插入” 的场景,添加置位标记。
- 有可复用节点,且该节点原位置在上次置位位置的左侧(原位置比较靠左),即为需要 “移位” 场景,添加置位标记。
参考如下图示:
这其中包括两个步骤:
- 生成 EffectTag List,在 FiberTree 的调和过程中(
Render
阶段)完成。 - 根据 EffectTag List 操作(更新) DOM,在
Commit
阶段的mutation
步骤中完成。
结合这两个步骤的脉络,加之 “置位” 和 “提交置位” 的逻辑,便不难理解这个问题了。
# 问题
# 总结
本文主要讲解了 commitDeletion
、 commitPlacement
和 commitWork
三个核心函数的原理。这三个函数都是在 mutation
过程中完成的,目的是向 DOM 提交 “删除”、“置位”、“更新” 等操作。可以此三个函数是 FiberTree 与 DOMTree 沟通的接口。FiberTree 相对于 DOMTree 具有更高层次的抽象意义,也就说 FiberTree 除了承担视图抽象之外和承担了数据抽象的责任。因此,FiberTree 与 DOMTree 交接时主要依赖于 Fiber.stateNode
和 Fiber.tag
这两个字段。
Fiber.tag
为 HOST 类型,典型为 HostComponent
和 HostText
,则表明该 Fiber 节点对应着 DOM 节点,则相应的 EffectTag 就需要在这些 HOST 节点上有所影响。 Fiber.stateNode
对于 HOST 节点而言缓存着对应的 DOM 节点,这为在节点上操作 DOM 提供了便利。
React 中 DOM 的处理(mutation)包括三个方面:
Deletion
,即删除,调用removeChild
。Placement
,即置位,包括插入和移位,调用insertBefore
或者appendChild
。Update
,即更新,调用commitUpdate
。
在如上的过程中往往伴随着副作用的处理、生命周期函数的处理、Ref 引用的处理等工作。而真正的 DOM 操作则是由 HostConfig
所提供,如 removeChild
、 commitUpdate
等。 HostConfig
提供了防腐层以抹平不同 HOST 环境(平台)中对于节点操作的差异,这方面内容将在下文中详述。