奇葩说框架之 React Fiber 调度机制

a0019ff34fe299a41ae391f48bcb62a5.png

要说 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↓,在这段时间内,浏览器需要执行如下任务

107fbb457851632e63388558dd9f802b.png
浏览器的1帧

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

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

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

Fiber 的诞生

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

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

这里不必为纤程、线程、x程...等的定义所感到迷惑,从下图的定义看出:对于不同的调度方,相同的线程类型会有不同的名字。

31c681a3a129ea90cae02e3a614f026b.png

结合定义与上图我们可以知道 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 的任务调度如下图所示

b0ce78e57badcca1403a870c11cb7732.png
使用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)少量的消息事件进行任务调度,虽然会加剧主线程与其他浏览器任务的争用,但却值得一试。

7006baf438e0a54957857530a0b35f14.png
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;
  }
};

整个流程可以用下图表示

e3c672dcaa7b7b8739d31d8d45e45083.png
让出线程

任务调度的初步模型已经有了,紧接着我们来看 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

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值