React Scheduler - 时间切片

4,747 阅读10分钟

前言

React Fiber 架构下使用 Scheduler 调度器特性 -「时间切片」进行「任务调度」,即:每执行一段时间的任务,就把主线程交还给浏览器,避免长时间占用主线程。

试想,触发一次 React 更新动作,会经过 Reconciler 调和阶段进行 Diff 的比较以及组件重渲染,如果组件 render 耗时过长或参与调和的 Fiber 节点很多,JS 执行时间过长就会带来页面卡住现象(无响应)。

在 Fiber 架构下,每一个虚拟 DOM 都是一个「任务执行单元」,而不是整个 Fiber 树一次全进入调和阶段。这样,借助 Scheduler 时间切片特性去调用 workLoop 循环执行任务,在需要让出主线程的时候中断任务,等待下一次合适的时间继续执行。

Scheduler「时间切片」的本质是为了模拟 requestIdelCallback 实现 JS 任务执行和浏览器渲染合理分配的运行在每一帧上,它们的区别在于:

  • requestIdelCallback 会在每一帧任务执行后存在剩余时间时,允许用户调用它来执行自己定义的代码,当没有剩余时间时,会将执行权交还给浏览器;
  • React 时间切片则是通过宏任务 MessageChannel 实现高频短间隔 5ms 执行 JS 逻辑,之后会将执行权交给浏览器去做渲染,并开启下一个异步请求,待浏览器工作完成后继续执行此任务。

任何 连续、可中断 的流程都可以使用 Scheduler 来调度。

扩展:除去“浏览器重排/重绘”,浏览器在一帧中可以用于执行 JS 的时机如下:

一个task(宏任务) -- 队列中全部job(微任务) -- requestAnimationFrame -- 浏览器重排/重绘 -- requestIdleCallback

接下来,我们将从三种角度来看看「时间切片」的发展史:

  1. requestIdleCallback,浏览器 API;
  2. requestAnimationFrame,React 旧方案;
  3. 高频短间隔调度任务,React 新方案。

另一篇「React Scheduler - 优先级调度」可以看这里。

一、requestIdleCallback

requestIdleCallback(下文简称 rIC)是浏览器提供的一个原生 API,它会判断一帧的任务执行是否留有空闲时间,去执行特定任务(不重要且不紧急的任务),目的是解决执行任务长时间占用主线程,导致高优先级任务(动画、事件)无法及时响应,带来的页面丢帧(卡死)情况。

rIC 语法使用如下:

window.requestIdleCallback(callback[, options]); 
  1. callback 为要执行的回调函数,它接收 deadline 作为对象。deadline 包含两个属性:
type Deadline = {
  timeRemaining: () => number // 当前剩余的可用时间。即该帧剩余时间。
  didTimeout: boolean // 是否超时。
}
  1. options 可以指定超时时间 options.timeout,如果任务还没有被排上执行,则会强制执行。

  2. 使用示例:

let appendTotal = 10000;
let count = 0;
const workLoop = (deadline) => {
  // 1、执行切片任务,当没有剩余时间时,终止执行
  while (deadline.timeRemaining() > 0 && count < appendTotal) {
    for (let i = 0; i < 10; i ++) {
      const div = document.createElement('div');
      div.innerHTML = 'box';
      document.body.appendChild(div);
    }
    count ++;
  }
  // 2、存在未执行完成的任务,开启下次空闲调度执行
  if (count < appendTotal) {
    console.log('再次开启空闲调度', count);
    requestIdleCallback(workLoop);
  } else {
    console.log('任务执行完成');
  }
}
console.log('开启空闲调度');
requestIdleCallback(workLoop);

虽然浏览器提供了 API 实现「任务空闲调度」,但它存在一些缺陷使得 React 团队并未采用它。

  • 一帧的执行时间存在偏差,导致留给工作执行的时间不确定(不稳定);
  • 浏览器兼容不好,其中 safari 浏览器根本不支持它。

