别再给主线程塞私活了!requestIdleCallback 让你优雅“偷懒”

0 阅读5分钟

引言

“我们页面加载完还要上报用户行为、预加载下一屏数据、提前解析埋点配置、顺便把离线包也更新一下……”

产品经理指着需求文档,一脸真诚地看着我:“这些都是必须做的,不影响首屏吧?”

我点点头:“不影响,都放 setTimeout 里延迟执行就行。”

一个月后,线上用户反馈:滚动页面总感觉“一卡一卡”的,尤其是点开大图的时候,浏览器像喝了假酒。

我打开 Performance 面板,好家伙,主线程的“任务清单”比我的购物车还长——原来那些“不重要的”延迟任务,全都在用户滚动时跑出来抢 CPU 了。

那天下午,我偶然发现了一个叫 requestIdleCallback 的 API,它让我学会了什么叫 “让浏览器有空了再干活”

一、问题的本质:setTimeout 的“自私”行为

setTimeout(fn, 0) 是我们常用的“让任务稍后执行”的办法。但它有一个问题:它只管在延迟结束后把任务塞进宏任务队列,不管浏览器现在忙不忙

如果用户正在滚动页面(每帧只有 16ms 的处理时间),而 setTimeout 里有一个耗时 50ms 的计算任务,就会直接挤占渲染时间,造成掉帧、卡顿。

换句话说,setTimeout 是个“不分场合”的积极员工——不管你忙不忙,它都要抢着干活。

requestIdleCallback 则完全不同:它会在浏览器 主线程空闲的时候 才执行你的任务。就像一个体贴的同事,看到你在忙,就说:“不急,你先忙完,我等你。”

二、初识 requestIdleCallback:浏览器的“空闲时间”

2.1 基本用法

requestIdleCallback(myIdleTask, { timeout: 2000 });

function myIdleTask(deadline) {
  // deadline.timeRemaining() 返回当前帧还剩余多少毫秒
  while (deadline.timeRemaining() > 0 && tasks.length > 0) {
    doOneTask(); // 每次做一小块工作
  }
  // 如果还有没做完的任务,再注册一次空闲回调
  if (tasks.length > 0) {
    requestIdleCallback(myIdleTask);
  }
}

requestIdleCallback 接收两个参数:

  • 回调函数:当空闲时执行,会传入一个 IdleDeadline 对象。
  • 可选配置{ timeout: ms },表示即使一直没有空闲时间,最多等待 ms 毫秒后也强制执行。

IdleDeadline 对象有两个关键成员:

  • timeRemaining():返回当前帧还剩多少毫秒(一般不超过 50ms)。
  • didTimeout:布尔值,表示是否因为 timeout 而强制触发。

2.2 应用场景

  • 非关键数据的预加载(如用户可能不会马上用到的内容)
  • 埋点上报(不急于立刻发出去的统计)
  • 预渲染下一页的模板
  • 离线缓存更新
  • 任何可以延后、分时执行的任务

三、实战:优化一个“滥用 setTimeout”的页面

3.1 优化前:塞满主线程的 setTimeout

// 页面加载后,要执行一大堆“不重要”的任务
window.addEventListener('load', () => {
  setTimeout(() => {
    // 解析埋点配置(50ms)
    parseAnalyticsConfig();
  }, 0);

  setTimeout(() => {
    // 预加载下一屏图片(20ms)
    preloadNextImages();
  }, 0);

  setTimeout(() => {
    // 检查版本更新(30ms)
    checkForUpdates();
  }, 0);
});

这些任务虽然都“延迟”到了 load 之后,但它们仍然会在 同一帧内 连续执行(因为 setTimeout 0 的回调会在下一个宏任务里依次执行),导致用户刚看到页面,一滚动就卡。

3.2 优化后:用 requestIdleCallback 分散执行

window.addEventListener('load', () => {
  const tasks = [
    parseAnalyticsConfig,
    preloadNextImages,
    checkForUpdates,
    // ... 更多
  ];

  function runTasks(deadline) {
    // 当还有剩余时间且还有任务时,执行任务
    while (deadline.timeRemaining() > 0 && tasks.length > 0) {
      const task = tasks.shift();
      task();
    }
    // 如果还有任务没做完,继续请求空闲回调
    if (tasks.length > 0) {
      requestIdleCallback(runTasks, { timeout: 5000 });
    }
  }

  requestIdleCallback(runTasks, { timeout: 5000 });
});

这样一来,每个空闲时间段只执行一小部分任务,不会长时间霸占主线程,滚动体验瞬间流畅。

四、深入原理:什么是“空闲时间”?

浏览器以 为单位进行渲染(通常 60fps,每帧约 16.6ms)。每一帧的工作流程大致是:

  1. 处理用户输入(点击、滚动等)
  2. 执行 JavaScript 任务(宏任务)
  3. 执行 requestAnimationFrame 回调
  4. 布局、绘制、合成
  5. 空闲时间:如果还有剩余时间(< 16.6ms),就执行 requestIdleCallback 任务

如果某一帧没有空闲时间(比如 JavaScript 任务耗时过长),空闲回调就会被推迟到下一帧,直到有空闲为止。

因此,requestIdleCallback 不会影响关键渲染和交互,是真正的“后台任务”。

五、注意事项与常见陷阱

5.1 不要在里面做 DOM 修改

如果在空闲回调里修改 DOM,可能会触发额外的回流和重绘,浪费宝贵的空闲时间。尽量只做数据处理、存储操作等不涉及可视区域变化的任务。

5.2 不要假设能执行完所有任务

timeRemaining() 通常最多给你 50ms(各浏览器实现略有差异)。你的任务必须能被 切分 成小段,每次执行一小部分,否则可能阻塞后续空闲回调。

5.3 低优先级任务才适合

不能把关键渲染逻辑放进去(比如动画更新),因为可能延迟很久才执行。如果你有一个任务必须在 1 秒内完成,一定要设置 timeout 兜底。

5.4 兼容性

requestIdleCallback 在 Safari 和 iOS 上不支持。可以用 setTimeout 做降级,也可以利用 requestAnimationFrame + performance.now 模拟一个简单的空闲检测。不过随着苹果的跟进,未来应该会全面支持。

降级示例:

const requestIdleCallback = window.requestIdleCallback || function(cb, options) {
  const start = Date.now();
  setTimeout(() => {
    cb({
      timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
      didTimeout: false
    });
  }, 0);
};

六、与其他 API 的对比

API执行时机适用场景
setTimeout延迟一段时间后(无论忙闲)延迟执行但无空闲控制
requestAnimationFrame下次重绘前动画、DOM 同步更新
requestIdleCallback浏览器空闲时后台非关键任务
setImmediate(Node)事件循环的下一个阶段类似但只在 Node 中有

七、总结:做一个“懂礼貌”的程序员

requestIdleCallback 不是万能的,但它给了我们一个优雅的方式去处理那些“不紧急但必须做”的任务。它就像一位情商高的同事,总是在大家不忙的时候才来请求帮助,既完成了工作,又不打扰他人。

下次当你想把一堆非关键任务塞进 setTimeout 的时候,不妨问问自己:“这些任务能不能等浏览器闲了再做?”如果可以,就用 requestIdleCallback 让你的页面更流畅。


每日一问:如果你有一个非常耗时的计算任务,必须执行,但又不能卡顿页面,除了用 Web Worker,你会如何结合 requestIdleCallback 来分片执行?评论区说说你的方案!