Published on

requestIdleCallback:浏览器空闲时间任务调度

在构建高性能 Web 应用时,确保主线程 (main thread) 的流畅、不被阻塞至关重要,因为主线程负责处理用户输入、执行动画和渲染更新。然而,许多应用都需要执行一些非关键的、可推迟的后台任务(如发送分析数据、预加载非可视区域数据等)。window.requestIdleCallback() 是一个由浏览器提供的、低优先级的任务调度 API,它允许开发者在浏览器主线程处于空闲 (idle) 状态时,安全地执行这些任务,从而避免对关键的用户交互和渲染性能产生负面影响。

核心机制:利用帧的空闲时间

要理解 requestIdleCallback,必须首先理解浏览器的渲染帧 (render frame) 及其时间预算。

渲染帧与空闲时间 (Idle Time)

浏览器以离散的帧来更新屏幕(通常为 60fps,即每帧约 16.67ms)。在一帧的时间预算内,浏览器需要完成 JavaScript 执行、样式计算、布局、绘制和合成等一系列工作。如果这些关键任务提前完成,该帧剩余的时间就构成了空闲时间requestIdleCallback 的回调函数,正是在这个空闲时段被调用的。

API 签名与 IdleDeadline 对象

requestIdleCallback 的 API 签名如下:

const handle = window.requestIdleCallback(callback, options?);
  • callback: 一个在浏览器空闲时将被调用的函数。
  • options (可选): 一个配置对象。

callback 会接收一个 IdleDeadline 对象作为其唯一参数,这个对象提供了关于当前空闲时间的重要信息。

IdleDeadline 对象

  • timeRemaining(): 一个方法,返回一个 DOMHighResTimeStamp,表示当前帧还剩余多少毫秒可供回调函数执行。
  • didTimeout: 一个布尔值,表示回调的执行是否因为超过了在 options 中设置的 timeout 而被强制触发。

实践模式:协作式任务调度

requestIdleCallback 的设计哲学是协作式调度 (cooperative scheduling)。开发者有责任确保回调函数不会长时间运行,以免阻塞下一帧的关键任务。

requestIdleCallback 的常见误区

即使 deadline.timeRemaining() 返回 0,如果你的回调函数已经开始执行,它也不会被中断,而是会继续运行直到完成。因此,如果在回调中执行一个同步的、耗时很长的任务,依然会阻塞主线程,导致页面卡顿。

正确的实践模式是,将一个长任务分解为多个小任务块,并利用 timeRemaining() 来判断是否还有时间执行下一个任务块。

处理任务队列的最佳实践

// 1. 维护一个任务队列
const tasks = [
  () => console.log('Task 1 executed'),
  () => console.log('Task 2 executed'),
  () => { for (let i = 0; i < 1e6; i++) {} console.log('Task 3 (heavy) executed'); },
  () => console.log('Task 4 executed'),
];

function processTaskQueue(deadline) {
  console.log(`Entering idle callback. Time remaining: ${deadline.timeRemaining().toFixed(2)}ms`);

  // 2. 在有空闲时间且队列不为空时,循环处理任务
  while (deadline.timeRemaining() > 0 && tasks.length > 0) {
    const task = tasks.shift();
    task();
  }

  // 3. 如果任务还未处理完,则调度下一次 idle callback
  if (tasks.length > 0) {
    console.log('Tasks remain, scheduling next idle callback.');
    requestIdleCallback(processTaskQueue);
  } else {
    console.log('All tasks finished.');
  }
}

// 启动第一个 idle callback
requestIdleCallback(processTaskQueue);

这个模式确保了每个任务块只在浏览器有足够空闲时间时才执行,实现了真正的“见缝插针”式后台处理。

timeout 选项的权衡

  • 问题: 浏览器的空闲时间是不被保证的。如果一个页面持续有高优先级的任务(如连续的动画),那么 requestIdleCallback 注册的回调可能永远不会被执行。
  • 解决方案: 在 options 对象中设置一个 timeout 属性。
requestIdleCallback(myTask, { timeout: 2000 }); // 2秒超时
  • 机制: 如果设置了 timeout,并且回调函数因为缺少空闲时间而在指定的毫秒数后仍未被执行,那么浏览器会在下一个事件循环中强制执行该回调,无论主线程是否空闲。此时,deadline.didTimeout 将为 true
timeout 的风险

使用 timeout 是一种权衡。它保证了任务的最终执行,但也牺牲了 requestIdleCallback 的核心优势——不阻塞用户。强制执行的回调可能会在关键时刻(如用户正在交互时)运行,从而导致可感知的延迟或卡顿。因此,它只应用于那些“低优先级,但不能无限期延迟”的任务。

cancelIdleCallback()

setTimeout 类似,requestIdleCallback 会返回一个 ID,可以通过 cancelIdleCallback(id) 来取消尚未执行的回调。