二、requestAnimationFrame

最初,React 采用 requestAnimationFrame(下文简称 rAF)+ MessageChannel 代替 rIC 使任务调度与帧对齐。

具体实现分为以下两步:

  1. 判断一帧是否有剩余时间;
  2. 存在剩余时间时,执行任务。

2.1、计算一帧的过期时间

rAF 会在每一帧任务的绘制之前执行,接收一个回调函数,回调函数的参数 rafatime 为执行当前帧的开始时间。

语法如下:

window.requestAnimationFrame(callback);

我们把一帧的执行时间控制在 16.67ms,可以推算出一帧的过期时间:

let deadlineTime;
requestAnimationFrame(rafTime => {
  // 一帧的执行结束时间 = 一帧开始时间 + 用时 16.67ms
  deadlineTime = rafTime + 16.67;
  console.log(rafTime, deadlineTime);
});

2.2、执行任务

MessageChannel 接口允许我们创建一个新的消息通道,并通过该通道的两个 Port 进行通信。选择 MessageChannel 实现时间切片,目的就是为了产生宏任务

React 为实现暂停 JS 任务执行,将主线程交还给浏览器,让浏览器有机会执行页面渲染,就需要借助事件循环的「宏任务」。

因为宏任务会在下次事件循环中执行,不会阻塞本次页面渲染更新。之所以不选择微任务,是因为「微任务是在本次页面更新前会全部执行」,这一点与同步执行无异,不会让出主线程

我们结合这两步看看最初 React 时间切片的实现:

// 计算出当前帧 结束时间点
var deadlineTime;
// 保存任务
var callback;
// 建立通信
var channel = new MessageChannel();
var port1 = channel.port1;
var port2 = channel.port2;

// 接收并执行宏任务
port2.onmessage = () => {
  // 判断当前帧是否还有空闲,即返回的是剩下的时间
  const timeRemaining = () => deadlineTime - performance.now();
  const _timeRemain = timeRemaining();
  // 有空闲时间 且 有回调任务
  if (_timeRemain > 0 && callback) {
    const deadline = {
      timeRemaining, // 计算剩余时间
      didTimeout: _timeRemain < 0 // 当前帧是否完成
    }
    // 执行回调
    callback(deadline);
  }
}

window.requestIdleCallback = function (cb) {
  requestAnimationFrame(rafTime => {
    // 结束时间点 = 开始时间点 + 一帧用时16.667ms
    deadlineTime = rafTime + 16.667;
    // 保存任务
    callback = cb;
    // 发送个宏任务
    port1.postMessage(null);
  })
}

三、requestHostCallback

由于 rAF 仰仗显示器的刷新频率,太过依赖设备本身运作流程,存在不稳定性。为了在每一帧尽可能多的执行任务,React 团队采用了 5ms 间隔的宏任务消息事件来发起任务调度

在 Scheduler 包中,SchedulerHostConfig.default.js 文件提供了 requestHostCallback 核心实现。

在源码中会根据环境实现两套 API:在不支持 MessageChannel 的环境下使用 setTimeout 作为备选方案,否则使用 MessageChannel,我们重点看这部分实现。

3.1、相关变量

export let requestHostCallback; // 处理 taskQueue 任务
export let cancelHostCallback;
export let requestHostTimeout; // 处理 timerQueue 任务
export let cancelHostTimeout;
export let shouldYieldToHost; // 是否让出主线程(currentTime >= deadline)
export let getCurrentTime; // 获取当前时间
export let forceFrameRate; // 根据 FPS 计算每一帧时长

Scheduler 会将任务分为两种类型:taskQueue 和 timerQueue,

  1. taskQueue 队列中存放的是需要立即执行的任务(已就绪任务);
  2. timerQueue 队列中存放的是可以延期执行的任务(未就绪任务)。

所以分别提供了两种调度任务方式:requestHostCallbackrequestHostTimeout

shouldYieldToHost 会被用在外部 workLoop 循环执行任务时,确定是否需要中断执行,让出主线程。

