将 React 组件呈现到页面上需要经历下面三个步骤:
- 触发渲染
- 渲染组件
- 提交到 DOM
触发渲染
以下两种情况会触发渲染:
- 初次渲染
- 组件(或者组件的祖先)状态发生改变
import { createRoot } from "react-dom/client";
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
// 状态改变触发再次渲染
setCount(count + 1);
}
return <button onClick={handleClick}>You pressed me {count} times</button>;
}
const root = createRoot(document.getElementById("root"));
// 初次渲染
root.render(<Counter />);
安排渲染任务
触发渲染后,React 并没有立即开始渲染工作,而是将渲染任务做了计划,具体何时执行需要听从调度。
我们来看一看 React 大致是如何处理的。
触发渲染后总会进入scheduleUpdateOnFiber
函数:
export function scheduleUpdateOnFiber(
root: FiberRoot,
fiber: Fiber,
lane: Lane,
eventTime: number
) {
ensureRootIsScheduled(root, eventTime);
}
然后进入ensureRootIsScheduled
函数:
// Use this function to schedule a task for a root.
function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
// Determine the next lanes to work on, and their priority.
const nextLanes = getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes
);
// We use the highest priority lane to represent the priority of the callback.
const newCallbackPriority = getHighestPriorityLane(nextLanes);
// 判断优先级
if (includesSyncLane(newCallbackPriority)) {
// 优先级高
// Special case: Sync React callbacks are scheduled on a special internal queue
scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
if (supportsMicrotasks) {
// 如果支持微任务
// Flush the queue in a microtask.
} else {
// 调用scheduler package的API以高优先级安排任务
scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks);
}
} else {
// 优先级不高
// 调用scheduler package的API以其他优先级安排任务
scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root)
);
}
}
我们来看看这个函数大致做了什么:
- 确定 Lanes 和优先级(Lanes 模型是 React 内部调度的一个重要模型,暂时不深入了解)
- 优先级很高:安排为同步回调,回调函数是
performSyncWorkOnRoot
- 如果支持微任务,会在微任务中来调用回调函数,源码中使用微任务可能通过
queueMicrotask | Promise
等方式(主流浏览器都支持 queueMicrotask) - 否则使用 scheduler 的 API 来实现回调
- 如果支持微任务,会在微任务中来调用回调函数,源码中使用微任务可能通过
- 优先级不高:安排为异步回调,回调函数是
performConcurrentWorkOnRoot
- 使用 scheduler 的 API 来实现回调
- 优先级很高:安排为同步回调,回调函数是
scheduler
scheduler
是 React 中一个用于任务调度的包,现在仅在 React 中使用,但是完全可以独立出来作为通用调用算法。每一个任务都有优先级,将任务放在最小堆中,每次取出优先级最高的任务执行,执行任务是通过MessageChannel
的端口发送和监听消息来完成的,属于事件循环中的任务。
为什么选择 MessageChannel
为了实现 0ms 延时的定时器,setTimeout(fn, 0)
无法做到零延时,更多可以了解 MDN 上的解析。
渲染阶段
两种情况下 React 在渲染阶段做的工作:
- 初次渲染:创建 DOM 节点
- 再次渲染:计算与上一次渲染之间的差异,此阶段不会使用差异信息做实际性修改,那是下一个阶段的工作
在上面一个章节我们看到 React 为渲染安排了回调:performSyncWorkOnRoot
或performConcurrentWorkOnRoot
,它们的主要调用流程如下:
function performSyncWorkOnRoot(root: FiberRoot) {
renderRootSync(root);
root.finishedWork = root.current.alternate;
}
function performSyncWorkOnRoot(root: FiberRoot) {
// We disable time-slicing in some cases: if the work has been CPU-bound
// for too long ("expired" work, to prevent starvation), or we're in
// sync-updates-by-default mode.
const shouldTimeSlice =
!includesBlockingLane(root, lanes) &&
!includesExpiredLane(root, lanes) &&
(disableSchedulerTimeoutInWorkLoop || !didTimeout);
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes);
root.finishedWork = root.current.alternate;
}
他们调用的renderSyncRoot | renderRootConcurrent
函数中最主要的就是工作循环。
function renderRootSync(root: FiberRoot, lanes: Lanes) {
// If the root or lanes have changed, throw out the existing stack
// and prepare a fresh one. Otherwise we'll continue where we left off.
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
prepareFreshStack(root, lanes);
}
workLoopSync();
}
function renderRootSync(root: FiberRoot, lanes: Lanes) {
// If the root or lanes have changed, throw out the existing stack
// and prepare a fresh one. Otherwise we'll continue where we left off.
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
prepareFreshStack(root, lanes);
}
workLoopConcurrent();
}
工作循环
下面是这两种工作循环的代码。
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
workInProgess
workInProgress
就是当前需要处理的 Fiber,可以调用下面的函数创建它:
// This is used to create an alternate fiber to do work on.
function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
let workInProgress = current.alternate;
if (workInProgress === null) {
// We use a double buffering pooling technique because we know that we'll
// only ever need at most two versions of a tree. We pool the "other" unused
// node that we're free to reuse. This is lazily created to avoid allocating
// extra objects for things that are never updated. It also allow us to
// reclaim the extra memory if needed.
workInProgress = createFiber(
current.tag,
pendingProps,
current.key,
current.mode
);
} else {
}
return workInProgress;
}
我们从 React 源码中的注释中看到这个函数使用了双缓冲池,如果current.alternate === null
时才创建新的 Fiber,否则可以重用之前创建的 Fiber。
那么进入工作循环的第一个workInProgress
是什么呢?
答案是:renderRootSync
或renderRootConcurrent
中调用prepareFreshStack
创建。
function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
const rootWorkInProgress = createWorkInProgress(root.current, null);
workInProgress = rootWorkInProgress;
}
这里FiberRoot
类型的root
参数是哪里来的呢?
答案是:调用 react-dom package 的 Client API createRoot(domNode, options?)
时创建的,reactDOMRoot._internalRoot
就是FiberRoot
,FiberRoot.current
是 Fiber 节点,我们称它为 HostRoot。
两种工作循环的差别
它们的唯一区别是在调用performUnitOfWork
前是否判断shouldYield()
以便让出主线程,这个 API 是 scheduler package 提供的,它的精简代码如下:
function shouldYieldToHost() {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// The main thread has only been blocked for a really short amount of time;
// smaller than a single frame. Don't yield yet.
return false;
}
return true;
}
贴士:源码中本来还有更多判断,例如使用 Facebook 和 Chrome 合作的 API navigator.scheduling.isInputPending()
,但是由于 React 目前默认没有开启这个功能,所以代码精简了。
如果占用主线程时间超出frameInterval
,那么就需要让出主线程,frameInterval
的初始值是 5ms。
performUnitOfWork
如果满足工作循环的判断,那么就会进入performUnitOfWork
,下面是这个函数的精简版本:
function performUnitOfWork(unitOfWork) {
let next = beginWork(unitOfWork);
if (next === null) {
// If this doesn't spawn new work, complete the current work.
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
这个函数非常好理解:
- 调用
beginWork
- 如果没有返回下一个 Fiber,那么就调用
completeUnitOfWork
- 否则让
workInProgress
指向下一个 Fiber,进入下一次工作循环。
- 如果没有返回下一个 Fiber,那么就调用
beginWork
我们暂时不深入了解这个函数,现在我们仅仅需要关注它的返回值,它始终返回workInProgress.child
或者null
,值得注意的是workInProgress.child
也可能是null
。
completeUnitOfWork
如果 beginWork 返回 null,意味着这个分支已经没有需要处理的 Fiber 了,那么就可以完成当前这个 Fiber,然后可以接着处理它的兄弟节点,然后返回父节点。
下面是这个函数的精简版本:
function completeUnitOfWork(unitOfWork) {
// 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 {
const next = completeWork(unitOfWork);
if (next !== null) {
// Completing this fiber spawned new work. Work on that next.
workInProgress = next;
return;
}
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// If there is more work to do in this returnFiber, do that next.
workInProgress = siblingFiber;
return;
}
// Otherwise, return to the parent
completedWork = completedWork.return;
// Update the next thing we're working on in case something throws.
workInProgress = completedWork;
} while (completedWork !== null);
}
渲染阶段结束
到这里渲染阶段就结束了,可以看到实际工作是在 beginWork 和 completeWork 中完成的,但是我们目前还没有深入了解这两个函数。
可能到这里我们有一个疑问,渲染阶段到底产出了什么呢?
答案是:生成了一个“全新”的 Fiber tree,之所以加引号,是因为并非所有的 Fiber 都是新创建的,可能是重用了之前的 Fiber,其中的 Fiber 有可能还标记了副作用。
FiberRootNode.finishedWork
还指向了这个新的 Fiber tree,这在下一个阶段中有使用到。
什么是副作用
副作用这个词从字面上很难理解,React 文档中有一些关于副作用的解释。在计算机科学中,副作用表示对于函数外的变量,修改了参数等等,例如在事件处理函数中更改状态,发送 http 请求,导航到其他页面等等都是副作用。我们熟知的 Hook 还有类组件的一些生命周期方法都是副作用。
还记得我们之前提到渲染阶段必须是纯函数,不能有任何副作用,否则 UI 将不受控制,所以在渲染阶段只是将副作用标记在 Fiber 上,等进入提交阶段再执行副作用。
Fiber 对象中有一些属性就是专门为副作用设置的:
{
// Effect
flags: Flags,
subtreeFlags: Flags,
deletions: Array<Fiber> | null,
// Singly linked list fast path to the next fiber with side-effects.
nextEffect: Fiber | null,
// The first and last fiber with side-effect within this subtree. This allows
// us to reuse a slice of the linked list when we reuse the work done within
// this fiber.
firstEffect: Fiber | null,
lastEffect: Fiber | null,
}
flags
中就保存了副作用的 flag,例如Placement | Update | ChildDeletion
,值得注意的是flags
中可能保存了很多副作用。
原来的设计中,属性nexeEffect
使得有副作用的 Fiber 可以串联成一个链,但是后来不再使用nextEffect | firstEffect | lastEffect
,而是去遍历整颗树。
提交阶段
提交阶段可以拆分成下面几个子阶段:
- before mutation 阶段
对 host tree(例如 DOM 树)做出修改前,例如类组件的getSnapshotBeforeUpdate
在这个阶段被调用。 - mutation 阶段
插入,修改,删除 DOM 节点等等。 - layout 阶段
修改 host tree 后,在浏览器进行绘制前,例如类组件的componentDidMount | componentDidUpdate
在这个阶段被调用。
提交阶段主要包括在commitRoot
函数中,它的精简版本如下:
function commitRoot(root: FiberRoot) {
const finishedWork = root.finishedWork;
// 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.
commitBeforeMutationEffects(root, finishedWork);
// The next phase is the mutation phase, where we mutate the host tree.
commitMutationEffects(root, finishedWork);
// 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.
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.
commitLayoutEffects(finishedWork, root, lanes);
}
到这里渲染和提交阶段就结束了。