React 源码漂流记:React 调和器核心源码解读(六)
# 目录
# 前言
在上文中,我们探讨了 React 调和过程中捕获的详细过程和原理。在整体上而言,捕获过程分成两个步骤,“调和当前节点” 和 “调和子节点”,“调和当前节点” 就是对 workInProgress
的节点( IndeterminateComponent
、 LazyComponent
、 FunctionComponent
、 ClassComponent
等)进行调和,不同的组件类型有不同的调和策略,“调和子节点” 就是对将 JSX
榨取出的 ReactElement
作为子节点进行调和,这其中大致又可分成两个步骤,即所谓 “调和” 和 “置位”,其中调和就是针对旧节点是否可复用而采取复用或者新建的方式获取子节点,应用最新的 state 和 props 等,最新获取整个调和完毕的 FiberTree,另外,“置位” 就是针对子节点标记副作用,这对本文或者后文中所述的副作用收集、DOM 的更新至关重要。
在本文中,我们回转头来,从 “捕获过程” 的细节万花筒的浮出,继续深入探索 React 调和中的 “冒泡过程”。
还记得 “捕获过程” 转换到 “冒泡过程” 的契机在哪里吗?
在 performUnitOfWork (opens new window) 函数的分析中,有如下的关键代码:
const next = beginWork(current, unitOfWork, subtreeRenderLanes);
if (next === null) {
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
2
3
4
5
6
在前文中我们已经知道,在捕获过程中,如果深入到叶子节点(没有子节点的节点)或者是因无更新而提前结束捕获过程时,都是将 next
悬空并返回,因此在函数 performUnitOfWork
中如果遇到 next 指针悬空的情况,则意味着捕获过程暂时结束了,可以进行冒泡了。 unitOfWork
本身就是 workInProgress
的节点。因此 completeUnitOfWork
函数即可看做是冒泡过程的入口。而本文的探讨也将由此展开。
注意
由捕获转到冒泡并不意味着不会再进行捕获。事实上,捕获冒泡的过程是对 FiberTree 的遍历的过程,因此,在冒泡时(优先遍历 sibling 节点,return 节点次之)遇到新的非叶子结点时,会再次转换到捕获过程。更多细节可参考如何理解 WorkLoop? (opens new window)。
# completeUnitOfWork
此函数在前置工作 beginWork
之后处理当前节点(workInProgress)的后置工作,即 completeWork
。同时与 performUnitOfWork
配合,共同完成 FiberTree 的遍历过程。
// src/react/packages/react-reconciler/src/ReactFiberWorkLoop.new.js
function completeUnitOfWork(unitOfWork: Fiber): void {
// Attempt to complete the current unit of work, then move to the next
// sibling. If there are no more siblings, return to the parent fiber.
let completedWork = unitOfWork;
do {
// 从 workInProgress Fiber 的 alternate 指针获取到 current Fiber
const current = completedWork.alternate;
// 从 workInProgress Fiber 的 return 指针获取到父节点
const returnFiber = completedWork.return;
// 检查 flags 上是否有 Incomplete 标记,无此标记即为没有未完成的工作(没有中断或者抛出错误)
if ((completedWork.flags & Incomplete) === NoFlags) {
// ......
// completeWork 在当前节点上完成工作,并且返回下一个节点(大多数情况下 next 都是 null)
const next = completeWork(current, completedWork, subtreeRenderLanes);
// workInProgress 指向下一个节点
if (next !== null) {
workInProgress = next;
return;
}
} else {
// Fiber 上前述任务未完成,可能有异常抛出,此时清理相关堆栈,进行错误冒泡捕获
// unwindWork 在少数清空下回抛出下个任务,如果抛出则继续执行此任务
// 注意,这里 return 后续才有可能有捕获过程
const next = unwindWork(completedWork, subtreeRenderLanes);
if (next !== null) {
next.flags &= HostEffectMask;
workInProgress = next;
return;
}
// ......
// 因当前 returnFiber 为 Incomplete 状态,因此添加相关标记
if (returnFiber !== null) {
// Mark the parent fiber as incomplete and clear its subtree flags.
returnFiber.flags |= Incomplete;
returnFiber.subtreeFlags = NoFlags;
returnFiber.deletions = null;
}
}
// 有兄弟节点时,移动到兄弟节点(有可能 beginWork 或者 completeWork)
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
workInProgress = siblingFiber;
return;
}
// 无兄弟节点时移动到父节点,将 completedWork 和 workInProgress 指针均移动到父节点
// 注意:这里并没有 return,这是因为父节点不需要重复捕获,继续冒泡即可,等移动到父节点的兄弟节点在考虑是否需要捕获
completedWork = returnFiber;
workInProgress = completedWork;
} while (completedWork !== null);
// 只要有兄弟节点或者父节点,上述循环就不会停止,如果退出循环,说明到达根节点
// 将退出状态改为 `RootCompleted`
if (workInProgressRootExitStatus === RootIncomplete) {
workInProgressRootExitStatus = RootCompleted;
}
}
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
分析如下:
- 前文中在
performUnitOfWork
中不断向子节点深入,completeUnitOfWork
中通过循环不断向兄弟节点扩展,这充分体现了 DFS 的原理。其中从代码逻辑上,也可以看出控制child
、sibling
、return
三个指针的移动也是遵循一定的优先级的,DFS 所要求的优先级正是:child
->sibling
->return
。 - 在当前节点执行
completeUnitOfWork
时,会根据节点上是否有Incomplete
标记(是否 beginWork 的前置任务未完成),选择不同的策略。正常情况下,执行completeWork
来完成节点上后续任务。 - 当 WorkLoop 遍历完毕返回到根节点时,将退出状态改成
RootCompleted
。此状态将在renderRootSync
或者renderRootConcurrent
函数中被返回。在函数performSyncWorkOnRoot
或者performConcurrentWorkOnRoot
中,结束Render
阶段进行Commit
阶段。 workInProgressRootExitStatus
表示workInProgress
的RootFiber
在退出时的状态。WorkLoop 所展开的 “捕获与冒泡” 的遍历过程,最终会返回到根节点,此时整个过程执行的状态就标记于此。
扩展
RootExitStatus 有如下的状态:
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5;
const RootIncomplete = 0; // 任务尚未完成
const RootFatalErrored = 1; // 发生致命错误
const RootErrored = 2; // 发生错误
const RootSuspended = 3; // 任务被暂停
const RootSuspendedWithDelay = 4; // 任务被延迟
const RootCompleted = 5; // 任务已经完成
2
3
4
5
6
7
# completeWork
从 completeUnitOfWork
函数中,我们已经获知, completeWork
事实上是为了完成 workInProgress
Fiber 节点上的后置工作。那么具体要哪些工作呢?我们继续往下看:
function completeWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
const newProps = workInProgress.pendingProps;
// ......
switch (workInProgress.tag) {
case IndeterminateComponent:
case LazyComponent:
case SimpleMemoComponent:
case FunctionComponent:
case ForwardRef:
case Fragment:
case Mode:
case Profiler:
case ContextConsumer:
case MemoComponent:
bubbleProperties(workInProgress);
return null;
case ClassComponent: {
// ......
bubbleProperties(workInProgress);
return null;
}
case HostRoot: {
// ......
bubbleProperties(workInProgress);
return null;
}
case HostComponent: {
// ......
bubbleProperties(workInProgress);
return null;
}
case HostText: {
// ......
bubbleProperties(workInProgress);
return null;
}
case SuspenseComponent: /*......*/
case HostPortal:
// ......
bubbleProperties(workInProgress);
return null;
case ContextProvider:
// ......
bubbleProperties(workInProgress);
return null;
case IncompleteClassComponent: {
// ......
bubbleProperties(workInProgress);
return null;
}
case SuspenseListComponent: /*......*/
case ScopeComponent: /*......*/
case OffscreenComponent:
case LegacyHiddenComponent: /*......*/
case CacheComponent: /*......*/
}
// ......
}
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
由上大致可以看出,最重要的内容是要执行 bubbleProperties
函数。其余细节不在赘述。
# bubbleProperties
此函数的目的是通过遍历节点下的下级子节点以收集节点的 childLanes
和 subtreeFlags
。结合整个冒泡过程来看,这实际上从 FiberTree 的底部向上冒泡以不断的更新属性。 childLanes
和 subtreeFlags
本别对应着节点的优先级和服副作用标记,都是极其重要的属性。
function bubbleProperties(completedWork: Fiber) {
// 判断是否是根据 bailoutOnAlreadyFinishedWork 结束 beginWork 的
// child 相等说明复用了子树,见 cloneChildFibers
const didBailout =
completedWork.alternate !== null &&
completedWork.alternate.child === completedWork.child;
// ......
let newChildLanes = NoLanes;
let subtreeFlags = NoFlags;
let child = completedWork.child;
while (child !== null) {
newChildLanes = mergeLanes(
newChildLanes,
mergeLanes(child.lanes, child.childLanes),
);
subtreeFlags |= child.subtreeFlags;
subtreeFlags |= child.flags;
child.return = completedWork;
child = child.sibling;
}
completedWork.subtreeFlags |= subtreeFlags;
completedWork.childLanes = newChildLanes;
return didBailout;
}
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
参见如下分析:
didBailout
是判断是否是通过bailout
(中文意思是 “保释”) 的方式退出捕获过程的,bailout
的方式和正常方式在此处有细微的区分,但是可以忽略。
# Render
阶段的终结
Render
阶段的终结,就意味着 Commit
阶段的开始。我们已经知道了一个正常的(忽略错误情况)渲染周期包括 Batch
阶段、 Render
阶段和 Commit
阶段,也就是所谓的 ExecutionContext
。 Batch
阶段主要是接受更新和调度请求, Render
阶段主要是对调度的回调做出反应,开启渲染(调和)的过程, Commit
阶段对 Render
阶段的调和结果进行确认,并且将更新落实到不同的 Host
宿主环境中,如 Web 环境、SSR 环境。
React 应用生命的大部分时间都是处于 batch
阶段,只有少部分时间切片分配给了 Render
阶段,并且 Render
阶段还可以被调度器通过 yield
的方式打断,极少部分的时间处于 Commit
阶段,且 Commit
阶段是不可打断的。这种情况也符合视图渲染框架的要求预期,因为 Batch
阶段才是最稳定的,是常态的; Render
的过程在调度器回调后产生,渲染的频率应当是高效且可控的,要把握更新频率和性能损耗的一个平衡(这里可以理解为刷新率的概念,后文我们将在调度器中体会这一点),最后 Commit
阶段是最不稳定的,因为这里涉及到 workInProgress
FiberTree 和 current
FiberTree 的交换过程,应当确保这个过程尽快完成,以免影响到页面渲染的平滑性。可以参考下图加深理解:
本文分析到这里, Render
阶段大致就告一段落,更深入的细节可以另行探讨,但是整体的调和的脉络必须向前推进了。下面我们在来重温下此两阶段切换的细节。在 performSyncWorkOnRoot
和 performConcurrentWorkOnRoot
函数(参见:React 调和器核心源码解读(二) (opens new window))中有如下代码:
// performSyncWorkOnRoot
let exitStatus = renderRootSync(root, lanes);
// performConcurrentWorkOnRoot
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes);
const finishedWork: Fiber = (root.current.alternate: any);
root.finishedWork = finishedWork;
root.finishedLanes = lanes;
// performSyncWorkOnRoot
commitRoot(root);
// performConcurrentWorkOnRoot
finishConcurrentRender(root, exitStatus, lanes);
2
3
4
5
6
7
8
9
10
11
12
13
14
我们已经知道了,上述两个函数实际上控制着 Render
过程和 Commit
过程,在 Render
结束之后,最重要的是产生了 finishedWork
和 finishedLanes
,分别代表着调和完毕的 workInProgress
FiberTree 和 lanes(优先级),在后文的 Commit
过程中,将会对此做进一步的处理。
另外还有一点需要注意的是,React 整体的生命周期(注意,这里不是指组件的生命周期,而是指上述的三个阶段)的控制是在 root
,即 FiberRoot
容器上控制的,这也是由双缓存结构所决定的,因为 FiberRoot
上 current
和 finishedWork
指针分别指向两棵 FiberTree。相对应的 current
节点和 workInProgress
节点之间是通过 alternate
指针相连接的。
在下一篇文章中,我们将继续探讨 Commit
阶段的原理,拭目以待吧。
# 扩展
# bubbleProperties
中为什么要收集 subtreeFlags
?
在 React 旧版本中,在捕获时会将 Fiber 节点上的 EffectTag 进行收集,形成 Effect List
链表,因此,在 Commit
时,只需遍历 Effect List
链表,对链表中的副作用执行相应的 mutation
操作。在新版中 React 中去除了 Effect List
的概念,使用 subtreeFlags
的概念。 subtreeFlags
将子树的的 EffectTag
通过冒泡的方式收集到父节点(实际上是组件节点,参见 completeWork
函数)上,在 Commit
时,再根据收集到的 subtreeFlags
遍历子树,为子节点执行相应的 mutation
操作。
具体的细节可以参考:React Effects List 大重构,是为了他? (opens new window)。
# 问题
# 总结
本文主要讲述了 WorkLoop
过程中 “捕获” 与 “冒泡” 机理,以及在冒泡过程中所做的重要的后置工作。
completeUnitOfWork
函数的核心职责是控制冒泡的过程以及完成在冒泡过程中的后置工作。“冒泡” 是从当前节点向兄弟节点或者是父节点移动的过程,在整个WorkLoop
中会遍历到每一个节点,并且在节点上一次执行beginWork
和completeWork
。beginWork
执行的工作可称之为前置工作,主要是 Fiber 节点的调和和 EffectTag 的标记;completeWork
中执行的工作可称之为后置工作,主要是优先级和副作用的收集和更新。