原文链接:

要说 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↓,在这段时间内,浏览器需要执行如下任务
奇葩说框架之 React Fiber 调度机制 - 图1
浏览器的1帧

可用户并不关心上面的大部分流程,只需要页面可以及时的展示就足够了。如果我们在一次渲染时,向栈中推入了过多的任务,从而导致其执行时间超过浏览器的一帧,就会使这一帧没能及时响应渲染页面,也是就我们常说的掉帧

而 Stack 这种架构的特点就是,所有任务都按顺序的压入了栈中,而执行的时候无法确认当前的任务是否会耗去过长的脚本运行时间,使得这一帧时间内里浏览器能做的事不可控。

所以可控便成了 React 团队的优化方向,Fiber Reconciler 应运而生。

Fiber 的诞生

其实 Fiber 这一概念并非由 React 定义。Fiber 本义为纤维,在计算机科学中含义为纤程是一种轻量级的执行线程

线程,操作系统能够进行运算调度的最小单位。

这里不必为纤程、线程、x程…等的定义所感到迷惑,从下图的定义看出:对于不同的调度方(注:ES6的协程,从用户层面进行调度),相同的线程类型会有不同的名字。
奇葩说框架之 React Fiber 调度机制 - 图2
结合定义与上图我们可以知道 fiber 的特性:“轻量级与非抢占式

非抢占式,也叫协作式(Cooperative),是一种多任务方式,相对于抢占式多任务(Preemptive multitasking),协作式多任务要求每一个运行中的程序,定时放弃自己的运行权利,告知操作系统可让下一个程序运行。

React 团队的目标也是与此一致,通过管理子任务的调用和让出,来决定当前的运行时处理哪部分内容:浏览器需要进行渲染时,线程让出,当前任务挂起。等到资源被释放回来的时候,又恢复执行,通过合理使用资源实现了多任务处理。

Fiber 实现思路

为了完成上述的目标,React 团队通过在 Stack 栈的基础上进行数据结构调整,将之前需要递归进行处理的事情分解成增量的执行单元,最终得出的实现方式就是链表

链表相较于栈来说操作更高效,对于顺序调整、删除等情况,只需要改变节点的指针指向就可以,在多向链表中,不仅可以根据当前节点找到下一个节点,还可以找到他的父节点或者兄弟节点。但链表由于保存了更多的指针,所以说将占用更多的空间。

在 React 项目的/packages/react-reconciler/src/ReactInternalTypes.js 文件中,有着 Fiber 单元的定义,每一个 VirtualDOM 节点内部现在使用 Fiber来表示。

  1. export type Fiber = {
  2. tag: WorkTag,
  3. key: null | string,
  4. ...
  5. // 链表结构信息
  6. return: Fiber | null,
  7. child: Fiber | null,
  8. sibling: Fiber | null,
  9. ...
  10. }

前面提到, Stack 是基于栈,每一次更新操作会一直占用主线程,直到更新完成。这可能会导致事件响应延迟,动画卡顿等现象。

在 Fiber 机制中,它采用”化整为零”的战术,将 Reconciler 开始调度时,将递归遍历 VDOM 这个大任务分成若干小任务,每个任务只负责一个节点的处理

在处理当前任务的时候生成下一个任务,如果此时浏览器需要执行渲染动作,则需要进行让出线程。如果没有下一个任务生成了,则本次渲染操作完成。

Fiber 的线程控制

至于 React 是如何进一步实现线程控制的,开发团队在官方文档中的设计原则这样写道:

  • 我们认为 React 在一个应用中的位置很独特,它知道当前哪些计算当前是相关的,哪些不是。
  • 如果不在当前屏幕,我们可以延迟执行相关逻辑。如果数据数据到达的速度快过帧速,我们可以合并、批量更新。我们优先执行用户交互的工作,延后执行相对不那么重要的后台工作,从而避免掉帧。

遵从上述原则,从上我们可以了解到,线程控制离不开保持帧的渲染,所以在实现方案上很自然的就想到 requestAnimationFrame 这个API,与之相关的还有 requestIdleCallback

requestIdleCallback方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件。

