1. 引入:界面是怎么更新的?
上面这个例子出自 React Conf 2017,List 组件渲染 numbers 数组中的元素,当点击按钮时数组中的元素变成原来的平方:
// main.tsx
function Item({ number }: { number: number }) {
return <div>{number}</div>
}
function List() {
const [numbers, setNumbers] = useState([1, 2, 3])
return (
<>
<button onClick={() => setNumbers(numbers.map((n) => n * n))}>^2</button>
{numbers.map((number, index) => (
<Item key={index} number={number} />
))}
</>
)
}
createRoot(document.getElementById('root')!).render(<List />)
我们知道,点击按钮时,会通过 setNumbers 更新 state 从而触发 List 组件的重新渲染(rerender),然后经过了一系列的过程,界面发生了更新。
但是从点击按钮到界面更新,这个过程具体发生了什么?
从宏观上说,React 的更新过程是这样的:
- Scheduler 根据任务的优先级进行调度,高优先级任务优先进入 Reconciler。对于上面的例子,就只有一个任务:点击按钮触发组件更新
- render 阶段:Reconciler 对于更新后的状态,得到新的 Fiber 树,并对旧的 Fiber 树和新的 Fiber 树进行对比(diff),为发生了改变的 Fiber 节点打上标记,表示该节点对应的真实 DOM 需要更新。对于上面的例子,需要更新的 Fiber 节点是 List 以及 2 和 3 对应的 Item 和 div(1 对应的 Item 不需要更新是因为 1 的平方还是 1,值没有变化)
- commit 阶段:Renderer 根据 Reconciler 生成的 Fiber 树上的标记,对真实 DOM 进行更新
Scheduler 之前有写文章讲过,Renderer 之后再讲,这篇文章主要介绍在 Render 阶段,Reconciler 做了什么。
2. Fiber 架构:Fiber 节点和 Fiber 树
从上面我们知道,在 render 阶段,Reconciler 的主要任务是:
- 更新 Fiber 树
- 为需要更新对应 DOM 的 Fiber 节点打上标记
在介绍 Reconciler 的具体更新流程之前,我们有必要先了解一下什么是 Fiber 节点和 Fiber 树。尽管听着很复杂,但其实很简单:Fiber 节点就是一个 js 对象,Fiber 树就是 Fiber 节点通过指针连接得到的树。
2.1. Fiber 节点
我们先来看看 Fiber 节点:
// ReactFiber.old.js
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
// Instance
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null;
// Fiber
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;
this.ref = null;
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
// Effects
this.flags = NoFlags;
this.subtreeFlags = NoFlags;
this.deletions = null;
this.lanes = NoLanes;
this.childLanes = NoLanes;
this.alternate = null;
}
属性很多,但全是很简单的数据结构,只是含义不清楚而已,我们一点点来看:
- instance 相关
// 节点类型相关
this.tag = tag; // 标记 Fiber 类型:函数组件/类组件/原生组件(如div、span...)等
this.key = key; // React 元素的 key
this.elementType = null; // 元素类型,大部分情况同 type
this.type = null; // 具体类型,如函数组件指函数本身,类组件指类本身,原生组件指 DOM 元素标签名
this.stateNode = null; // Fiber 节点对应的真实 DOM 节点引用
这里比较重要的属性是 type,对于 React 组件,它是组件本身(如函数组件指函数本身),这个我们之后会再提到。
另一个比较重要的属性是 stateNode,它记录了 Fiber 节点对应的真实 DOM 节点引用。之所以要记录真实 DOM,是为了在 commit 阶段能根据需要修改的 Fiber 节点找到对应的 DOM 节点,从而对 DOM 进行更新。
- Fiber 树结构
// 链表结构
this.return = null; // 父 Fiber
this.child = null; // 第一个子 Fiber
this.sibling = null; // 下一个兄弟 Fiber
this.index = 0; // 同级节点的位置索引
我们知道 Fiber 节点通过指针连接,从而形成 Fiber 树。实际上涉及到的指针就是这里的 return、child 和 sibling。我们之后会知道,通过这种链表结构,全局仅需要记录一个 workInProgress 指针,即可实现可中断 & 恢复的架构。
- 更新相关信息
// 组件的 props/state
this.pendingProps = pendingProps; // 新的 props
this.memoizedProps = null; // 已渲染的 props
this.updateQueue = null; // 更新队列
this.memoizedState = null; // 已渲染的 state
this.dependencies = null; // context/events 依赖
// 优先级相关
this.lanes = NoLanes; // 当前 Fiber 的优先级
this.childLanes = NoLanes; // 子树优先级
这部分我们主要关注 pendingProps、memoizedProps 和 updateQueue三个属性。
memoizedProps 和 pendingProps 分别是组件更新前后的 props,通过对比两者是否相同来决定是否需要更新 Fiber节点。
updateQueue 用来存储 Fiber 节点对应的 DOM 节点需要更新的属性以及更新的值,例如文章开头的例子,div2 对应的 updateQueue 就是 ['children', '4'],表示 div 的 children (也就是 innerText)需要更新为 4。
lanes 和 childLanes 两个属性涉及到 Lane 模型,这个之后再介绍,这里不看。
- 副作用标记信息
// 标记更新
this.flags = NoFlags; // 副作用标记
this.subtreeFlags = NoFlags; // 子树的副作用标记
this.deletions = null; // 需要删除的子节点
我们之前说过 Reconciler 会为发生更新的 Fiber 节点打上标记,这里的 flags 和 subtreeFlags 属性就是用来标记更新的。我们可以来简单看几个例子:
// ReactFiberFlags.js
export const NoFlags = /* */ 0b000000000000000000; // 表示不需要执行任何操作
export const Placement = /* */ 0b000000000000000010; // 需要插入
export const Update = /* */ 0b000000000000000100; // 需要更新
export const Deletion = /* */ 0b000000000000001000; // 需要删除
export const ChildDeletion = /* */ 0b000000000000010000; // 子节点需要删除
export const ContentReset = /* */ 0b000000000000100000; // 文本内容重置
export const Ref = /* */ 0b000000000001000000; // ref 更新
我们可以看到 flags 以 0b 开头,表示是二进制数。这种使用位来标记不同状态的方式可以节省内存,操作起来也更高效。比如:
// 检查是否包含某个标记
if (flags & Update) { ... } // 按位与
// 添加标记
flags |= Update; // 按位或。例如 Update | ChildDeletion 就表示 Fiber 节点需要更新和删除子节点
// 移除标记
flags &= ~Update; // 按位与非
通过这种方式,可以方便地为 Fiber 节点打上更新标记(通过按位或 |),也可以在 commit 阶段方便地判断需要对 Fiber 节点对应的 DOM 进行何种更新(通过按位与 &)。
而之所以还需要存储子树的标记 subtreeFlags,是因为这样在 commit 阶段可以快速判断是否需要对某个 Fiber 节点的子树进行更新,从而直接跳过不需要更新的子树,也就是剪枝:
// ReactFiberCommitWork.old.js
// commit 阶段快速判断是否需要处理子树
if (parentFiber.subtreeFlags & MutationMask) {
// 这个子树有需要处理的副作用
let child = parentFiber.child;
while (child !== null) {
commitMutationEffectsOnFiber(child, root, lanes);
child = child.sibling;
}
}
// ReactFiberFlags.js
export const MutationMask =
Placement |
Update |
ChildDeletion |
ContentReset |
Ref |
Hydrating |
Visibility;
上面这个例子中,只有 subtreeFlags 中包含 MutationMask(也就是包含 Placement、Update、ChildDeletion......中的任意一种标记)的时候,才会遍历子节点并执行 commitMutationEffectsOnFiber,否则就会直接跳过。
很多人写的 Reconciler 文章中会提到 effectTag 和 effectList。effectList 用来在 render 阶段收集所有的副作用,从而避免在 commit 阶段重新遍历整棵 Fiber 树,而是直接从 effectList 中取出副作用并执行更新。
但是后续的 React 代码中修改了这一部分:现在将 effectTag 重命名为了 flags,也删除了 effectList,所以现在 commit 阶段需要遍历整棵 Fiber 树来进行真实 DOM 的更新。
- 其他重要属性
this.alternate = null; // 另一个树对应的 Fiber,下面会讲到
this.mode = mode; // 渲染模式(同步/并发等)
2.2. Fiber 树
React 使用双缓存的技术更新界面,即存在最多两棵 Fiber 树。当前界面对应的 Fiber 树称为 current,正在内存中构建的称为 workInProgress,它们通过 alternate 属性连接:
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
在创建 Fiber 树时,还会创建 HostRoot(也叫 RootFiber),即整个应用的根 Fiber 节点。对于上面的例子,current 和 workInProcess 的 Fiber 树长这个样子:
接下来我们会基于这个例子讲解 Reconciler 更新 Fiber 树并为 Fiber 节点打上标记的具体流程。
3. Reconciler 的工作流程
3.1. 整体流程
render 阶段的起点是 performSyncWorkOnRoot 或 performConcurrentWorkOnRoot,这取决于本次更新是同步更新还是异步更新,它们分别会调用不同的方法:
// performSyncWorkOnRoot 会调用该方法
function workLoopSync() {
// 不可中断
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
// performConcurrentWorkOnRoot 会调用该方法
function workLoopConcurrent() {
// 可中断的 render
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
我们的例子(用户点击事件)是一个同步优先级任务,因此不走 Scheduler 的逻辑,直接调用 workLoopSync 一次全部执行完。
从 workLoopSync 开始,Reconciler 的处理流程如图所示:
该流程是一个递归结构:
- 递:首先从 HostRoot 开始向下深度优先遍历,为每个节点调用 beginWork 函数。该函数会更新当前节点的子节点。
- 归:当某个节点没有子节点时,为该节点调用 completeWork,之后对它的第一个 sibling 执行递阶段。如果该节点不存在 sibling,则对它的 return 执行归阶段。
比如对于这个例子:
执行的流程应该是:
1. HostRoot beginWork
2. List beginWork
3. button beginWork # button 没有子节点了,下面执行 button 的 completeWork
4. button completeWork # 之后对它的 sibling 执行 dfs
5. Item1 beginWork
6. div1 beginWork
7. div1 completeWork # div1 没有子节点,也没有 sibling,因此执行 return 的 completeWork
8. Item1 completeWork # 之后对它的 sibling 执行 dfs
9. Item2 beginWork
10. div2 beginWork
11. div2 completeWork
12. Item2 completeWork
13. Item3 beginWork
14. div3 beginWork
15. div3 completeWork
16. Item3 completeWork # Item3 没有子节点和 sibling,回到 return
17. List completeWork
18. HostRoot completeWork
我们可以看到,Reconciler 并没有使用递归,而是通过循环和链表结构,达成了 DFS 遍历 Fiber 树的效果。
3.2. performUnitOfWork
performUnitOfWork 的功能正如函数名一样,是执行一部分的工作,这里的最小单元就是 Fiber 节点:
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
let next = beginWork(current, unitOfWork, subtreeRenderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
ReactCurrentOwner.current = null;
}
在异步更新的情况下,这里会对当前 Fiber 节点执行 beginWork,如果有子节点,则让 workInProgress 指针指向子节点,然后结束,从而可以交出主线程,达到中断 render 的目的。
3.3. beginWork
beginWork 函数的作用是根据传入 Fiber 节点,更新其子节点。
所谓更新子节点,实际上就是要更新子节点的 pendingProps 和 flags。比如第二个 Item Fiber,在更新后 pendingProps 会从 { children: 2 }变为 { children: 4 },flags 会更新为 Update(workInProgress.flags |= Update)
为方便理解,我简化了 beginWork 的代码:
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// 判断是 mount 还是 update
if (current !== null) {
// update
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (oldProps !== newProps) {
didReceiveUpdate = true;
} else {
didReceiveUpdate = false;
}
} else {
// mount
didReceiveUpdate = false;
}
switch (workInProgress.tag) {
// 处理函数组件
case FunctionComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
}
// 处理原生组件(如 div、button 等 DOM 元素)
case HostComponent:
return updateHostComponent(current, workInProgress, renderLanes);
// ...省略其他类型
}
}
首先通过判断 current 是否为空,判断 work 是 mount 还是 update。如果是 mount 则不需要更新,因此 didReceiveUpdate 置 false;如果是 update,且 props 发生了改变,则需要更新,否则不需要更新。
接着根据当前 Fiber 节点的类型,调用不同的 handler 来更新 Fiber 的子节点。对于我们的例子,我们只需要关心函数组件和原生组件的情况。
实际上,关键的步骤发生在处理 List 时,这里会更新它的子节点(button 和 Item),从而更新 Item 的 pendingProps:
由于 List 是函数组件,会调用 updateFunctionComponent 函数,因此我们着重看一下这个函数:
// ReactFiberBeginWork.old.js
function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps: any,
renderLanes,
) {
let nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes,
);
// 如果不需要更新,则通过 bailout 跳过不必要的工作
if (current !== null && !didReceiveUpdate) {
bailoutHooks(current, workInProgress, renderLanes);
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
}
我们可以看到 updateFunctionComponent 接受的参数中包含 Component,看回 beginWork,我们发现传入的是函数组件 Fiber 的 type,也就是函数组件的函数,即 List 组件函数。而在 renderWithHooks 函数中,会通过:
let children = Component(props, secondArg);
的方式来调用 List 函数,实现组件的重新渲染,从而更新组件的 state,返回的 React Element 对象存储在 children 中。
在 reconcileChildren 函数中会通过 Diff 算法,将 current 和 workInProgress 进行比较,并根据传入的children React Element,最终得到更新后的子 Fiber 节点。这部分的逻辑较多,因此用一个图简单带过:
reconcileChildren
│
├──> reconcileChildFibers (更新)
│ │
│ ├──> case: REACT_ELEMENT_TYPE
│ │ └──> reconcileSingleElement
│ │
│ ├──> case: REACT_PORTAL_TYPE
│ │ └──> reconcileSinglePortal
│ │
│ ├──> case: REACT_LAZY_TYPE
│ │ └──> recursively call reconcileChildFibers
│ │
│ ├──> case: Array
│ │ └──> reconcileChildrenArray ───┐
│ │ │
│ └──> case: Iterator │
│ └──> reconcileChildrenIterator │
│ │
└──> mountChildFibers(First mount) |
│
▼
处理子节点的核心逻辑:
1. mapRemainingChildren (建立 key map)
2. placeChild (处理位置)
3. createChild (创建新节点)
4. deleteChild (删除旧节点)
5. updateElement (更新已有节点)
reconcileChildFibers 会根据 children 类型不同调用不同的 handler,最常见的是数组类型,会走到 reconcileChildrenArray。reconcileChildrenArray 是最核心的 Diff 算法实现,包含:
- 新旧节点的对比和复用
- 节点位置移动的处理
- 新增和删除节点
至此,beginWork 结束。我们在这部分以 List Fiber 为例,大致讲解了更新 List 的子 Fiber Item 的流程,实际上更新 div Fiber 的流程也是类似的。这部分总体流程如下:
beginWork
│
├──> 根据 workInProgress.tag 类型选择更新函数
│
├─────────────────┬───────────────┐
▼ ▼ ▼
FunctionComponent HostComponent 其他类型...
│ │ │
│ │ │
▼ │ │
renderWithHooks │ │
│ │ │
▼ ▼ ▼
└────────> reconcileChildren <────┘
│
▼
mountChildFibers/reconcileChildFibers
3.4. completeUnitOfWork
当某个 Fiber 节点没有 child 时,Reconciler 会通过 completeUnitOfWork 完成该 Fiber 节点:
function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
const current = completedWork.alternate;
const returnFiber = completedWork.return;
if ((completedWork.flags & Incomplete) === NoFlags) {
let next = completeWork(current, completedWork, subtreeRenderLanes);
// 如果当前 Fiber 节点产生了新的工作项
if (next !== null) {
workInProgress = next;
return;
}
} else {
// 处理错误恢复,fiber 未能完成渲染时的处理逻辑。这里忽略
}
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// 处理 sibling
workInProgress = siblingFiber;
return;
}
completedWork = returnFiber;
} while (completedWork !== null);
// 已经处理完所有的 Fiber,把 workInProgressRootExitStatus 置为 RootCompleted
if (workInProgressRootExitStatus === RootInProgress) {
workInProgressRootExitStatus = RootCompleted;
}
}
这部分代码的核心逻辑是:
- 调用 completeWork 完成该节点
- 如果该节点存在 sibling,则把 siblingFiber 赋值给 workInProgress 并返回,从而在下一个 workLoop 中对 sibling 节点调用 beginWork
- 如果不存在 sibling,则把 returnFiber 赋值给 completedWork,从而在下一个循环中对 return 节点调用 completeWork
- 当所有 Fiber 节点都处理完后,把 workInProgressRootExitStatus 置为 RootCompleted,表示 workInProgress 已经处理完毕,可以执行 commit 阶段
值得注意的是,这部分只有当处理 sibling 或 Fiber 节点产生新的工作项时才会返回,其余情况都会一直执行直到循环结束,从而也就无法中断。
3.5. completeWork
completeWork 函数会根据 workInProgress 的 tag 来调用不同的处理函数,这里仅以原生组件 HostComponent 为例,其余代码省略:
// ReactFiberCompleteWork.old.js
function completeWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
const newProps = workInProgress.pendingProps;
popTreeContext(workInProgress);
switch (workInProgress.tag) {
case HostComponent: {
popHostContext(workInProgress);
const rootContainerInstance = getRootHostContainer();
const type = workInProgress.type;
if (current !== null && workInProgress.stateNode != null) {
// 真实 DOM 节点存在,根据 pendingProps 更新
updateHostComponent(
current,
workInProgress,
type,
newProps,
rootContainerInstance,
);
if (current.ref !== workInProgress.ref) {
markRef(workInProgress);
}
} else {
// 不存在 stateNode,创建对应的 DOM 实例
const currentHostContext = getHostContext();
const instance = createInstance(
type,
newProps,
rootContainerInstance,
currentHostContext,
workInProgress,
);
appendAllChildren(instance, workInProgress, false, false);
workInProgress.stateNode = instance;
}
// 属性冒泡
bubbleProperties(workInProgress);
return null;
}
// ...其他类型省略
}
}
在 HostComponent 的处理逻辑中,首先会判断 Fiber 对应的真实 DOM 节点是否存在:
- 如果不存在则创建对应的 DOM 实例,并通过 appendAllChildren 将当前 Fiber 节点的所有子节点对应的 DOM 节点附加到新创建的父 DOM 节点 instance 上
- 如果存在则调用 updateHostComponent,根据 pendingProps(newProps)进行更新:
updateHostComponent = function(
current: Fiber,
workInProgress: Fiber,
type: Type,
newProps: Props,
rootContainerInstance: Container,
) {
const oldProps = current.memoizedProps;
if (oldProps === newProps) {
// props 没有发生改变,跳过更新
return;
}
const instance: Instance = workInProgress.stateNode;
const currentHostContext = getHostContext();
const updatePayload = prepareUpdate(
instance,
type,
oldProps, // 例: { children: 2 }
newProps, // 例: { children: 4 }
rootContainerInstance,
currentHostContext,
);
// 返回的 updatePayload: [ 'children', '4' ]
workInProgress.updateQueue = (updatePayload: any);
if (updatePayload) {
markUpdate(workInProgress);
}
};
updateHostComponent 函数会根据新旧 props 进行对比,生成需要更改的信息 updatePayload,并将其存储在 Fiber 节点的 updateQueue 中,从而为 commit 阶段提供更新的信息。
最后我们来看一下 bubbleProperties,这个函数是用来进行属性冒泡的,其核心逻辑如下:
// ReactFiberCompleteWork.old.js
let child = completedWork.child;
while (child !== null) {
subtreeFlags |= child.subtreeFlags;
subtreeFlags |= child.flags;
child.return = completedWork;
child = child.sibling;
}
completedWork.subtreeFlags |= subtreeFlags;
这段代码会对 completedWork Fiber 的子节点的 flags 属性进行冒泡,父 Fiber 会收集所有子节点的 flags 以及它们的子树的 flags subtreeFlags。通过这种方式,可以在 commit 阶段对遍历 Fiber 树的过程进行剪枝,从而加快更新真实 DOM 的过程。
4. 总结
对于我们文章开头给出的例子,从点击按钮到界面更新,Reconciler 做了以下事情:
- 检测到 setNumbers 触发的状态更新,进入 workLoop
- 通过 beginWork 遍历 Fiber 树,更新 Fiber 节点,为需要更新的节点打上标记,并把待更新的 props 作为 pendingProps
- 通过 completeWork 对比新旧 props,将更新内容存储在 updateQueue 中,并将属性冒泡至父节点
当这些内容都执行结束后,将带有更新标记的 Fiber 节点交给 commit 阶段,最终更新真实 DOM,实现界面更新。