原文链接:
要说 React 框架这些年迭代更新中让人眼前一亮的方案设计,Fiber Reconciler(下文将简称为 Fiber)绝对占有一席之地。作为 React 团队两年多研究与后续不断深入所产出的成果,Fiber 提高了 React 对于复杂页面的响应能力和性能感知,使其在面对不断扩展的页面场景时可以更加流畅的渲染。今天我们一起从 Reconciler 这个概念开始,简单聊聊 React Fiber。
Reconciler 在调度什么?
在某一时间节点调用 React 的 render() 方法,会创建一棵由 React 元素组成的树。在下一次 state 或 props 更新时,相同的 render() 方法会返回一棵不同的树。React 需要基于这两棵树之间的差别来判断如何高效的更新 UI,以保证当前 UI 与最新的树保持同步。
这是 React 历代 Reconciler 的设计动机,也是 React 团队优化方向的主旨。合理的分配浏览器每次渲染内容,保证页面的及时更新,正是 Reconciler 的职责所在。
Fiber 的前任:Stack Reconciler
Stack Reconciler(下文将简称为 Stack)作为 Fiber 的前任调度器,就像它的名字一样,通过栈的方式实现任务的调度:将不同任务(渲染变动)压入栈中,浏览器每次绘制的时候,将执行这个栈中已经存在的任务。
说到这,Stack 的问题已经很明显的暴露出来了。我们知道设备刷新频率通常为 60Hz,如今支持高刷(120Hz+)的设备也在不断增加,页面每一帧所消耗掉的时间也在不断减少 1s/60↑ ≈ 16ms↓,在这段时间内,浏览器需要执行如下任务
浏览器的1帧
可用户并不关心上面的大部分流程,只需要页面可以及时的展示就足够了。如果我们在一次渲染时,向栈中推入了过多的任务,从而导致其执行时间超过浏览器的一帧,就会使这一帧没能及时响应渲染页面,也是就我们常说的掉帧。
而 Stack 这种架构的特点就是,所有任务都按顺序的压入了栈中,而执行的时候无法确认当前的任务是否会耗去过长的脚本运行时间,使得这一帧时间内里浏览器能做的事不可控。
所以可控便成了 React 团队的优化方向,Fiber Reconciler 应运而生。
Fiber 的诞生
其实 Fiber 这一概念并非由 React 定义。Fiber 本义为纤维,在计算机科学中含义为纤程,是一种轻量级的执行线程。
线程,操作系统能够进行运算调度的最小单位。
这里不必为纤程、线程、x程…等的定义所感到迷惑,从下图的定义看出:对于不同的调度方(注:ES6的协程,从用户层面进行调度),相同的线程类型会有不同的名字。
结合定义与上图我们可以知道 fiber 的特性:“轻量级与非抢占式”
非抢占式,也叫协作式(Cooperative),是一种多任务方式,相对于抢占式多任务(Preemptive multitasking),协作式多任务要求每一个运行中的程序,定时放弃自己的运行权利,告知操作系统可让下一个程序运行。
React 团队的目标也是与此一致,通过管理子任务的调用和让出,来决定当前的运行时处理哪部分内容:浏览器需要进行渲染时,线程让出,当前任务挂起。等到资源被释放回来的时候,又恢复执行,通过合理使用资源实现了多任务处理。
Fiber 实现思路
为了完成上述的目标,React 团队通过在 Stack 栈的基础上进行数据结构调整,将之前需要递归进行处理的事情分解成增量的执行单元,最终得出的实现方式就是链表。
链表相较于栈来说操作更高效,对于顺序调整、删除等情况,只需要改变节点的指针指向就可以,在多向链表中,不仅可以根据当前节点找到下一个节点,还可以找到他的父节点或者兄弟节点。但链表由于保存了更多的指针,所以说将占用更多的空间。
在 React 项目的/packages/react-reconciler/src/ReactInternalTypes.js
文件中,有着 Fiber 单元的定义,每一个 VirtualDOM 节点内部现在使用 Fiber来表示。
export type Fiber = {
tag: WorkTag,
key: null | string,
...
// 链表结构信息
return: Fiber | null,
child: Fiber | null,
sibling: Fiber | null,
...
}
前面提到, Stack 是基于栈,每一次更新操作会一直占用主线程,直到更新完成。这可能会导致事件响应延迟,动画卡顿等现象。
在 Fiber 机制中,它采用”化整为零”的战术,将 Reconciler 开始调度时,将递归遍历 VDOM 这个大任务分成若干小任务,每个任务只负责一个节点的处理。
在处理当前任务的时候生成下一个任务,如果此时浏览器需要执行渲染动作,则需要进行让出线程。如果没有下一个任务生成了,则本次渲染操作完成。
Fiber 的线程控制
至于 React 是如何进一步实现线程控制的,开发团队在官方文档中的设计原则这样写道:
- 我们认为 React 在一个应用中的位置很独特,它知道当前哪些计算当前是相关的,哪些不是。
- 如果不在当前屏幕,我们可以延迟执行相关逻辑。如果数据数据到达的速度快过帧速,我们可以合并、批量更新。我们优先执行用户交互的工作,延后执行相对不那么重要的后台工作,从而避免掉帧。
遵从上述原则,从上我们可以了解到,线程控制离不开保持帧的渲染,所以在实现方案上很自然的就想到 requestAnimationFrame
这个API,与之相关的还有 requestIdleCallback
。
requestIdleCallback方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件。
若使用这两个API,此时 Fiber 的任务调度如下图所示
使用rAF的任务调度
看起相当完美,requestIdleCallback
仿佛是因此而生一般,Fiber 的早期版本确实却是使用了这样的方案,不过这已经是过去式了。在19年的一次更新中,React 团队推翻之前的设计,使用了 **MessageChannel**
来实现了对于线程控制。
MessageChannel
允许我们创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据。此特性在 Web Worker 中可用。其使用方式如下:
const channel = new MessageChannel()
channel.port1.onmessage = function(msgEvent) {
console.log('recieve message!')
}
channel.port2.postMessage(null)
// output: recieve message!
React 开发成员对这次更新这样说道:requestAnimationFrame
过于依赖硬件设备,无法在其之上进一步减少任务调度频率,以获得更大的优化空间。使用高频(5ms)少量的消息事件进行任务调度,虽然会加剧主线程与其他浏览器任务的争用,但却值得一试。
React commit message
在最新版本源码的 /packages/scheduler/src/forks/Scheduler.js
文件中可以看到,这次“尝试性实验”沿用至今。
let schedulePerformWorkUntilDeadline; // 调度器
if (typeof localSetImmediate === 'function') {
// Node.js 与 旧版本IE环境.
...
} else if (typeof MessageChannel !== 'undefined') {
// DOM 与 Web Worker 环境.
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline; // 执行器
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
// 非浏览器环境的兜底方案.
...
}
这里我们只看第二种情况就好,React 将上述的集中兼容处理做一封装,最终得到一个与 requestIdleCallback
类似的函数 requestHostCallback
function requestHostCallback(callback) {
scheduledHostCallback = callback;
// 开启任务循环
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
// 调度器开始运作,即 port1 端口将收到消息,执行 performWorkUntilDeadline
schedulePerformWorkUntilDeadline();
}
}
我们接着看 performWorkUntilDeadline
如何处理事件的
const performWorkUntilDeadline = () => {
//当前是否有处理中的任务
if (scheduledHostCallback !== null) {
// 计算此次任务的 deadline
const currentTime = getCurrentTime();
deadline = currentTime + yieldInterval;
const hasTimeRemaining = true;
let hasMoreWork = true;
try {
// 是否还有更多任务
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
} finally {
if (hasMoreWork) {
// 有:继续进行任务调度
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
scheduledHostCallback = null;
}
}
} else {
isMessageLoopRunning = false;
}
};
整个流程可以用下图表示
让出线程
任务调度的初步模型已经有了,紧接着我们来看 Fiber 是如何把控线程的让出:
const localPerformance = performance;
// 获取当前时间
getCurrentTime = () => localPerformance.now();
// 让出线程周期, 默认是5ms
let yieldInterval = 5;
let deadline = 0;
const maxYieldInterval = 300;
let needsPaint = false;
const scheduling = navigator.scheduling;
// 是否让出主线程
shouldYieldToHost = function() {
const currentTime = getCurrentTime();
if (currentTime >= deadline) {
if (needsPaint || scheduling.isInputPending()) { // 判断是否有输入事件
return true;
}
return currentTime >= maxYieldInterval; // 在持续运行的react应用中, currentTime肯定大于300ms, 这个判断只在初始化过程中才有可能返回false
} else {
// 当前帧还有时间
return false;
}
};
当 currentTime >= deadline
时,我们将会让出主线程 (deadline
的计算在 performWorkUntilDeadline
中)yieldInterval
默认是5ms, 如果一个 task 运行时间超过5ms,那么在下一个 task 执行之前, 将会把控制权归还浏览器,以保证浏览时的及时渲染。
任务的恢复
接下来我们看一下由于让出线程所被中断的任务如何恢复。
代码中定义了一个任务队列:
// Tasks are stored on a min heap
// 任务被存储在一个小根堆中
var taskQueue = []; // 任务队列
通过 unstable_scheduleCallback
进行任务创建
// 代码有所简化
function unstable_scheduleCallback(priorityLevel, callback, options) {
// 【1. 计算任务过期时间】
var startTime = getCurrentTime();
var timeout;
switch (priorityLevel) {
...
timeout = SOME_PRIORITY_TIMEOUT
}
var expirationTime = startTime + timeout; // 优先级越高;过期时间越小
//【2. 创建新任务】
var newTask = {
id: taskIdCounter++, // 唯一ID
callback, // 传入的回调函数
priorityLevel, // 优先级
startTime, // 创建 task 的时间
expirationTime, // 过期时间,
};
newTask.sortIndex = expirationTime;
// 【3. 加入任务队列】
push(taskQueue, newTask);
// 【4. 请求调度】
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
}
return newTask;
}
可以看到,在上面代码中的 【4. 请求调度】 中使用了上面提到的 requestHostCallback
方法,也正是 postMessage
的开始。requestHostCallback
的入参 `flushWork
实际上返回的是一个函数 **workLoop**
。所以我们从 workLoop 继续看:
// 代码有所简化
function workLoop(hasTimeRemaining, initialTime) {
let currentTime = initialTime; // 保存当前时间, 用于判断任务是否过期
currentTask = peek(taskQueue); // 获取队列中的第一个任务
while (currentTask !== null) {
// 是否需要让出线程的判断
if (
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
// 任务虽然没有超时,但本帧时间不够了。挂起
break;
}
const callback = currentTask.callback;
if (typeof callback === 'function') {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
// 执行回调
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
// 回调完成, 判断是否还有连续回调
if (typeof continuationCallback === 'function') {
currentTask.callback = continuationCallback;
} else {
// 把currentTask移出队列
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
}
} else {
// 如果任务被取消(这时currentTask.callback = null), 将其移出队列
pop(taskQueue);
}
// 更新 currentTask
currentTask = peek(taskQueue);
}
if (currentTask !== null) {
return true; // 如果task队列没有清空, 返回ture. 等待调度中心下一次回调
} else {
return false; // task队列已经清空, 返回false.
}
}
就这样,在一次次的 workLoop
循环中,通过向 currentTask
的赋值,Fiber 始终保存着当前任务的执行情况,以根据不同的 deadline
及时中断,保存,再通过下一次的 unstable_scheduleCallback
恢复任务调度。
可以看出,使用 postMessage
实现的任务调度流程整体更加可控,对其他因素的依赖更少。虽说开发者对这次改动并无感知,但其背后的设计思路值得我们学习。至于 Fiber 下一次会有怎样的更新,我们拭目以待。
结语
关于 Fiber 的介绍先告一段落,希望今天的你能有所收获。
欢迎在评论区留下你的建议或问题,也欢迎指出文中的错误。奇葩说框架系列后续将持续更新,感兴趣的小伙伴们可以不要忘了关注我们~
参考文章
- reactjs.org/docs/design-principles.html
- www.yuque.com/docs/share/8c167e39-1f5e-4c6d-8004-e57cf3851751
- github.com/7kms/react-illustration-series/blob/master/docs/main/scheduler.md
- react.jokcy.me/book/flow/scheduler-pkg.html