Skip to content

深入 React 调度器:任务调度器的选择取舍

一帧中的任务

通常对于浏览器这一宿主环境来说,一帧中的任务会包含 事件循环 tick(执行一个宏任务,然后清空所有已注册的微任务)、浏览器渲染周期 api 以及 浏览器渲染任务,整体执行流程如下:

  1. 执行一个宏任务 (Task): 从宏任务队列中选择一个最旧的、可运行的宏任务并执行它。这些宏任务通常包含 setTimeoutsetIntervalMessageChannelI/O 操作UI 交互事件 等回调函数。
  2. 执行所有微任务 (Microtask Checkpoint): 执行当前宏任务执行完毕后存在的所有微任务。如果微任务执行过程中又添加了新的微任务,则继续执行,直到微任务队列为空。这些微任务通常包含 Promise.resolve().then(fn)queueMicrotask(fn)MutationObserver 等回调函数。
  3. 更新渲染 (Update the Rendering - 按需)
    1. 执行所有 requestAnimationFrame (rAF) 回调。
    2. 执行样式计算、布局、合成和绘制等渲染步骤 (如果浏览器确定需要渲染)。
  4. 执行 requestIdleCallback 回调 (按需且有空闲时间): 如果当前帧有空闲时间,并且 requestIdleCallback 队列中有回调,则执行它们。
例子说明
test.js
js
const channel = new MessageChannel();
channel.port1.onmessage = ({ data: { message } }) => {
  console.log('MessageChannel Callback  ', message);
};
const callback = () => {
  requestIdleCallback(() => {
    console.log('requestIdleCallback');
    Promise.resolve().then(res => {
      console.log('requestIdleCallback - Promise');
    });
    channel.port2.postMessage({ message: 'MessageChannel - 3' });
  });
  requestAnimationFrame(() => {
    channel.port2.postMessage({ message: 'MessageChannel - 2' });
    console.log('requestAnimationFrame');
    Promise.resolve().then(res => {
      console.log('requestAnimationFrame - Promise');
    });
  });
  Promise.resolve().then(res => {
    console.log('Promise');
    channel.port2.postMessage({ message: 'MessageChannel - 1' });
  });
};
callback();

上述例子的 输出结果 为:

bash
Promise
requestAnimationFrame
requestAnimationFrame - Promise
MessageChannel Callback   MessageChannel - 1
MessageChannel Callback   MessageChannel - 2
requestIdleCallback
requestIdleCallback - Promise
MessageChannel Callback   MessageChannel - 3

解释

