React 源码漂流记:React 调和器核心源码解读(八)
# 目录
# 前言
在上文中,我们探讨了 Commit
过程的三大步骤,以及完成这三大步骤所采用的遍历方式。 beforeMutation
、 mutation
和 layout
此三大步骤归根结底是对 workInProgress FiberTree
的应用,是两个重要跳跃的基石:一是从 WorkInProgress FiberTree
向 current FiberTree
的跳跃,二是从 EffectList
向 DOM 更新
的跳跃。
扩展来说,有以下几点值得我们思考:
FiberTree
、ReactElementTree
和DOMTree
在 React 中的关系是相当复杂的。总结来说,DOMTree
是页面视图的状态,ReactElementTree
是逻辑视图的状态,它包含了组件层面的状态变化(setState)、视图更新(JSX 更新)和事件响应(Event Listener),而FiberTree
是数据层面的状态,它是应用层面的(或者说是 FiberRoot 容器层面的)数据的生态,是对状态变化(updateQueue)、视图更新(EffectList)、事件响应(事件委托系统)的数据抽象。我们可以从整个调和器的大循环中进行体会。- “捕获和冒泡” 是 React 中针对 Tree 数据结构的一种通用的遍历方式,其本质是 DFS(深度优先遍历)的模型,React 将调和 FiberTree、消费 EffectList 的逻辑注入到 DFS 的过程之中,并针对 Tree 结构的特性进行性能和效率的优化。为什么采用 DFS 的方式呢?一是足够高效,DFS 对每个节点访问(visit )两次;而是足够灵活,DFS 可以跳过某些不需要遍历的子树从而提升遍历效率。
# commitBeforeMutationEffectsOnFiber
此函数在 mutation 阶段之前执行类组件的 getSnapshotBeforeUpdate
函数。
function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) {
const current = finishedWork.alternate;
const flags = finishedWork.flags;
// ......
// Snapshot EffectTag 标记在具有 getSnapshotBeforeUpdate 函数的类组件上或者 `HostRoot` 上
if ((flags & Snapshot) !== NoFlags) {
// ......
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
break;
}
case ClassComponent: {
// 如果非首次渲染
if (current !== null) {
// 当前 current Fiber 上的 prop、state 和组件实例
const prevProps = current.memoizedProps;
const prevState = current.memoizedState;
const instance = finishedWork.stateNode;
// ......
// 调用 getSnapshotBeforeUpdate,see https://zh-hans.reactjs.org/docs/react-component.html#getsnapshotbeforeupdate
const snapshot = instance.getSnapshotBeforeUpdate(
// 如果 elementType 和 type 不一致,则可能是 lazyComponent,需要
// 将 ReactElement 上的默认 props 同步到组件实例上,see https://zh-hans.reactjs.org/docs/react-component.html#defaultprops
finishedWork.elementType === finishedWork.type
? prevProps
: resolveDefaultProps(finishedWork.type, prevProps),
prevState,
);
// ......
// 缓存 snapshot 值以在 componentDidUpdate 中使用
instance.__reactInternalSnapshotBeforeUpdate = snapshot;
}
break;
}
case HostRoot: {
// 如果是 HostRoot 组件,说明是首次渲染,清空容器
const root = finishedWork.stateNode;
clearContainer(root.containerInfo);
break;
}
case HostComponent:
case HostText:
case HostPortal:
case IncompleteClassComponent:
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
大家可能已经注意到了,如上的代码都是针对有 Snapshot
标记的 fiber 节点执行的, Snapshot
标记用于有 getSnapshotBeforeUpdate
的函数的类组件和 HostRoot
组件,用于对需要在 DOM 操作之前操作原来的实例信息、DOM 节点的场景做标记。 HostRoot
节点是 ReactDOM.render
中挂载到 root 容器中的 RootFiber 节点,一般情况下 HostRoot
在非首次渲染时并不会发生变化,至首次渲染时则需要对对所挂载的容器进行节点清空。
此函数有如下作用:
- 针对类组件,执行
getSnapshotBeforeUpdate(prevProps, prevState)
生命周期函数,并且将 snapshot 传递给componentDidUpdate(prevProps, prevState, snapshot)
。 - 针对 HostRoot 节点,清空 root 容器中的 DOM 节点。
# commitMutationEffectsOnFiber
此函数对移位(placement)、update(更新)等操作提交相应的 mutation 操作,此 mutation 操作将会操纵 JavaScript 进行 DOM 节点的更新。
function commitMutationEffectsOnFiber(finishedWork: Fiber, root: FiberRoot) {
const flags = finishedWork.flags;
// 如果有 ContentReset 标记,清空节点中的文本内容
if (flags & ContentReset) {
commitResetTextContent(finishedWork);
}
// 如果有 Ref 标记,则将对应的 current 节点上的 Ref 关联去除
if (flags & Ref) {
const current = finishedWork.alternate;
if (current !== null) {
commitDetachRef(current);
}
// ......
}
// ......
// The following switch statement is only concerned about placement,
// updates, and deletions. To avoid needing to add a case for every possible
// bitmap value, we remove the secondary effects from the effect tag and
// switch on that value.
// 由于下面的处理只关心 placement、updates 和 deletions 相关的操作,因此将之作为一流标记保留,其余标记均删除
const primaryFlags = flags & (Placement | Update | Hydrating);
outer: switch (primaryFlags) {
// 如果包含 Placement(位置变化)标记,则提交 Placement 的 mutation 操作
case Placement: {
commitPlacement(finishedWork);
// Clear the "placement" from effect tag so that we know that this is
// inserted, before any life-cycles like componentDidMount gets called.
// 清理标记
finishedWork.flags &= ~Placement;
break;
}
// 如果包含 PlacementAndUpdate(位置变化和节点更新)标记,则提交 Placement 和 update 的 mutation 操作
case PlacementAndUpdate: {
// Placement
commitPlacement(finishedWork);
finishedWork.flags &= ~Placement;
// 提交 update 操作
const current = finishedWork.alternate;
commitWork(current, finishedWork);
break;
}
case Hydrating: {
// SSR 无需 DOM 操作
finishedWork.flags &= ~Hydrating;
break;
}
case HydratingAndUpdate: {
finishedWork.flags &= ~Hydrating;
const current = finishedWork.alternate;
commitWork(current, finishedWork);
break;
}
case Update: {
const current = finishedWork.alternate;
commitWork(current, finishedWork);
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
此函数核心作用是对具有 primaryFlags(一流的标记)的节点提交相应的 mutation 操作。细节内容如下:
- 处理
ContentReset
和Ref
标记。如果节点有ContentReset
标记,则清空节点内部的文本内容,如果节点有Ref
标记,则解除该节点相对应的 current 节点上 Ref 的联结(注意当前节点上并没有 Ref 的联结),因为节点在 layout 过程中会重新建立 Ref 联结。Ref 的原理将在 hook 相关章节详述。 commitMutationEffectsOnFiber
只关心与 placement、update 和 hydrating(水合)相关的遗留标记(注意:删除操作已经移到冒泡过程中处理,此处不再处理),primaryFlags
的位运算计算原理不再赘述。需要特别交代的是:为什么PlacementAndUpdate
和PlacementAndUpdate
也能被处理,事实上这两个标记是复合标记,见下:
const PlacementAndUpdate = Placement | Update;
const HydratingAndUpdate = Hydrating | Update;
2
- 如果有
Placement
标记,则调用commitPlacement
提交移位的 mutation 操作。 - 如果有
Update
标记,则调用commitWork
提交更新的 mutation 操作。
# commitLayoutEffectOnFiber
这个函数主要是针对不同的组件类型执行不同的处理,包括生命周期的处理、副作用的处理等。
// src/react/packages/react-reconciler/src/ReactFiberCommitWork.new.js
const LayoutMask = Update | Callback | Ref | Visibility;
function commitLayoutEffectOnFiber(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedLanes: Lanes,
): void {
if ((finishedWork.flags & LayoutMask) !== NoFlags) {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
// ......
// 调用 useLayoutEffect 的回调函数,并且缓存销毁函数
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
break;
}
case ClassComponent: {
const instance = finishedWork.stateNode;
// 如果组件发生了更新
if (finishedWork.flags & Update) {
// 如果有 `Update` 标记,在首次渲染时执行实例的 componentDidMount 生命周期函数,
// 否则执行 componentDidUpdate 生命周期函数
if (current === null) {
// ......
instance.componentDidMount();
} else {
// 因为此处 workProgress 已经与 current 交换,所以 current 上具有最新的 props 和 state
const prevProps =
finishedWork.elementType === finishedWork.type
? current.memoizedProps
: resolveDefaultProps(
finishedWork.type,
current.memoizedProps,
);
const prevState = current.memoizedState;
// ......
// 成为了 current fiber 之后,相对于 workInProgress fiber 而言,其 props 和 state 就是之前的。
// see https://zh-hans.reactjs.org/docs/react-component.html#componentdidupdate
instance.componentDidUpdate(
prevProps,
prevState,
instance.__reactInternalSnapshotBeforeUpdate, // getSnapshotBeforeUpdate 返回的 snapshot
);
}
}
const updateQueue: UpdateQueue<*,> | null = (finishedWork.updateQueue: any);
if (updateQueue !== null) {
// ......
// 消费 updateQueue 中的副作用回调
commitUpdateQueue(finishedWork, updateQueue, instance);
}
break;
}
case HostRoot: {
// 如果 HostRoot 上有 updateQueue(注意 updateQueue 是一个 object),可能有 callback effect,因此
// 提交 commitUpdateQueue 消费这些 effect,注意这里传递给 effect 的 context 是 HostRoot 下直系的的叶子节点
const updateQueue: UpdateQueue<*,> | null = (finishedWork.updateQueue: any);
if (updateQueue !== null) {
let instance = null;
if (finishedWork.child !== null) {
switch (finishedWork.child.tag) {
case HostComponent:
// getPublicInstance 兼容不同 HOST 环境
instance = getPublicInstance(finishedWork.child.stateNode);
break;
case ClassComponent:
instance = finishedWork.child.stateNode;
break;
}
}
commitUpdateQueue(finishedWork, updateQueue, instance);
}
break;
}
case HostComponent:
case HostText:
case HostPortal:
case Profiler:
case SuspenseComponent:
case SuspenseListComponent:
case IncompleteClassComponent:
case ScopeComponent:
case OffscreenComponent:
case LegacyHiddenComponent:
break;
// ......
}
}
// ......
// 如果 fiber 上有 Ref 标记,则重新建立 Ref 联结
if (finishedWork.flags & Ref) {
commitAttachRef(finishedWork);
}
}
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
86
87
88
89
90
91
92
93
94
95
96
97
98
分析如下:
注意此函数的执行时机, mutation
阶段之后表示 DOM 的更改已经提交了, requestPaint
之前,表示浏览器很有可能并没有实现视图的绘制,但是这不影响相关的生命周期函数在 layout
期间可以获取到最新的 DOM 属性、组件状态和属性。
- 对于
FunctionComponent
、ForwardRef
(接受一个参数 render,即为函数式组件或者类组件的 render 函数,返回一个可传递 Refs 的函数)、SimpleMemoComponent
(接受一个函数式组件,返回一个可缓存 props(浅比较) 的函数式组件) 这些函数式组件而言,执行commitHookEffectListMount
处理useLayoutEffect
中的副作用回调,并且缓存其销毁回调。 - 对于
ClassComponent
而言,需要处理类组件的生命周期函数,如果是初次渲染则调用componentDidMount
,否则就调用componentDidUpdate
。commitUpdateQueue
会消费掉 setState 中传入的回调函数,即setState(updater[, callback])
中的 callback。因此可以看出,这些 setState 中的回调并不是在updater
执行后调用的,而是收集起来在layout
阶段批量消费的,这样可以保证callback
执行之时 state 是已经更新过的,也可以提高回调执行的效率(事实上,state 的更新也是批量完成的)。 - 对于
HostRoot
(RootFiber) 而言,也需要执行commitUpdateQueue
消费回调,此回调来自于render(element, container[, callback])
中的callback
。Callback
标记不止用类组件中 setState 的回调,也用于HostRoot
上render
或者hydrate
的回调。
# 扩展
# useLayoutEffect
中 layout
含义是什么?
此 layout
正是来源于 layout
阶段中的这个 layout
的概念,中文意为 “布局”。我们可以结合 commitLayoutEffectOnFiber
这个函数的具体内容来分析 layout
的含义。其实,无论是 useLayoutEffect
的处理,还是类组件生命周期钩子 componentDidMount
或 componentDidUpdate
的处理,还是对各种 Callback
副作用的处理,都离不开一个词,即是 “副作用”。具体而言, layout
所处理的正是组件挂载(或者更新)时的各种同步的副作用。这些副作用依赖于 mutation
阶段完成 DOM 操作这个时机,对组件的状态和行为产生一定的影响,进而影响对组件后续的渲染。
因为 layout
中副作用的执行是同步的、阻塞渲染的,因此也就可以在渲染之前对 DOM 进行更改,从而使浏览器可以一并完成重排和重绘。从这个角度来看,颇有 “布局” 的含义。以 DOM 更新的视角而言, useLayoutEffect
可以减少浏览器的绘制成本,如果不涉及到阻塞更新的缺陷(具有较小的阻塞成本,并且多为 DOM 操作),则可以考虑使用 useLayoutEffect
。
# 问题
# 总结
本文承接上文中 EffectList
循环的内容,讲解在其循环过程中的 “冒泡” 阶段所执行的三个核心函数的内容。现对此三个函数总结如下:
commitBeforeMutationEffectsOnFiber
: 执行于beforeMutation
阶段,主要是针对具有Snapshot
标记的节点做处理。Snapshot
意为 “快照”,在mutation
阶段变回执行真正的 DOM 操作,因此趁此mutation
未处理尚可以操作旧 DOM 节点之际,对依靠Snapshot
的相关逻辑进行处理。例如类组件getSnapshotBeforeUpdate
钩子函数。commitMutationEffectsOnFiber
: 执行于mutation
阶段,主要对具有Update
和Placement
标记的节点进行处理。Update
和Placement
分别对应节点的 “更新” 和 “替换” 行为,因此此过程主要对节点进行 DOM 修正(即mutation
)。由于组件是 DOM 节点组织形式的抽象,因此无论是函数式组件、类组件还是其他,一律不考虑组件类型,提交相应的mutation
操作即可(事实上,在提交此操作后真正执行修正过程中才会针对组件类型进行区分处理)。commitLayoutEffectOnFiber
: 执行于layout
阶段,主要针对有LayoutMask
标记的节点做处理(注意:实际上没有这个标记,这是一个标记集合,或者成为遮罩标记)。由于位于mutation
阶段之后,因此此过程主要对依赖于新的 DOM 状态或者组件挂载(或者更新)的逻辑进行处理。其中比较重要的包括三个方面:useLayoutEffect
的调用。useLayoutEffect
的调用时机是 DOM 更新之后,浏览器未渲染之前,其调用要早于useEffect
,其内部的更新计划为同步刷新。事实上,useLayoutEffect
与类组件中componentDidMount
和componentDidUpdate
调用时间是一致的,也都是同步刷新。相对而言,React 官网会推荐使用useEffect
来替代useLayoutEffect
。原因有三:两者执行时 DOM 都已经加载完毕,其中useEffect
执行时,浏览器基本已经渲染完毕,不存在各种副作用执行的误差;useEffect
是经过调度器的回调在浏览器的空闲时机单独处理副作用的,其不会阻塞渲染(或者说不会提升渲染的执行成本)因此效率更高;useEffect
对 SSR 更加友好,不容易出现问题。参见 Hook API 索引:useLayoutEffect (opens new window)。- 类组件生命周期
componentDidMount
或者componentDidUpdate
的调用。类组件生命周期的调用都是同步的,因此生命周期的内容的执行实际上是会对渲染的过程具有一定的阻塞作用的。因此,对于组件中生命周期的设计而言,天然就具有这样的劣势,因为要想维护生命周期时机的正确性,必须要容忍其同步性。相对于言,副作用思想的组件设计就突破了这种劣势,因为副作用的执行是异步的、非阻塞式的,这也是函数式组件针对类组件的具有的优势之一。副作用天然就具有对执行时机的低耦合性,也就是说,在大部分场景下,我们所需要的副作用的场景并不需要阻塞渲染,因此,React 为我们提供了灵活的副作用的执行方式,useEffect
和useLayoutEffect
的设计正是为此而生。 - 执行
Callback
副作用。使用Callback
标记的各种Callback
副作用本质上是批量同步执行的,包括类组件setState
产生的回调副作用和ReactDOM
中render
函数或hydrate
函数产生的回调副作用。
从整体上看,这三个函数除了对生命周期和副作用处理之外,其核心还是对照 EffectTag 以对 DOM 的各种操作进行提交,此部分内容将在下文详述。