React 源码漂流记:React 调和器核心源码解读(七)
# 目录
# 前言
在上篇文章中,我们对 React 渲染过程中的 “捕获” 与 “冒泡” 过程的理解有了更深一层的认识,并且从整体脉络上总结了 Batch
阶段和 Render
阶段的过程。从本文开始,我们将开始探讨 Commit
阶段的原理,逐步了解 FiberTree 向 DOMTree 飞跃的过程。
渲染根据调度方式的不同被分成了同步渲染和异步渲染,在同步的渲染结束后调用 commitRoot
提交本次的调和结果,而在异步渲染结束后是通过 finishConcurrentRender
来处理后续的工作的。下面我们就从 finishConcurrentRender
函数开始深入分析。
# finishConcurrentRender
从本质上来说 finishConcurrentRender
的核心作用还是执行 commitRoot
以提交调和结果,但是相比同步渲染而言,异步渲染要更加复杂,换句话说, Render
结束后要视情况而定是否需要立即 Commit
,要根据 Render
阶段的执行情况(exitStatus)加以确认。 Commit
的操作应当足够高效,因为 DOM 的绘制过程成本不菲。
function finishConcurrentRender(root, exitStatus, lanes) {
switch (exitStatus) {
// ......
case RootErrored: {
commitRoot(root);
break;
}
case RootSuspended:
case RootSuspendedWithDelay: {
markRootSuspended(root, lanes);
// ......
// The work expired. Commit immediately.
commitRoot(root);
break;
}
case RootCompleted: {
// The work completed. Ready to commit.
commitRoot(root);
break;
}
// ......
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
分析如下:
- 只有在发生普通错误的时候才允许提交,
RootIncomplete
(未完成状态) 和RootFatalErrored
(致命错误状态)是不允许提交的。这也很符合预期,因为在进入此函数之前,普通错误就已经重试多次了。 - 对于
RootSuspended
和RootSuspendedWithDelay
,必须等到任务超时,才能够进行提交。
# commitRoot
commitRoot
主要调用 commitRootImpl
函数,源码如下:
function commitRootImpl(root, renderPriorityLevel) {
do {
// `flushPassiveEffects` will call `flushSyncUpdateQueue` at the end, which
// means `flushPassiveEffects` will sometimes result in additional
// passive effects. So we need to keep flushing in a loop until there are
// no more pending effects.
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);
// ......
// 确保当前是 Batch 状态
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
throw new Error('Should not already be working.');
}
const finishedWork = root.finishedWork;
// 当前 commit 的优先级
const lanes = root.finishedLanes;
// ......
// 合并finishedWork子树的 lanes,剩余的未处理的优先级
let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes);
// 将剩余未处理的优先级挂载到 root.pendingLanes 上
markRootFinished(root, remainingLanes);
// ......
// If there are pending passive effects, schedule a callback to process them.
// Do this as early as possible, so it is queued before anything else that
// might get scheduled in the commit phase.
// 确保子树有 PassiveMask 副作用时被调度以处理副作用
// const PassiveMask = Passive | ChildDeletion;
if (
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags
) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
// ......
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
// This render triggered passive effects
return null;
});
}
}
// Check if there are any effects in the whole tree.
// 判断子树是否有副作用
const subtreeHasEffects =
(finishedWork.subtreeFlags &
(BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !==
NoFlags;
// 判断根节点是否有副作用
const rootHasEffect =
(finishedWork.flags &
(BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !==
NoFlags;
// 如果子树或者根节点有副作用,则处理之
if (subtreeHasEffects || rootHasEffect) {
// ......
const prevExecutionContext = executionContext;
// executionContext 更新为 CommitContext
executionContext |= CommitContext;
// ......
// The commit phase is broken into several sub-phases. We do a separate pass
// of the effect list for each phase: all mutation effects come before all
// layout effects, and so on.
// The first phase a "before mutation" phase. We use this phase to read the
// state of the host tree right before we mutate it. This is where
// getSnapshotBeforeUpdate is called.
// Commit 阶段分成三个步骤,分别是 before mutation, mutation 和 layout。
// before mutation 阶段在 mutation 之前读取旧状态,并调用相关的组件生命周期函数
const shouldFireAfterActiveInstanceBlur = commitBeforeMutationEffects(
root,
finishedWork,
);
// ......
// The next phase is the mutation phase, where we mutate the host tree.
// mutation 阶段对副作用进行执行和更新,执行 DOM 操作,调用相关的生命周期函数
commitMutationEffects(root, finishedWork, lanes);
// ......
// The work-in-progress tree is now the current tree. This must come after
// the mutation phase, so that the previous tree is still current during
// componentWillUnmount, but before the layout phase, so that the finished
// work is current during componentDidMount/Update.
// workInProgress 树切换到current树的时机是在mutation结束后,layout开始前。
// 这样做的原因是在mutation阶段调用类组件的componentWillUnmount的时候,还可以获取到卸载前的组件信息;
// 在layout阶段调用componentDidMount/Update时,获取的组件信息更新后的。
root.current = finishedWork;
// The next phase is the layout phase, where we call effects that read
// the host tree after it's been mutated. The idiomatic use case for this is
// layout, but class component lifecycles also fire here for legacy reasons.
// layout 阶段在 mutation 阶段之后,读取组件的最新状态,并执行相关的生命周期函数
commitLayoutEffects(finishedWork, root, lanes);
// ......
// Tell Scheduler to yield at the end of the frame, so the browser has an
// opportunity to paint.
// 请求调度器在帧尾阻塞 `Render` 过程,以使浏览器有足够的空闲时间绘制视图
requestPaint();
// Commit 完毕后恢复 executionContext
executionContext = prevExecutionContext;
// ......
}
if (
includesSomeLane(pendingPassiveEffectsLanes, SyncLane) &&
root.tag !== LegacyRoot
) {
flushPassiveEffects();
}
// ......
// 退出 commitRoot 时调用,确保 Root 上新的任务会被调度
ensureRootIsScheduled(root, now());
// ......
// If layout work was scheduled, flush it now.
flushSyncCallbacks();
// ......
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
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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
此函数涵盖了 Commit
阶段的整个过程,有一些细节问题分析如下:
flushPassiveEffects
:flushPassiveEffects
主要与useEffect
的副作用相关,此函数以同步或者异步的方式执行useEffect
的销毁函数和回调函数。细节部分将在 hook 相关章节进行详细探讨。如果子树有PassiveMask
标记,则在调度器的回调中调用flushPassiveEffects
。- 有四种副作用标记被用来判断是否需要
Commit
,分别是:BeforeMutationMask
、MutationMask
、LayoutMask
、PassiveMask
。当finishedWork
根节点上或者子树上具有如上的副作用,则执行Commit
操作。 Commit
过程分成三个步骤,分别是beforeMutation
、mutation
和layout
,分别调用commitBeforeMutationEffects
、commitMutationEffects
和commitLayoutEffects
。- workInProgress FiberTree 成为 current FiberTree 是在
mutation
阶段之后、layout 阶段之前完成的,root.current = finishedWork
。之前的 current FiberTree 现在利索当前的就成了workInProgress FiberTree
。 - 在
mutation
阶段,React 已经根据 EffectTag 操纵 JavaScript 对 DOM 进行了插入、更新、删除等操作,由于浏览器的空闲时间实际上是被调度器控制的,所以在layout
阶段完成之后,需要通知调度器进行 yield(阻塞渲染回调),给浏览器重绘留下充足的时间。requestPaint
函数将在调度器部分进行详细的分析。
旧版本的执行逻辑
三个阶段:
- before mutation:读取组件变更前的状态,针对类组件,调用 getSnapshotBeforeUpdate,让我们可以在 DOM 变更前获取组件实例的信息;针对函数组件,异步调度 useEffect。
- mutation:针对 HostComponent,进行相应的 DOM 操作;针对类组件,调用 componentWillUnmount;针对函数组件,执行 useLayoutEffect 的销毁函数。
- layout:在 DOM 操作完成后,读取组件的状态,针对类组件,调用生命周期 componentDidMount 和 componentDidUpdate,调用 setState 的回调;针对函数组件填充 useEffect 的 effect 执行数组,并调度 useEffect。
在进入下面三个核心函数的分析之前,我们需要先分析一下 FiberTree 上 Effect 的遍历过程。从上文中我们已经知道了,新版的 React 去除了 EffectList 的概念,将 Effect 冒泡收集到 subtreeFlags
标记上。因此,在对 EffectList 的遍历时,就不能直接使用旧版中链表的遍历方式。
# EffectList 的遍历
下面以 commitMutationEffects
探讨 FiberTree 中 EffectList
的遍历过程。这里同样分为 “捕获” 和 “冒泡” 的过程,将遍历过程穿插执行的工作可以理解为 beginWork
和 completeWork
。(注意,此时遍历的原理与调和 FiberTree 时遍历的原理一致)。
// src/react/packages/react-reconciler/src/ReactFiberCommitWork.new.js
function commitMutationEffects(
root: FiberRoot,
firstChild: Fiber,
) {
nextEffect = firstChild;
commitMutationEffects_begin(root);
}
function commitMutationEffects_begin(root: FiberRoot) {
while (nextEffect !== null) {
const fiber = nextEffect;
// begin work......(beginWork插槽)
const child = fiber.child;
// MutationMask 或者 BeforeMutationMask 或者 LayoutMask
// 如果当前 Fiber 有 subtreeFlags,说明子树中有相应的 EffectTag
// 这里的判断决定是否需要继续捕获
if ((fiber.subtreeFlags & MutationMask) !== NoFlags && child !== null) {
// ......
// 将子节点作为下一个遍历的节点,向下捕获
nextEffect = child;
} else {
// 不符合上述条件,说明子树中无响应的 EffectTag,因此开始冒泡
commitMutationEffects_complete(root);
}
}
}
function commitMutationEffects_complete(root: FiberRoot) {
while (nextEffect !== null) {
const fiber = nextEffect;
// complete work......(completeWork插槽)
// 冒泡时先冒泡到兄弟节点,无兄弟节点时再冒泡到父节点
// 冒泡一次后需要仅此进行捕获的判断,因此需要 return
const sibling = fiber.sibling;
if (sibling !== null) {
// ......
nextEffect = sibling;
return;
}
nextEffect = fiber.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
弄清楚上述的 “捕获” 和 “冒泡” 的遍历过程之后,在下文中 commitBeforeMutationEffects
、 commitMutationEffects
和 commitLayoutEffects
三个函数中都是对此遍历方法的应用。因此在下文的分析中,我们将着重探讨 beginWork
和 completeWork
插槽中的工作细节。
# commitBeforeMutationEffects
// completeWork插槽
commitBeforeMutationEffectsOnFiber(fiber);
2
分析如下:
- 在冒泡阶段在 fiber 上执行
commitBeforeMutationEffectsOnFiber
。
# commitMutationEffects
// beginWork插槽
const deletions = fiber.deletions;
if (deletions !== null) {
for (let i = 0; i < deletions.length; i++) {
const childToDelete = deletions[i];
commitDeletion(root, childToDelete, fiber);
// ......
}
}
// completeWork插槽
commitMutationEffectsOnFiber(fiber, root);
2
3
4
5
6
7
8
9
10
11
分析如下:
- 在捕获阶段查找 fiber 上
deletions
收集的待删除的节点,并且提交节点的删除。 - 在冒泡阶段调用
commitMutationEffectsOnFiber
。 - 为什么删除操作要在捕获阶段单独进行,而不是放在冒泡阶段一起处理?因为删除操作相对于更新操作(添加或者更新)而言要简单许多,在当前已经遍历到的节点上,只需将前置工作中收集到的待删除的节点进行处理即可;相对而言,
commitMutationEffectsOnFiber
的处理过程则要复杂的多,需要通过不同的 EffectTag 以进行不同的处理,并且处理方式也与组件的类型有很大的关联。所以此处将删除操作在捕获时即执行,一是因为删除操纵本身与 EffectTag 类型、组件类型无关,而是因为提前处理能够避免 DOM 操作的混乱性。
# commitLayoutEffects
// completeWork插槽
if ((fiber.flags & LayoutMask) !== NoFlags) {
const current = fiber.alternate;
// ......
commitLayoutEffectOnFiber(root, current, fiber, committedLanes);
}
2
3
4
5
6
分析如下:
- 在捕获阶段执行
commitLayoutEffectOnFiber
,但需使 fiber 上具有LayoutMask
标记。
我们可能已经注意到,在上述 commitBeforeMutationEffectsOnFiber
、 commitMutationEffectsOnFiber
函数中,都没有 EffectTag 的标记要求,而 commitLayoutEffectOnFiber
却要求 fiber 具有 LayoutMask
标记。事实上 MutationMask
、 BeforeMutationMask
或者 LayoutMask
都是 EffectTag 的集合:
// Groups of flags that are used in the commit phase to skip over trees that
// don't contain effects, by checking subtreeFlags.
export const BeforeMutationMask = Update | Snapshot | ChildDeletion | Visibility;
export const MutationMask = Placement | Update | ChildDeletion | ContentReset | Ref | Hydrating | Visibility;
export const LayoutMask = Update | Callback | Ref | Visibility;
2
3
4
5
通过判断 subtreeFlags 上是否有这个 EffectTag 的组合,可以使捕获和冒泡的过程能够进行的针对需要进行操作的节点进行处理。
# 扩展
# commitRootImpl
三次执行 flushPassiveEffects
有何含义?
首先需要明确的是, flushPassiveEffects
是与 useEffect
所产生的的副作用是密切相关的,在发生一次 “渲染” 的前后,需要对这种 Passive
的副作用进行处理,包括执行副作用的销毁函数和副作用函数。
所谓副作用,是在某些触发渲染的时机上执行 DOM 操作、网络请求、日志打印、事件订阅、定时任务等行为。副作用是不能直接在组件主体(函数式组件)中执行的,因为 React 无法保证副作用在执行时所处的环境是正确的。事实上,在函数主体执行时,React 还处于调和阶段,并没有 Commit
下一次的渲染,况且直接在函数体中执行的副作用会极大的阻塞调和的效率(参照前文内容)。
因此,在函数式组件的主体函数中应当遵循 “只定义组件的特性和行为方式(包括视图的定义、事件回调的定义、副作用的定义、状态和计算属性的定义,因此可以把函数式组件分成 Render
、 Callback/Handler
、 Effect
和 Props/State
四个部分),不进行高成本的计算 “的原则,“JSX” 本质上是一种动态的描述和定义组件的语法糖。组件的状态和行为的变化应该由副作用和事件机制来推动。(注意:副作用虽然可以用来模拟类似于类组件的生命周期,但是其理念是极为不同的,相比而言,副作用是函数式编程的产物,要更为灵活和高效,按照生命周期的想法去编写函数式组件是错误的,React 中提供的副作用钩子并不能准确的翻译 / 转化为类组件各种生命周期!参见 Hook 会因为在渲染时创建函数而变慢吗? (opens new window)。)
回归整体, commitRootImpl
函数中共有三次 flushPassiveEffects
的调用:
// [1]
do {flushPassiveEffects()} while (rootWithPendingPassiveEffects !== null);
// [2]
scheduleCallback(NormalSchedulerPriority, () => flushPassiveEffects());
// [3]
if (includesSomeLane(pendingPassiveEffectsLanes, SyncLane)) flushPassiveEffects();
2
3
4
5
6
7
8
- 第一次执行
flushPassiveEffects
是在Commit
三大步骤未开始之前执行的,目的是判断是否有残留的passiveEffects
(useEffect 的副作用) 未执行,如果有则调用flushPassiveEffects
处理之。之所有用while
循环是因为副作用有可能产生新的副作用(注意:在函数式组件中,应该避免副作用之间相互调用以免造成死循环)。 - 第二次执行
flushPassiveEffects
是在Commit
三大步骤未开始之前请求一次flushPassiveEffects
的调度,从前文中我们已经知道,Commit
步骤中会阻塞Render
过程,但是并不会阻塞调度过程,因此,此次flushPassiveEffects
调度能够保证其执行时间能够在本次Commit
阶段之后,也就是说传给 useEffect 的函数会在浏览器完成布局与绘制之后,在一个延迟事件中被调用
,参见 effect 的执行时机 (opens new window)。 - 第三次执行
flushPassiveEffects
是在root.finishedLanes
中有同步的 lanes 的时机下,这说明本次副作用的处理的优先级较高,需同步执行。从 React 18 开始,当它是离散的用户输入(如点击)的结果时,或者当它是由 flushSync 包装的更新结果时,传递给 useEffect 的函数将在屏幕布局和绘制之前同步执行
,参见 effect 的执行时机 (opens new window)。
重点提示
flushPassiveEffects
的执行是异步的,是通过调度器的调度完成的,但是可以保证相关的副作用是在当前Commit
之后,也即视图更新渲染之后执行。useEffect
中的副作用的执行时机是:Commit
完成之后进入Batch
阶段中的某一次调度器的回调中,进一步将就是,屏幕视图布局和绘制之后的一个延迟事件中。useEffect
中副作用的执行是异步的。useEffect
中的副作用也可以同步的执行(useLayoutEffect
中副作用执行之后,在Commit
完成之时),当然这需要特殊的条件。
# 问题
# 为什么在 layout
阶段之后需要 requestPaint
?
提示
By default, the browser will wait until the current thread of execution finishes and do one consolidated reflow and repaint (as this is considered more efficient than doing many reflows and repaints). This is not specified in any specification so the browser can implement as it wants to.
But, there are some specific operations that will generally trigger a reflow (and sometimes a corresponding repaint). These operations are operations (requesting certain properties related to the position of elements) which can only be completed when an accurate reflow has been done. So, it is possible to manually trigger a reflow by requesting one of these properties.
参见:html - When does the DOM repaint during Javascript routines? - Stack Overflow (opens new window)
默认情况下,浏览器会在当前线程执行完成之后,执行一次合并的 reflow
和 repaint
。但是也存在一些特殊的情况会触发 reflow
,这就是执行那些只有在 reflow
之后才能完成的任务。因此,操纵 JavaScript 执行 DOM 操作并不是会立即促使浏览器进行 repaint
,这其中浏览器有诸多优化措施以保证 reflow
和 repaint
高效的进行。
# 总结
本文中最最要的内容是对 commitRoot
函数的分析。 commitRoot
是 Commit
阶段提纲挈领的函数,它将整个 Commit
的过程分成了最要的三个步骤,分别是 beforeMutation
、 mutation
和 layout
。这三个步骤分别调用 commitBeforeMutationEffects
、 commitMutationEffects
和 commitLayoutEffects
函数进行处理,总结其特性分别是 commitBeforeMutationEffects
提供组件更新之前的实例数据, commitMutationEffects
操作原生 JavaScript 更新 DOM 节点,处理组件销毁前的副作用, commitLayoutEffects
处理组件挂载后(更新后)的副作用。
另外一个重点是, workInProgress
树切换为 current
树的过程是在 mutation
之后 layout
之前完成的。三个步骤完成之后需要通知调度器进行 yield (中断 Render
阶段的执行),从而使浏览器有足够的时间对 mutation
阶段造成的 DOM 更新进行重排(reflow)和重绘(repaint)。
上述三个步骤追根究底还是在 EffectList
(沿用旧版本的概念) 的遍历过程中执行的,其总体过程可归纳为 “跳跃式的”(可精准的针对有相关 EffectTagMask
的节点进行操作)“捕获与冒泡” 的过程,其内部逻辑同样可抽象为 beginWork
和 completeWork
的工作。上述三个步骤其实质是要执行 commitBeforeMutationEffectsOnFiber
、 commitMutationEffectsOnFiber
和 commitLayoutEffectOnFiber
函数。