初始状态: 所有任务队列均为空。

  1. Tick 1: 初始脚本执行

    • 宏任务: 主线程开始执行全局脚本,这是第一个宏任务。
      • callback() 函数被调用。
      • Promise.resolve().then(...) 注册一个 微任务 A
      • requestIdleCallback(...) 注册一个 Idle 宏任务Idle 任务队列
      • requestAnimationFrame(...) 注册一个 rAF 宏任务动画帧回调队列
    • 宏任务结束: 脚本同步代码执行完毕。
    • 微任务阶段: 清空微任务队列。
      • 执行 微任务 A 的回调,控制台输出 Promise 日志(✅)。
      • 执行 channel.port2.postMessage({ message: 'MessageChannel - 1' }),注册一个 宏任务 M1标准任务队列
    • Tick 1 执行结束
      • 当前队列状态:
        • rAF 队列: [rAF 回调]
        • 任务队列: [M1]
        • Idle 队列: [Idle 回调]
  2. Tick 2: 动画帧回调

    • 宏任务: 事件循环进入下一轮。根据优先级,浏览器准备渲染更新,因此从 rAF 队列 中取出 rAF 宏任务 执行。
      • channel.port2.postMessage({ message: 'MessageChannel - 2' }) 被调用,注册一个宏任务 M2 到标准任务队列。
      • 控制台输出 requestAnimationFrame 日志(✅)。
      • Promise.resolve().then(...) 注册一个微任务 B
    • 宏任务结束: rAF 回调执行完毕。
    • 微任务阶段: 清空微任务队列,执行 微任务 B 的回调,控制台输出 requestAnimationFrame - Promise 日志(✅)。
    • Tick 2 结束
      • 当前队列状态:
        • 任务队列: [M1, M2]
        • Idle 队列: [Idle 回调]
  3. Tick 3: 第一个 MessageChannel 消息

    • 宏任务: 事件循环从标准任务队列中按先进先出原则取出 宏任务 M1 执行。
      • channel.port1.onmessage 回调被触发,控制台输出 MessageChannel Callback MessageChannel - 1 日志(✅)。
    • 宏任务结束: M1 回调执行完毕。
    • 微任务阶段: 微任务队列为空,跳过。
    • Tick 3 结束
      • 当前队列状态:
        • 任务队列: [M2]
        • Idle 队列: [Idle 回调]
  4. Tick 4: 第二个 MessageChannel 消息

    • 宏任务: 事件循环继续从标准任务队列中取出 宏任务 M2 执行。
      • channel.port1.onmessage 回调被触发。
      • 控制台输出 MessageChannel Callback MessageChannel - 2 日志(✅)。
    • 宏任务结束: M2 回调执行完毕。
    • 微任务阶段: 微任务队列为空,跳过。
    • Tick 4 结束
      • 当前队列状态:
        • 任务队列: []
        • Idle 队列: [Idle 回调]
  5. Tick 5: 空闲状态回调 (requestIdleCallback)

    • 宏任务: 事件循环发现高优先级队列已空,且浏览器处于空闲状态。因此,从 Idle 任务队列 中取出 Idle 宏任务 执行。
      • 控制台输出 requestIdleCallback 日志(✅)。
      • 执行 Promise.resolve().then(...) 注册一个微任务 C
      • 执行 channel.port2.postMessage({ message: 'MessageChannel - 3' }),注册一个宏任务 M3 到标准任务队列。
    • 宏任务结束: Idle 回调执行完毕。
    • 微任务阶段: 清空微任务队列。
      • 执行 微任务 C 的回调,控制台输出 requestIdleCallback - Promise 日志(✅)。
    • Tick 5 结束
      • 当前队列状态:
        • 任务队列: [M3]
        • Idle 队列: []
  6. Tick 6: 第三个 MessageChannel 消息

    • 宏任务: 事件循环从标准任务队列中取出宏任务 M3 执行。
      • channel.port1.onmessage 回调被触发。
      • 控制台输出 MessageChannel Callback MessageChannel - 3 日志(✅)。
    • 宏任务结束: M3 回调执行完毕。
    • 微任务阶段: 微任务队列为空,跳过。
    • Tick 6 结束
      • 当前队列状态: 所有不同种类的任务队列均为已清空,任务执行完成。

流程总结

Tick宏任务类型控制台输出
1初始脚本(无)
2requestAnimationFramerequestAnimationFrame requestAnimationFrame - Promise
3MessageChannel (第一个)MessageChannel Callback MessageChannel - 1
4MessageChannel (第二个)MessageChannel Callback MessageChannel - 2
5requestIdleCallbackrequestIdleCallback requestIdleCallback - Promise
6MessageChannel (第三个)MessageChannel Callback MessageChannel - 3

接下来将 requestAnimationFramerequestIdleCallback 统称为 环境渲染任务(即浏览器渲染周期 api 调度的任务)。那么对于浏览器宿主环境的任务类型来说,可以划分为 宏任务微任务环境渲染任务 三类任务类型,后续的内容就对这三类任务做具体的可行性分析。

可行性分析

首先需要先确定一下是要使用宏任务,还是微任务。

微任务的可行性分析

react 需要具备并发执行的特性,并发就意味着单个事件循环 tick 中只能执行部分任务,剩余的任务分配给后续多个事件循环 tick 中执行。

在单个事件循环 tick 中,是会清空所有的微任务队列。若使用调度采用微任务,那么就需要做到最基本的一件事情是,如何在这一个事件循环 tick 中执行受限微任务,在后续的事件循环 tick 中再执行剩余的受限微任务。

直接在一个事件循环 tick 中推送多个微任务是不切实际的,这些微任务都会被执行掉,这是同步调度而非并发调度。并发的意义在于可以控制一帧任务中控制要执行哪些任务。

