接上篇 React Fiber 架构原理:关于 Fiber 树的一切 - 知乎
这篇讨论 Fiber 架构调度部分的实现原理,你将看到:
- Fiber 架构的调度能力的分层设计。
- Scheduler 的分片原理,以及调度器如何基于浏览器能力实现“空闲回调”和“时间管理”。
- Scheduler 中的任务是怎样注册管理、派发执行、定义优先级的。
- React 如何利用 Scheduler 实现“可中断更新”。
Part 0 背景
Fiber 架构是 React 一次伟大的革新。
React Fiber 是 React 核心算法的重新实现。 它的主要特点是渐进式渲染: 能够将渲染工作分割成块,并将其分散到多个帧。 其他关键特性包括在新的更新到来时暂停、中止或重用工作的能力; 为不同类型的更新分配优先级的能力; 以及新的并发方式。 ——GitHub - acdlite/react-fiber-architecture: A description of React’s new core algorithm, React Fiber
为了实现上述特性,Fiber 架构加入了关键的 Scheduler(调度器)。有了 Scheduler 这个“大脑”,React 在 setState 后不再直接启动“协调”过程,而是把本次更新注册到 Scheduler,再由 Scheduler 根据浏览器剩余空闲时间、优先级等因素派发给 Reconciler(协调器),并通过中断查询控制协调的中断重启。(协调就是我们说的包含 Diffing 的虚拟 DOM 构建计算过程,参考上篇)
编辑切换为全宽
添加图片注释,不超过 140 字(可选)
所以这样的 Scheduler 要支持哪些能力呢?
- 要能维护一个“任务池”
- 要提供一系列“优先级”的定义,并派发高优任务
- 要能感知浏览器的空闲,并根据剩余时间,随时给出“能不能继续工作”的建议
而 Reconciler 也要通过对 Scheduler 能力的调用,管理协调过程的发起、暂停、终止。
Part 1 调度的分层实现和调用链路
为了进一步看清 Scheduler 在 React 中的角色,这里有一张图解释整个调度的分层实现和调用链路。
编辑切换为居中
添加图片注释,不超过 140 字(可选)
React 源码是分包组织的,packages 下面有若干个相对独立的包,react-reconciler、scheduler 就是其中两个,包下面是具体文件模块,这里列举了四个关键的。
- 当我们调用 setState 之类的 api,组件会把状态入队后调 ReactFiberScheduler 注册本次修改。
- ReactFiberScheduler,主要做 Fiber 调度、协调管理这些事。比如注册调度并提供回调函数发起协调、管理当前协调的节点。这一层并不直接依赖 Scheduler 的 API(可能觉得不优雅?),而是 react-reconciler 内部的一个封装模块。
- SchedulerWithReactIntegration,就是那个封装模块,直接调 Scheduler,并把API改了改名字透传出来。
- Scheduler,是调度器最核心的实现,实现了Part0 中说的第1、2点能力。这里做了优先级定义、任务池维护/注册/取消、任务调度执行、中断判断。这些能力又依赖对宿主时间片的判断,什么时候空闲、剩多少时间。在早些版本的 Fiber 中宿主时间片也做在 Scheduler,后来拆出去了。
- SchedulerHostConfig,实现了宿主时间片部分,也就是 Part0 的第 3 点能力。
接下来我们按依赖顺序,自底向上,看看各层具体的实现方式。
Part 2 SchedulerHostConfig 宿主时间片判断
SchedulerHostConfig 要基于宿主(这里只谈浏览器)API,实现时间片管理。它要回答两个问题:
- 浏览器什么时候有空?空了叫我
- 此时此刻,我要不要让出线程给浏览器?
为什么要回答这些问题,怎么回答这些问题,就要从浏览器机制说起。
单线程JS 和浏览器帧
众所周知,浏览器里 JS 是单线程的。不但 JS 执行本身单线程,而且 JS 执行引擎和浏览器渲染引擎都挤在单个线程里。
好在大部分情况下,JS 执行、浏览器渲染都足够快,所以浏览器只要在单位时间内交替执行 JS 引擎和渲染引擎就好。这个单位时间叫做“帧(frame)”,目前主流的是60fps,每秒60轮,快到用户肉眼根本看不出来交替执行。一帧的生命周期如下:
编辑切换为居中
添加图片注释,不超过 140 字(可选)
\
但不排除某一帧下,触发了一个巨复杂的 js 逻辑,把当前帧事件耗完了还没跑完,甚至跨了几个帧都没跑完。那浏览器就拿他没办法,必须等他跑完才能做渲染,这样用户就发现:“哎,刚刚有段时间页面卡住不动了”。巧的是,一个很庞大的虚拟DOM树的 Diffing 就可能是这种卡帧的逻辑,所以必须在需要的时候“暂时”退出来,让浏览器先把这帧的渲染跑了,回头再继续跑。
再回到大多数情况。当 js 不那么复杂时,这一帧的 js 和渲染跑完后,是有剩余时间的。这时候浏览器就会通过某种方式通知出来。让我们知道:“浏览器现在有空闲了”。
浏览器的空闲回调
这时候大名鼎鼎的 requestIdleCallback 出场了。
window.requestIdleCallback()方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。 —— requestIdleCallback - Web API 接口参考 | MDN
看起来完美对不对,但它的兼容性堪忧:
编辑切换为居中
添加图片注释,不超过 140 字(可选)
\
所以 React 找了个替代品 —— MessageChannel。
Channel Messaging API 的MessageChannel 接口允许我们创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据。 —— MessageChannel - Web API 接口参考 | MDN
这是个 Full Support 的 API。
编辑切换为居中
添加图片注释,不超过 140 字(可选)
\
原理很简单。自己给自己发消息,相当于把球踢出去,等闲了再接回来。
基于 MessageChannel 的空闲回调实现
SchedulerHostConfig 实现了两个方法:
export let requestHostCallback; // 注册空闲回调
export let cancelHostCallback; // 取消空闲回调
首先全局定义了一个“回调池”,一个消息通道:
let scheduledHostCallback = null;
const channel = new MessageChannel();
const port = channel.port2;
那球怎么踢出去呢?把回调挂到“回调池”上,再发个空消息通知接球就好:
requestHostCallback = function(callback, absoluteTimeout) {
scheduledHostCallback = callback;
port.postMessage(undefined);
};
空闲的时候,消息通道的另一端去“回调池”捞出来执行,并传入当前回调是否已过期(didTimeout):
channel.port1.onmessage = function(event) {
const prevScheduledCallback = scheduledHostCallback;
if (prevTimeoutTime <= currentTime) {
didTimeout = true;
}
if (prevScheduledCallback !== null) {
prevScheduledCallback(didTimeout);
}
};
取消回调也很简单,清空“回调池”就好:
cancelHostCallback = function() {
scheduledHostCallback = null;
};
这里只列出了最关键的利用 MessageChannel 做回调的方式,实际情况要比这复杂得多:
- 可能当前帧已经不够执行回调,就需要挪到下一帧。为此上述代码引入了 requestAnimationFrame API,并声明了几个变量标记当前回调函数的执行方式。
- 考虑到时间,就要考虑“等不起”的回调,也就是满足 requestHostCallback 第二个参数 absoluteTimeout 的配置。及时计算回调的剩余时间(比如你挪到下一帧了就要减去浪费的时间),然后在拖无可拖的时候强制执行。所以又需要几个变量标记当前回调的时间信息。
总之都是围绕 MessageChannel 的,比如我们把 requestHostCallback 再丰富下:
requestHostCallback = function(callback, absoluteTimeout) {
scheduledHostCallback = callback;
if (isFlushingHostCallback || absoluteTimeout < 0) {
port.postMessage(undefined);
} else if (!isAnimationFrameScheduled) {
isAnimationFrameScheduled = true;
requestAnimationFrameWithTimeout(animationTick);
}
};
const animationTick = function(rafTime) {
if (!isMessageEventScheduled) {
isMessageEventScheduled = true;
port.postMessage(undefined);
}
};
在经过 requestAnimationFrame 调度后,还是回到 postMessage。
时间计算和 shouldYieldToHost
前面提到的时间变量还有个重要作用,就是标记帧结束时间,这对 shouldYieldToHost(是否让路宿主)至关重要。
let frameDeadline = 0;
let previousFrameTime = 33;
let activeFrameTime = 33;
const animationTick = function(rafTime) {
let nextFrameTime = rafTime - frameDeadline + activeFrameTime;
if ( nextFrameTime < activeFrameTime && previousFrameTime < activeFrameTime) {
activeFrameTime = nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime;
}
frameDeadline = rafTime + activeFrameTime;
// postMessage ...
};
这里的 frameDeadline 是一个具体的时间戳,表示当前帧的结束时间;activeFrameTime 是一个时间段,表示当前帧的长度,默认是 33(30fps)。
每次执行 animationTick,都会更新 frameDeadline 为 rafTime + activeFrameTime,其中 rafTime 就是 requestAnimationFrame 传入的,加上帧长,正好是下一帧的结束时间。同时 animationTick 也会根据两次 requestAnimationFrame 调用时间差,计算真实的帧长,修正 activeFrameTime。
到此为止,React 能相对准确获取到当前帧的结束时间戳。那判断 shouldYieldToHost 就顺理成章了:如果当前时间超过帧结束时间,说明已经卡到帧了,需要让出。
shouldYieldToHost = function() {
return frameDeadline <= getCurrentTime();
};
getCurrentTime = function() {
return Date.now();
};
小结
SchedulerHostConfig 实现了两个关键方法:
- requestHostCallback:注册一个在帧间空闲时间执行的回调函数(及其过期时间),并可以通过 cancelHostCallback 取消它。
- shouldYieldToHost:随时判断是否需要让出线程(避免卡帧)
Part 3 Scheduler 调度实现
SchedulerHostConfig 提供了空闲时回调和是否让出查询的能力,在 Scheduler 这一层,要做的事如下:
- 维护一个任务池
- 定义、应用优先级决定任务池的调用顺序
- 派发任务(调 requestHostCallback)
- 及时中断(在 shouldYieldToHost 时终止派发)
优先级定义
优先级对任务池结构、任务派发顺序至关重要,所以先从优先级说起,Scheduler 定义了五种优先级,以及它们对应的过期时间:
var ImmediatePriority = 1;
var UserBlockingPriority = 2;
var NormalPriority = 3;
var LowPriority = 4;
var IdlePriority = 5;
var maxSigned31BitInt = 1073741823;
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
var USER_BLOCKING_PRIORITY = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
var IDLE_PRIORITY = maxSigned31BitInt;
其中优先级定义会作为变量导出,而过期时间为内部计算任务派发顺序的参考。
任务池数据结构
接下来看任务池。Scheduler 任务池数据结构如下:
编辑切换为全宽
添加图片注释,不超过 140 字(可选)
- 每个任务都是一个节点,里面维护着三条信息:回调函数、优先级、过期时间
- 和 Fiber 树、effectList 等实现一样,任务池节点间也是通过链表(双向循环链表)组织的,next 指向下一个任务,previous 指向上一个任务
- 外界访问任务池的唯一入口,是一个全局指针 firstCallbackNode,指向任务池中第一个任务节点。无论任务注册时向任务池添加,还是派发任务时从任务池取,都要走这个指针。
- 链表从头到尾是按过期时间排序的,这个后面细说。
scheduleCallback:任务注册
我们打开 unstable_scheduleCallback 实现,这个函数传入一个回调函数和优先级,返回对应的任务节点。
任务注册过程也是根据新任务优先级插入任务池的过程:计算过期时间 —> 构造节点对象 —> 插入任务池。先看前两步:
var startTime = getCurrentTime();
var expirationTime;
switch (priorityLevel) {
case ImmediatePriority:
expirationTime = startTime + IMMEDIATE_PRIORITY_TIMEOUT;
break;
case UserBlockingPriority:
expirationTime = startTime + USER_BLOCKING_PRIORITY;
break;
case IdlePriority:
expirationTime = startTime + IDLE_PRIORITY;
break;
case LowPriority:
expirationTime = startTime + LOW_PRIORITY_TIMEOUT;
break;
case NormalPriority:
default:
expirationTime = startTime + NORMAL_PRIORITY_TIMEOUT;
}
var newNode = {
callback,
priorityLevel: priorityLevel,
expirationTime,
next: null,
previous: null,
};
比较简单,根据优先级在当前时间上加一个预设时间作为过期时间。然后构造任务节点。
接下来是把任务节点插到任务池里,这里是个链表的操作:
if (firstCallbackNode === null) {
firstCallbackNode = newNode.next = newNode.previous = newNode;
scheduleHostCallbackIfNeeded();
} else {
var next = null;
var node = firstCallbackNode;
do {
if (node.expirationTime > expirationTime) {
next = node;
break;
}
node = node.next;
} while (node !== firstCallbackNode);
if (next === null) {
next = firstCallbackNode;
} else if (next === firstCallbackNode) {
firstCallbackNode = newNode;
scheduleHostCallbackIfNeeded();
}
var previous = next.previous;
previous.next = next.previous = newNode;
newNode.next = next;
newNode.previous = previous;
}
如果任务池空,当前任务作为 firstCallbackNode,注意如果只有一个节点,它的 next、previous 都是自己,这也是双向链表的一部分。
如果任务池已有内容,则要计算下插入位置。前面说任务池是按过期时间排序的,所以从链表头出发,一直 do while 找到一个过期时间比自己长的作为 next 就好。如下图,假设绿色是新任务,节点上的数字表示过期时间(仅示意,实际是时间戳)。
编辑切换为居中
添加图片注释,不超过 140 字(可选)
\
但无论插入的是不是空任务池,都要执行一句 scheduleHostCallbackIfNeeded,看意思是调 Host 回调执行任务。但“if need”什么意思?具体又是怎么执行的呢?
scheduleHostCallbackIfNeeded:任务池跑起来
显然,scheduleHostCallbackIfNeeded 尝试在浏览器空闲的时候执行任务池里的任务。
那每次浏览器空闲就取出一个任务执行吗?显然不够高效。我们想做到的是“每次浏览器空闲执行尽可能多的任务”。
看代码(有简化):
function scheduleHostCallbackIfNeeded() {
if (firstCallbackNode !== null) {
requestHostCallback(flushWork, firstCallbackNode.expirationTime);
}
}
function flushWork(didUserCallbackTimeout) {
try {
if (didUserCallbackTimeout) {
do {
flushFirstCallback();
} while (firstCallbackNode !== null && firstCallbackNode.expirationTime <= currentTime);
} else {
do {
flushFirstCallback();
} while (firstCallbackNode !== null && !shouldYieldToHost());
}
} finally {
scheduleHostCallbackIfNeeded();
}
}
按照 “每次浏览器空闲执行尽可能多的任务”,跑任务池的方式不是往 host callback 里塞一个最优先任务,而是一个 “尽可能多跑任务” 的 flushWork 函数。那 “尽可能多” 的终点是哪呢?
- 如果头里的任务已过期,说明至少有一个任务过期了,就要一直刷到不过期的任务,顾不得浏览器卡帧了。
- 如果头里的任务没过期,那就保证浏览器不卡帧(shouldYieldToHost)的前提下尽量多跑
当一次 flushWork 跑完,finally 还是主动去 scheduleHostCallbackIfNeeded 看看有没有别的活可以干。
但来来回回我们发现,scheduleHostCallbackIfNeeded 调的很随意,或者说在任意想要查看有没有任务的时候都会调。这就需要 scheduleHostCallbackIfNeeded 自己控制要不要执行,也就是“锁”。我们再看下这俩函数的执行时序:
编辑切换为居中
添加图片注释,不超过 140 字(可选)
\
scheduleHostCallbackIfNeeded 把任务通过 requestHostCallback 注册到宿主,等宿主回调了才能到 flushWork。这个过程是一个不知道多久的异步过程,而且任务池和宿主回调都是单例(参考上一 Part)。所以当我们调 scheduleHostCallbackIfNeeded 的时候有几种可能:
- 没有已经注册但未回调的 flushWork,也没有正在执行的 flushWork:这是理想情况,不会造成任何资源冲突。
- 有已经注册但未回调的 flushWork:这时候我很可能往任务池加了任务才调的 scheduleHostCallbackIfNeeded,所以已发起的那次 flushWork 回调是过期的,就要取消掉重新注册最新回调。
- 上次注册的 flushWork 已经回调,正在执行:这已经尽到“同步”逻辑里了,不能取消了,但要避免重复发起。
所以需要两个锁,来锁 “是否有注册但未回调的 flushWork”、“是否正在 flushWork”。具体实现:
var isPerformingWork = false;
var isHostCallbackScheduled = false;
function scheduleHostCallbackIfNeeded() {
if (isPerformingWork) {
return;
}
if (isHostCallbackScheduled) {
cancelHostCallback();
} else {
isHostCallbackScheduled = true;
}
// requestHostCallback(flushWork, expirationTime);
}
function flushWork(didUserCallbackTimeout) {
isHostCallbackScheduled = false;
isPerformingWork = true;
try {
// 尽可能多的执行任务池
} finally {
isPerformingWork = false;
// scheduleHostCallbackIfNeeded();
}
}
编辑切换为居中
添加图片注释,不超过 140 字(可选)
\
小结
小结一下 Scheduler 的调度实现:
- Scheduler 做了个单例的任务池,双向循环链表结构,通过一个链表指针存取。
- Scheduler 定义了五级优先级,对应的是过期时间,作为任务执行顺序的依据。
- Scheduler 实现了 scheduleCallback,就是把传入的回调函数添加到任务池链表里。
- 添加任务的时候会尝试执行任务池,把 flushWork 注册到宿主回调,这是一个“尽可能多跑任务”的方法。这里会用两个锁避免资源混乱。
到此为止,Scheduler 的实现就算完事了,核心提供了几个接口:
XXXPriority: number; // 五级优先级定义
scheduleCallback: (priorityLevel: number, callback) => CallbackNode; // 按优先级调度回调方法
cancelCallback: (callbackNode: CallbackNode) => void; // 取消调度
shouldYield: () => boolean; // 随时查询是否要暂停任务交还控制权的函数
Part 4 React 对 Scheduler 的应用
前面我们已经看到了一个非常完善的调度器,React Fiber 正是在这个基建上实现的。
setState 后发生了什么?
我们看下 setState 后的主要函数调用:
编辑切换为居中
添加图片注释,不超过 140 字(可选)
\
setState 会创建 Update 对象,Update 对象加入当前 fiber 节点的 updateQueue(也是个链表),这部分不展开。
然后调用 scheduleWork 发起调度,从这里开始进入 ReactFiberScheduler 模块范围,并最终走到 workLoop。另一篇讲 Fiber 树构建的文章里( React Fiber 架构原理:关于 Fiber 树的一切 - 知乎),正是从 workLoop 开始的,这就算接上了。
发起 scheduleCallback
scheduleWork 传入的是当前发起 setState 的 fiber 节点和过期时间。
scheduleWork(fiber, expirationTime);
在 ReactFiberScheduler 中 scheduleWork 定义为 scheduleUpdateOnFiber 函数。这个函数无非做了两件事:1、向上找到根节点;2、把根节点发起调度。(这也说明 React 一个重要特点,每次 Diffing 都是整棵树的 Diffing)。关键代码如下:
export const scheduleWork = scheduleUpdateOnFiber;
export function scheduleUpdateOnFiber( fiber: Fiber, expirationTime: ExpirationTime ) {
const root = markUpdateTimeFromFiberToRoot(fiber, expirationTime);
scheduleCallbackForRoot(root, priorityLevel, expirationTime);
}
function scheduleCallbackForRoot( root, priorityLevel, expirationTime) {
root.callbackExpirationTime = expirationTime;
const existingCallbackNode = root.callbackNode;
if (existingCallbackNode !== null) {
cancelCallback(existingCallbackNode);
}
root.callbackNode = scheduleCallback( priorityLevel,
runRootCallback.bind( null, root,
renderRoot.bind(null, root, expirationTime),
),
);
}
markUpdateTimeFromFiberToRoot 是一个沿着当前 fiber 节点的 return 向上获取根节点的方法。此外还会一路更新节点过期时间。根节点、优先级、过期时间会被拿来调用 scheduleCallbackForRoot(为根节点发起调度)。
在 scheduleCallbackForRoot 中,root 被绑上了 callbackNode 和 callbackExpirationTime,保证当前只有一个 scheduleCallback 在跑,如果调度之前 root 上有别的 callbackNode,会先被取消掉再创建新的。
再接下去,就接上了调度的基建「scheduleCallback」。但是这个入参有点意思,连用两个 bind,不过既然 bind 的 this 都是 null,其实我们可以改写得更通俗点,像这样:
root.callbackNode = scheduleCallback(
priorityLevel,
() => runRootCallback(root, () => renderRoot(root, expirationTime)),
);
字面意思,scheduleCallback 回调后会先执行 runRootCallback,再在里面回调 renderRoot。
runRootCallback:“断点续跑” 的精髓
打开 runRootCallback 看看,这里隐藏着 fiber “断点续跑”的精髓。
function runRootCallback(root, callback) {
const prevCallbackNode = root.callbackNode;
let continuation = null;
try {
continuation = callback();
if (continuation !== null) {
return runRootCallback.bind(null, root, continuation);
} else {
return null;
}
} finally {
if (continuation === null && prevCallbackNode === root.callbackNode) {
root.callbackNode = null;
root.callbackExpirationTime = NoWork;
}
}
}
- callback 进来的是 renderRoot(root, expirationTime)。
- 如果 renderRoot 没跑完就退出了,会返回一个可继续跑的 continuation,这个 continuation 会抛回给 scheduleCallback 下次继续跑。
- 如果 renderRoot 跑完了退出,就返回null,重置 root 的 callbackNode。
那么什么情况下 renderRoot 会返回 continuation?又会返回什么样的 continuation 呢?这就要从 Fiber 的更新流程说起。
renderRoot Fiber 的更新流程
先来看这张经典的图:(出自:React lifecycle methods diagram)
编辑切换为居中
添加图片注释,不超过 140 字(可选)
\
Fiber 的更新分为两个阶段:render 阶段和 commit 阶段。render 阶段可中断,就是我们说的 Diffing 过程,详细内容参考 React Fiber 架构原理:关于 Fiber 树的一切 - 知乎;commit 阶段不可中断。
打开 renderRoot,我们发现它有很多分支,16个 return,但总的来说只有三种方法:renderRoot、commitRoot、null。很容易对应上面两个阶段:返回 renderRoot 表示 render 阶段被中断,要继续 render;返回 commitRoot 表示 render 阶段走完,但还没 commit;返回 null 表示本次更新已完成。
因此进入 renderRoot 也可能有两种场景:1、完全从新发起 render 阶段;2、上次 render 被中断,这次进来要继续 render。
renderRoot 是一个传入 root、expirationTime 的函数。作为辅助,模块顶层还给了 workInProgress 、workInProgressRoot 指针,既可以和别的方法共享指针,也可以保证 render 阶段被中断重启后能恢复现场,还可以保证“全局只有一个在跑的任务”:
// The fiber we're working on
let workInProgress: Fiber | null = null;
// The root we're working on
let workInProgressRoot: FiberRoot | null = null;
// The expiration time we're rendering
let renderExpirationTime: ExpirationTime = NoWork;
function renderRoot( root, expirationTime ): SchedulerCallback | null;
从新发起 render
如何判断这次 renderRoot 是从新发起的呢?我们有全局的 workInProgressRoot、renderExpirationTime 变量,记着上次 renderRoot 的 root 和 renderExpirationTime,如果这两个任意一个变了,就说明不是上次的任务了。
这时候就要把指针都更新为新任务的指针,是通过 prepareFreshStack 方法实现的:
if (root !== workInProgressRoot || expirationTime !== renderExpirationTime) {
prepareFreshStack(root, expirationTime);
}
function prepareFreshStack(root, expirationTime) {
workInProgressRoot = root;
workInProgress = createWorkInProgress(root.current, null, expirationTime);
}
此时 workInProgress 由 createWorkInProgress 创建返回,是新 workInProgress 树的根节点。
在React中最多会同时存在两棵Fiber树: 当前屏幕上显示内容对应的Fiber树称为 current Fiber 树 正在构建的Fiber树称为 workInProgress Fiber 树,我们这里讨论的所有遍历都在这棵树上 当一次协调发起,首先会开一棵新 workInProgress Fiber 树,然后从根节点开始构建并遍历 workInProgress Fiber 树。 如果构建到一半被打断,current 树还在。如果构建并提交完成,直接把 current 树丢掉,让 workInProgress Fiber 树成为新的 current 树。 —— React Fiber 架构原理:关于 Fiber 树的一切 - 知乎
render 阶段的完成或中断
无论从新还是”断点续跑”,到这里我们都会从 workInProgress 发起 workLoop(代码省了一大堆,看关键行):
if (workInProgress !== null) {
workLoop();
}
workLoop 可能跑完,把 workInProgress 置 null。也可能根据 shouldYield 中断,让出资源给宿主,这样 workInProgress 就停在了某个节点上。
function workLoop() {
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
如果是后者,就可以直接返回 renderRoot 套娃了:
if (workInProgress !== null) {
return renderRoot.bind(null, root, expirationTime);
}
如果是前者,会先判断一下 workInProgressRootExitStatus,这个状态在 workLoop 中更新,非异常情况下会走到 RootCompleted,然后返回 commitRoot:
switch (workInProgressRootExitStatus) {
// ...
case RootCompleted: {
// The work completed. Ready to commit.
return commitRoot.bind(null, root, expirationTime);
}
}
到 commitRoot,Fiber 基于调度器的异步部分走完,进到同步部分。
小结
- setState 会创建更新对象并插入 fiber 节点队列,然后发起对这个 fiber 的更新。但随后会沿着 fiber 找到根节点,变成对整棵树的更新
- 树上带着更新信息,请求 scheduleCallback 回调,接上调度器的接口
- 回调后的 renderRoot 反映了整个更新流程,包括可中断的 render 阶段和不可中断的 commit 阶段,render 阶段的中断是 workLoop 查询 shouldYield 接口发起的
- 为了实现“断点续跑”,未完成的工作会以函数形式返回,下一轮回调继续。而断点信息保存在像 workInProgress 这样的模块全局变量中
Part Z 总结
这篇我们自底向上盘清楚了 Fiber 架构中,Scheduler 的主要实现和应用思路,总结如下:
- 调度能力分为四层实现:对接浏览器的 SchedulerHostConfig、实现核心任务管理的 Scheduler、抹平接口的 SchedulerWithReactIntegration、应用 Scheduler 的 ReactFiberScheduler
- 为了避免卡住渲染线程,SchedulerHostConfig 通过 MessageChannel 利用浏览器的帧间空闲时间,实现空闲回调和时间计算,成为 Fiber 调度的基础
- Scheduler 基于空闲回调做了完整的任务调度方案。定义了五级优先级;通过双向循环链表实现任务池,并将任务池的增删包装成任务注册/取消;并在浏览器空闲时“尽可能多跑任务”。
- React 中对类似 setState 的调用会通过 Scheduler 调度整棵树的更新过程,并在 render 阶段随时查 Scheduler 中断任务。中断的现场保留在模块全局变量中,并在下一轮回调中重启。