若使用这两个API,此时 Fiber 的任务调度如下图所示
奇葩说框架之 React Fiber 调度机制 - 图3
使用rAF的任务调度
看起相当完美,requestIdleCallback仿佛是因此而生一般,Fiber 的早期版本确实却是使用了这样的方案,不过这已经是过去式了。在19年的一次更新中,React 团队推翻之前的设计,使用了 **MessageChannel** 来实现了对于线程控制

MessageChannel 允许我们创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据。此特性在 Web Worker 中可用。其使用方式如下:

  1. const channel = new MessageChannel()
  2. channel.port1.onmessage = function(msgEvent) {
  3. console.log('recieve message!')
  4. }
  5. channel.port2.postMessage(null)
  6. // output: recieve message!

React 开发成员对这次更新这样说道:requestAnimationFrame 过于依赖硬件设备,无法在其之上进一步减少任务调度频率,以获得更大的优化空间。使用高频(5ms)少量的消息事件进行任务调度,虽然会加剧主线程与其他浏览器任务的争用,但却值得一试。
奇葩说框架之 React Fiber 调度机制 - 图4
React commit message
在最新版本源码的 /packages/scheduler/src/forks/Scheduler.js 文件中可以看到,这次“尝试性实验”沿用至今。

  1. let schedulePerformWorkUntilDeadline; // 调度器
  2. if (typeof localSetImmediate === 'function') {
  3. // Node.js 与 旧版本IE环境.
  4. ...
  5. } else if (typeof MessageChannel !== 'undefined') {
  6. // DOM 与 Web Worker 环境.
  7. const channel = new MessageChannel();
  8. const port = channel.port2;
  9. channel.port1.onmessage = performWorkUntilDeadline; // 执行器
  10. schedulePerformWorkUntilDeadline = () => {
  11. port.postMessage(null);
  12. };
  13. } else {
  14. // 非浏览器环境的兜底方案.
  15. ...
  16. }

这里我们只看第二种情况就好,React 将上述的集中兼容处理做一封装,最终得到一个与 requestIdleCallback 类似的函数 requestHostCallback

  1. function requestHostCallback(callback) {
  2. scheduledHostCallback = callback;
  3. // 开启任务循环
  4. if (!isMessageLoopRunning) {
  5. isMessageLoopRunning = true;
  6. // 调度器开始运作,即 port1 端口将收到消息,执行 performWorkUntilDeadline
  7. schedulePerformWorkUntilDeadline();
  8. }
  9. }

我们接着看 performWorkUntilDeadline 如何处理事件的

  1. const performWorkUntilDeadline = () => {
  2. //当前是否有处理中的任务
  3. if (scheduledHostCallback !== null) {
  4. // 计算此次任务的 deadline
  5. const currentTime = getCurrentTime();
  6. deadline = currentTime + yieldInterval;
  7. const hasTimeRemaining = true;
  8. let hasMoreWork = true;
  9. try {
  10. // 是否还有更多任务
  11. hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
  12. } finally {
  13. if (hasMoreWork) {
  14. // 有:继续进行任务调度
  15. schedulePerformWorkUntilDeadline();
  16. } else {
  17. isMessageLoopRunning = false;
  18. scheduledHostCallback = null;
  19. }
  20. }
  21. } else {
  22. isMessageLoopRunning = false;
  23. }
  24. };

整个流程可以用下图表示
奇葩说框架之 React Fiber 调度机制 - 图5

让出线程

任务调度的初步模型已经有了,紧接着我们来看 Fiber 是如何把控线程的让出:

  1. const localPerformance = performance;
  2. // 获取当前时间
  3. getCurrentTime = () => localPerformance.now();
  4. // 让出线程周期, 默认是5ms
  5. let yieldInterval = 5;
  6. let deadline = 0;
  7. const maxYieldInterval = 300;
  8. let needsPaint = false;
  9. const scheduling = navigator.scheduling;
  10. // 是否让出主线程
  11. shouldYieldToHost = function() {
  12. const currentTime = getCurrentTime();
  13. if (currentTime >= deadline) {
  14. if (needsPaint || scheduling.isInputPending()) { // 判断是否有输入事件
  15. return true;
  16. }
  17. return currentTime >= maxYieldInterval; // 在持续运行的react应用中, currentTime肯定大于300ms, 这个判断只在初始化过程中才有可能返回false
  18. } else {
  19. // 当前帧还有时间
  20. return false;
  21. }
  22. };