那么是否宿主环境中存在能检测事件循环 tick 开始的时机,在这个时机中提前注册要执行的微任务似乎也能满足要求。但不幸的是,宿主环境中并没有提供观测事件循环 tick 开始时机的钩子,这对于 react 来说就无法通过微任务得知下一个事件循环 tick 的时机。

既然没有的话,那么就需要微任务依附在环境 api,跟随环境 api 的执行时机而触发调度,接下来分析一下环境 api(requestAnimationFramerequestIdleCallback)。

浏览器渲染任务的可行性分析 (rAF / rIC)

测试用例
test.js
js
let startTime = null;
let exCount = 0;
const handler = function (info) {
  return function (count) {
    console.log(
      `VI-LOG_${info}_${count} -- Time Overhead: `,
      new Date().getTime() - startTime
    );
  };
};
const requestIdleCallbackOnMessage = handler('requestIdleCallback');
const requestAnimationFrameOnMessage = handler('requestAnimationFrame');
const channel = new MessageChannel();
channel.port1.onmessage = function wrap({ data }) {
  const { count } = data;
  channelOnMessage(count);
};

const triggerMessageChannel = count => {
  channel.port2.postMessage({ count });
};
const callback = exCount => {
  startTime = new Date().getTime();
  requestIdleCallback(requestIdleCallbackOnMessage.bind(null, exCount));
  requestAnimationFrame(requestAnimationFrameOnMessage.bind(null, exCount));
};
let setInternalTime = new Date().getTime();
setInterval(() => {
  console.log(
    `------------------------------------ VI-LOG_Test_Start: ${++exCount} ------------------------------------`
  );
  console.log(
    `VI-LOG_setInterval_${exCount} -- Time Overhead: `,
    new Date().getTime() - setInternalTime
  );
  callback(exCount);
  setInternalTime = new Date().getTime();
}, 2000);
前提要知
- 基线: chrome 137.0.7151.57 (Official Build) (arm64)
- 第 10 次迭代: 切换到其他标签页
- 第 113 次迭代: 重新聚焦到当前标签页
  1. requestAnimationFrame 的执行行为:

    • 前台: 回调函数会在浏览器下一次重绘之前执行。这通常与显示器的刷新率同步(例如 60Hz 对应约 16.7ms 一帧)。日志中,requestAnimationFramesetInterval 回调之后几毫秒内执行,符合预期。
    • 后台: 当标签页不是激活状态时,浏览器会暂停 requestAnimationFrame 的回调执行。所有通过 requestAnimationFrame 调度的回调会被加入队列,直到标签页恢复激活状态。
    • 重新聚焦: 一旦标签页重新激活,所有在后台期间累积的 requestAnimationFrame 回调会在短时间内集中执行完毕,然后再开始处理新的 requestAnimationFrame 请求。
    • 可行性分析
      • 后台 tab 暂停:当 tab 进入后台时,requestAnimationFrame 的回调会完全暂停执行。若 react 的核心工作循环完全依赖 requestAnimationFrame,那么在 tab 后台时,所有 react 更新(包括 状态更新副作用处理 等)都会停止。这对于某些需要后台保持一定活性的应用(如 消息通知数据预取后更新状态 等场景)是不可接受的。
      • 用途requestAnimationFrame 调度完成后会执行浏览器渲染工作,这确实适合 reactcommit 阶段的同步任务。但 react 并不仅有与渲染相关的任务,还存在大量副作用的任务,对于这些副作用的任务的处理放置在 requestAnimationFrame 中并不合理。
  2. requestIdleCallback 的执行行为:

    • 前台: 回调函数会在浏览器主线程处于空闲时期时被调用。这允许执行一些低优先级的后台任务而不会影响用户体验(如动画流畅性)。

    • 后台: requestIdleCallback 的行为与 requestAnimationFrame 不同。即使在后台,如果浏览器判断有空闲资源,requestIdleCallback 的回调仍然可能被执行。不过,后台 requestIdleCallback 的执行频率和可用的 timeRemaining() 可能会受到限制。

    • 重新聚焦: 类似于 requestAnimationFrame,如果在后台期间有 requestIdleCallback 回调被调度但因某些原因(如没有足够的空闲时间或浏览器策略)未能立即执行,它们也可能在 Tab 重新激活后得到执行。

    • 可行性分析

      • 后台 tab 暂停requestIdleCallback 在后台 tab 中仍有机会执行,尽管频率和可靠性可能降低。这比 requestAnimationFrame 的完全暂停要好,requestIdleCallback 执行时机是不稳定的,只有在浏览器空闲时才会执行,可以视作低优先级任务,容易被浏览器其他任务占用主线程而导致饥饿问题。这对于需要及时响应的用户交互(如输入框反馈)或高优先级更新是不可接受的。

      • 利用空闲时间requestIdleCallback 允许在浏览器主线程空闲时执行任务,这其实是符合 react 的并发可中断渲染。同时还提供 deadline.timeRemaining() 机制,有利于协助 react 判断当前帧有多少剩余时间可用于执行工作。

      • 兼容性问题safari v18.5 版本默认是关闭 requestIdleCallback 特性,IEOpera Mini 完全不支持。用户采用率仅 78.95%

      • Hack 行为

        requestIdleCallback 可以通过配置 timeout 参数来使低优先任务超时而推送到宿主环境的宏任务队列。与此同时,经测试发现当配置 timeout > 0 时(timeout = 0 时,后台任务会堆积后聚焦时一次性执行),后台执行触发的时机也相对稳定。

        因此若场景中无需考虑 requestIdleCallback 的兼容性和跨平台问题,通过 requestIdleCallback(fn, { timeout: 1 }) 也是可以在一定程度上完成调度任务。

        但这是一种 hack 行为,与 requestIdleCallback 的设计初衷相悖。requestIdleCallback 的设计初衷是将任务视为低优先级任务调度,当宿主环境空闲时或任务饥饿时进行调度,而 requestIdleCallback(fn, { timeout: 1 }) 的意义则视为尽快将任务推送到宿主环境的宏任务队列中,等待宿主环境调度。

        react 希望能够 快速响应并执行其内部最高优先级任务,虽然能通过 hack 行为来完成调度,但 timeout: 1 推送宏任务的积极性并没有 setTimeout(fn, 0)new MessageChannel() 高。与此同时 hack 方式与 requestIdleCallback 的初衷相悖,再接着 requestIdleCallback 又存在兼容性和跨平台性问题,因此选择采纳 requestIdleCallback 并不是一个很好的选择。