3.2、具体实现

在源码中,通过 MessageChannel 创建一个消息通道,当用户执行 requestHostCallback 调度 callback 时,便会通过 postMessage 发起一个宏任务进入 performWorkUntilDeadline 方法。

if (typeof window === 'undefined' || typeof MessageChannel !== 'function') {
  // 非浏览器环境,或不支持 MessageChannel,会使用 setTimeout 宏任务来实现
} else {
  // 保存 api 引用,防止 polyfill 覆盖它们
  const setTimeout = window.setTimeout;
  const clearTimeout = window.clearTimeout;

  getCurrentTime = () => performance.now(); // 页面加载后开始计算

  let isMessageLoopRunning = false; // 标记 MessageChannel 正在运行
  let scheduledHostCallback = null; // 要执行的处理函数
  let taskTimeoutID = -1; // 用作终止 setTimeout 延迟任务

  // 定义每一帧工作时间,默认时间为 5ms,React 会根据浏览器主机环境进行重新计算。
  let yieldInterval = 5;
  let deadline = 0; // 过期时间,让出主线程

  // 让出主线程
  shouldYieldToHost = function () {
    return getCurrentTime() >= deadline;
  };

  // (可选方法)默认空闲执行时间是5ms,用户可通过该方法来根据不同用户主机的设备刷新率(FPS)来计算预留时间
  forceFrameRate = function (fps) {
    if (fps < 0 || fps > 125) {
      return;
    }
    if (fps > 0) {
      yieldInterval = Math.floor(1000 / fps);
    } else {
      yieldInterval = 5;
    }
  };

  // 开启高频短间隔 5ms 执行工作
  const performWorkUntilDeadline = () => {
    ...
  };

  // 定义宏任务,建立通信
  const channel = new MessageChannel();
  const port = channel.port2; // 用于发布任务
  channel.port1.onmessage = performWorkUntilDeadline; // 处理任务

  requestHostCallback = function (callback) {
    scheduledHostCallback = callback; // 保存任务
    if (!isMessageLoopRunning) {
      isMessageLoopRunning = true;
      port.postMessage(null); // 发起宏任务
    }
  };

  cancelHostCallback = function () {
    scheduledHostCallback = null;
  };

  requestHostTimeout = function (callback, ms) {
    taskTimeoutID = setTimeout(() => {
      callback(getCurrentTime());
    }, ms);
  };

  cancelHostTimeout = function () {
    clearTimeout(taskTimeoutID);
    taskTimeoutID = -1;
  };
}

performWorkUntilDeadline 方法中,基于 yieldInterval 计算得到一个执行过期时间 deadline,也就是「高频短间隔 5ms」。

当用户在 callback 中通过调用 shouldYieldToHost() 发现执行时间过期且还存在未完成的任务,可在 callback 函数中返回 true,performWorkUntilDeadline 会先将主线程交给浏览器,再开启一个宏任务等待执行。

const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime(); // 拿到当前时间
    // 根据 yieldInterval(5ms)计算剩余时间(任务执行截止时间)。这种方式意味着 port.postMessage 开始后总有剩余时间
    deadline = currentTime + yieldInterval;
    // 标识还有时间,类似 requestIdleCallback deadline.didTimeout
    const hasTimeRemaining = true;
    try {
      const hasMoreWork = scheduledHostCallback(
        hasTimeRemaining,
        currentTime,
      );
      // 执行完成,没有新任务,初始化工作环境
      if (!hasMoreWork) {
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      } else {
        // 如果任务截止时间过期(根据 shouldYieldToHost()),还有需要处理的工作,再发起一个异步宏任务
        port.postMessage(null);
      }
    } catch (error) {
      port.postMessage(null);
      throw error;
    }
  } else {
    isMessageLoopRunning = false;
  }
};