currentTime >= deadline 时,我们将会让出主线程 (deadline 的计算在 performWorkUntilDeadline 中)yieldInterval 默认是5ms, 如果一个 task 运行时间超过5ms,那么在下一个 task 执行之前, 将会把控制权归还浏览器,以保证浏览时的及时渲染。

任务的恢复

接下来我们看一下由于让出线程所被中断的任务如何恢复。

代码中定义了一个任务队列:

  1. // Tasks are stored on a min heap
  2. // 任务被存储在一个小根堆中
  3. var taskQueue = []; // 任务队列

通过 unstable_scheduleCallback 进行任务创建

  1. // 代码有所简化
  2. function unstable_scheduleCallback(priorityLevel, callback, options) {
  3. // 【1. 计算任务过期时间】
  4. var startTime = getCurrentTime();
  5. var timeout;
  6. switch (priorityLevel) {
  7. ...
  8. timeout = SOME_PRIORITY_TIMEOUT
  9. }
  10. var expirationTime = startTime + timeout; // 优先级越高;过期时间越小
  11. //【2. 创建新任务】
  12. var newTask = {
  13. id: taskIdCounter++, // 唯一ID
  14. callback, // 传入的回调函数
  15. priorityLevel, // 优先级
  16. startTime, // 创建 task 的时间
  17. expirationTime, // 过期时间,
  18. };
  19. newTask.sortIndex = expirationTime;
  20. // 【3. 加入任务队列】
  21. push(taskQueue, newTask);
  22. // 【4. 请求调度】
  23. if (!isHostCallbackScheduled && !isPerformingWork) {
  24. isHostCallbackScheduled = true;
  25. requestHostCallback(flushWork);
  26. }
  27. return newTask;
  28. }

可以看到,在上面代码中的 【4. 请求调度】 中使用了上面提到的 requestHostCallback 方法,也正是 postMessage 的开始。requestHostCallback 的入参 `flushWork 实际上返回的是一个函数 **workLoop**。所以我们从 workLoop 继续看:

  1. // 代码有所简化
  2. function workLoop(hasTimeRemaining, initialTime) {
  3. let currentTime = initialTime; // 保存当前时间, 用于判断任务是否过期
  4. currentTask = peek(taskQueue); // 获取队列中的第一个任务
  5. while (currentTask !== null) {
  6. // 是否需要让出线程的判断
  7. if (
  8. currentTask.expirationTime > currentTime &&
  9. (!hasTimeRemaining || shouldYieldToHost())
  10. ) {
  11. // 任务虽然没有超时,但本帧时间不够了。挂起
  12. break;
  13. }
  14. const callback = currentTask.callback;
  15. if (typeof callback === 'function') {
  16. currentTask.callback = null;
  17. currentPriorityLevel = currentTask.priorityLevel;
  18. const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
  19. // 执行回调
  20. const continuationCallback = callback(didUserCallbackTimeout);
  21. currentTime = getCurrentTime();
  22. // 回调完成, 判断是否还有连续回调
  23. if (typeof continuationCallback === 'function') {
  24. currentTask.callback = continuationCallback;
  25. } else {
  26. // 把currentTask移出队列
  27. if (currentTask === peek(taskQueue)) {
  28. pop(taskQueue);
  29. }
  30. }
  31. } else {
  32. // 如果任务被取消(这时currentTask.callback = null), 将其移出队列
  33. pop(taskQueue);
  34. }
  35. // 更新 currentTask
  36. currentTask = peek(taskQueue);
  37. }
  38. if (currentTask !== null) {
  39. return true; // 如果task队列没有清空, 返回ture. 等待调度中心下一次回调
  40. } else {
  41. return false; // task队列已经清空, 返回false.
  42. }
  43. }

就这样,在一次次的 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