综上分析可知,requestAnimationFramerequestIdleCallback 并不适合作为 react 通用场景下的任务调度器。讨论到此,宿主环境任务 以及 微任务 均不适合。

那么接下来就对宏任务进行分析吧。对于 react 来说是需要一个稳定且高效的调度机制,宏任务本身就适合作为调度机制。每一次事件循环 tick 中,浏览器都会从任务队列中取一个宏任务执行,这个过程通常情况下是稳定且可控的。

那么接下来就对宏任务进行分析吧。

宏任务的可行性分析

测试用例
test.js
js
let startTime = null;
let exCount = 0;
const handler = function (info) {
  return function (count) {
    console.log(
      `VI-LOG_${info}_${count} -- Time Overhead: `,
      new Date().getTime() - startTime
    );
  };
};
const channelOnMessage = handler('MessageChannel');
const setTimeoutPreOnMessage = handler('setTimeout pre');
const setTimeoutPostOnMessage = handler('setTimeout post');
const channel = new MessageChannel();
channel.port1.onmessage = function wrap({ data }) {
  const { count } = data;
  channelOnMessage(count);
};

const triggerMessageChannel = count => {
  channel.port2.postMessage({ count });
};
const callback = exCount => {
  startTime = new Date().getTime();
  setTimeout(setTimeoutPreOnMessage.bind(null, exCount), 0);
  triggerMessageChannel(exCount);
  setTimeout(setTimeoutPostOnMessage.bind(null, exCount), 0);
};
let setInternalTime = new Date().getTime();
setInterval(() => {
  console.log(
    `------------------------------------ VI-LOG_Test_Start: ${++exCount} ------------------------------------`
  );
  console.log(
    `VI-LOG_setInterval_${exCount} -- Time Overhead: `,
    new Date().getTime() - setInternalTime
  );
  callback(exCount);
  setInternalTime = new Date().getTime();
}, 2000);
前提要知
- 基线: chrome 137.0.7151.57 (Official Build) (arm64)
- 第 10 次迭代: 切换到其他标签页
- 第 53 次迭代: 重新聚焦到当前标签页

