9

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

 3 years ago
source link: https://zhuanlan.zhihu.com/p/422640825
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

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

v2-39138fa1c2a127ce0ea52a4a802cc600_720w.jpg

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

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

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

Fiber 的诞生

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

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

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

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

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

在最新版本源码的 /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 的介绍先告一段落,希望今天的你能有所收获。

欢迎在评论区留下你的建议或问题,也欢迎指出文中的错误。奇葩说框架系列后续将持续更新,感兴趣的小伙伴们可以不要忘了关注我们~

参考文章


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK