原文链接:
要说 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 端口将收到消息,执行 performWorkUntilDeadlineschedulePerformWorkUntilDeadline();}}
我们接着看 performWorkUntilDeadline 如何处理事件的
const performWorkUntilDeadline = () => {//当前是否有处理中的任务if (scheduledHostCallback !== null) {// 计算此次任务的 deadlineconst 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();// 让出线程周期, 默认是5mslet 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++, // 唯一IDcallback, // 传入的回调函数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);}// 更新 currentTaskcurrentTask = 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