根据图表可分析出,在后台需要持续进行精确、低延迟任务的场景中,setTimeoutsetInterval 是不可靠的,会受到浏览器节流策略的严重影响。而 MessageChannel 作为一种宏任务,无论标签页是在前台还是后台,MessageChannel 的时间开销始终保持在 0-2ms 之间,表现是三者中最稳定、最高效的。因此 MessageChannel 是实现可靠、高性能任务调度的理想选择。

注意

浏览器在标签页退后台后会对 setTimeout(fn, 0) 进行显著的节流(throttling),导致其延迟通常增加到约 1s 左右,当处于后台的时间达到 12.4min 左右,会将延迟时间提升到 1min 左右。相比之下,MessageChannel 的消息传递即便在标签页后台状态下,其回调执行相对稳定,虽然在非激活 tab 场景下偶尔会存在回调延迟问题(超过 1s),但基本稳定在 10ms 以下。标签页重新激活后,setTimeout 的响应会恢复。

那么接下来分析一下 react 是如何选择调度机制的。

react 的选择策略

reactscheduler 包中也是采用宏任务作为任务调度,代码如下:

scheduler.js
js
// Capture local references to native APIs, in case a polyfill overrides them.
const localSetTimeout =
  typeof setTimeout === 'function' ? setTimeout : null;
const localSetImmediate =
  typeof setImmediate !== 'undefined' ? setImmediate : null; // IE and Node.js + jsdom
const performWorkUntilDeadline = () => {
  if (typeof localSetImmediate === 'function') {
    // Node.js and old IE.
    // There's a few reasons for why we prefer setImmediate.
    //
    // Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
    // (Even though this is a DOM fork of the Scheduler, you could get here
    // with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
    // https://github.com/facebook/react/issues/20756
    //
    // But also, it runs earlier which is the semantic we want.
    // If other browsers ever implement it, it's better to use it.
    // Although both of these would be inferior to native scheduling.
    schedulePerformWorkUntilDeadline = () => {
      localSetImmediate(performWorkUntilDeadline);
    };
  } else if (typeof MessageChannel !== 'undefined') {
    // DOM and Worker environments.
    // We prefer MessageChannel because of the 4ms setTimeout clamping.
    const channel = new MessageChannel();
    const port = channel.port2;
    channel.port1.onmessage = performWorkUntilDeadline;
    schedulePerformWorkUntilDeadline = () => {
      port.postMessage(null);
    };
  } else {
    // We should only fallback here in non-browser environments.
    schedulePerformWorkUntilDeadline = () => {
      // $FlowFixMe[not-a-function] nullable value
      localSetTimeout(performWorkUntilDeadline, 0);
    };
  }
};

从代码注释和代码逻辑可以看出,react 选择的优先级顺序是:setImmediate > MessageChannel > setTimeout

node.js 宿主环境中,首选 setImmediate 的原因在于 更符合语义执行更早setImmediate 会在当前事件循环的 I/O 事件之后立即执行,比 setTimeout(..., 0) 更早,更符合 立即执行下一个宏任务 的需求。MessageChannel 虽然在 node.js v15+ 中可用,但它会阻止进程在没有其他任务时自动退出,而 setImmediate 不会。这对于服务端渲染 (ssr) 场景至关重要。

在浏览器宿主环境中,MessageChannelsetTimeout 拥有更优选择的原因在于规避 setTimeout4ms 延迟。浏览器为了节能和防止嵌套的 setTimeout 造成性能问题,会对 setTimeout(..., 0) 设置一个最小延迟(通常是 4ms,现代浏览器对其做了优化)。而 MessageChannel 是一个宏任务(Macrotask),可以实现几乎 零延迟 推送宏任务到宿主的任务队列中,等待宿主环境调度。

在通用的宿主环境中,即使 setTimeout 存在一定延迟,但这是所有环境中最通用的异步 api

贡献者

页面历史

Discuss

根据 CC BY-SA 4.0 许可证发布。 (e35f943)