利用宏任务异步的机制,以高频(短间隔)5ms 的方式去对任务进行切片执行,每隔 5ms 让出执行权给浏览器看它是否有渲染工作要做,浏览器做完工作或没有工作要做时,根据 EventLoop 的运行机制会再次进入到下一个宏任务中,接着上次的任务继续执行。

之所以选择宏任务,不选择微任务,是因为微任务会在页面更新前全部执行完成,无法做到将主线程交还给浏览器,而宏任务可以。

此外,setTimeout 仅作为宏任务的备选方案,这是因为:

当递归执行 setTimeout(fn, 0) 时,间隔会由最初的 1ms 变成 4ms。如果使用它来实现 Scheduler,就会浪费 4 毫秒。因为 60 FPS 下要求每帧间隔不超过 16.66 ms,所以 4ms 存在浪费。

你可以将下面代码运行在浏览器查看运行结果:

var count = 0;

var startVal = +new Date();
console.log("start time", 0, 0);
function func() {
  setTimeout(() => {
    console.log("exec time", ++count, +new Date() - startVal);
    if (count === 20) return;
    func();
  }, 0)
}

func();

四、使用

现在,我们通过一个简单 DOM 来验证一下 requestHostCallback 实现是否有用。

假设页面上有两个 DOM 节点,我们要去访问 DOM 属性(DOM 操作会影响程序执行效率),时间切片的任务数量为 5000 次。如果我们直接同步执行这 5000 次任务,会造成页面渲染卡顿大约 3s 时间(本机测试),在这期间无法对页面进行任何操作(如点击按钮)。代码如下:

<body>
  <button id="btn1">按钮1</button>
  <button id="btn2">按钮2</button>

  <script>
    let workIndex = 0;
    let taskTotal = 5000; // 任务数量
    const start = Date.now();
    function handleWork() {
      for (let j = 0; j < 4000; j ++) {
        // DOM 操作严重影响程序执行效率
        const btn1Attr = document.getElementById('btn1').attributes;
        const btn2Attr = document.getElementById('btn2').attributes;
        const btn3Attr = document.getElementById('btn1').attributes;
        const btn4Attr = document.getElementById('btn2').attributes;
      }
      workIndex ++;
      if (workIndex >= taskTotal) {
        console.log(`任务调度完成,用时:`, Date.now() - start, 'ms!');
      }
    }

    while (workIndex < taskTotal) {
      handleWork();
    }
  </script>
</body>

现在我们改用 requestHostCallback 高频 5ms 宏任务方式去执行,页面渲染不会出现卡顿,点击按钮能够正常响应。代码如下:

<script>
  let workIndex = 0;
  let taskTotal = 5000; // 任务数量
  const start = Date.now();
  function handleWork() {
    for (let j = 0; j < 4000; j ++) {
      // DOM 操作严重影响程序执行效率
      const btn1Attr = document.getElementById('btn1').attributes;
      const btn2Attr = document.getElementById('btn2').attributes;
      const btn3Attr = document.getElementById('btn1').attributes;
      const btn4Attr = document.getElementById('btn2').attributes;
    }
    workIndex ++;
    if (workIndex >= taskTotal) {
      console.log(`任务调度完成,用时:`, Date.now() - start, 'ms!');
    }
  }

  // while (workIndex < taskTotal) {
  //   handleWork();
  // }

  function workLoop() {
    // 执行 shouldYieldToHost 来判断本次宏任务的 高频(短间隔)5ms 时间切片是否用尽
    while (!shouldYieldToHost() && workIndex < taskTotal) {
      handleWork();
    }
    if (workIndex < taskTotal) {
      console.log(`开启下一个宏任务继续执行剩余任务`);
      return true;
    } else {
      return false;
    }
  }

  requestHostCallback(workLoop);
</script>

最后

感谢阅读,如有不足之处,欢迎指正。

借鉴:
1. 字节教育 - 实现 React requestIdleCallback 调度能力
2. React Scheduler 为什么使用 MessageChannel 实现
3. 探索 React 的内在 —— postMessage & Scheduler
4. React 技术揭秘