善用 requestIdleCallback:在浏览器空闲时悄悄执行低优先级任务
善用 requestIdleCallback:在浏览器空闲时悄悄执行低优先级任务
不要让非紧急逻辑抢占主线程,把“剩下的时间”留给用户真正关心的交互。
前端性能优化中,我们经常谈论如何减少重排重绘、拆分长任务。但还有一个容易被忽视的利器:requestIdleCallback。它能让我们在浏览器真正“空闲”的间隙里,执行那些不重要但必须完成的工作,从而最大限度地保证页面的流畅度。
一、requestIdleCallback 是什么?
requestIdleCallback 是一个浏览器 API,会在浏览器完成一帧的渲染工作、且还有剩余时间时,执行我们传入的回调函数。
它的语法很简单:
const idleCallbackId = requestIdleCallback(callback, options);
callback:一个接收IdleDeadline对象的函数,我们可以通过它查询剩余空闲时间。options:可选的{ timeout },指定最长等待时间(毫秒),超时后即使没有空闲也会强制执行回调。
IdleDeadline 对象有两个关键属性和方法:
deadline.timeRemaining():返回当前空闲周期内剩余的预估毫秒数(最多 50ms)。如果时间用完了,就应该主动让出主线程。deadline.didTimeout:布尔值,表示回调是否因超时而被强制执行。
二、与 requestAnimationFrame 的区别
很多开发者会混淆这两个 API,它们的分工完全不同:
| 特性 | requestAnimationFrame | requestIdleCallback |
|---|---|---|
| 触发时机 | 每一帧渲染之前 | 帧渲染完成后、有空闲时 |
| 优先级 | 高,与帧渲染同步 | 低,闲置时才执行 |
| 用途 | 动画、视觉更新、DOM 批量修改 | 日志上报、预加载、数据同步 |
| 执行保障 | 一定会执行(若页面可见) | 可能因一直繁忙而延迟,除非设置 timeout |
简单来说:动画和视觉工作用 rAF,非视觉的“杂务”用 rIC。
三、一个真实的场景:批量埋点上报
假设我们有一个用户行为埋点队列,为了避免频繁发送请求,我们希望将事件合并后再上报。这个上报操作并不紧急,完全可以等到浏览器空闲时再做。
const eventQueue = [];
const MAX_BATCH = 20;
// 收集事件
function trackEvent(event) {
eventQueue.push({
type: event.type,
timestamp: Date.now(),
data: event.detail
});
// 如果队列堆积足够多,可以提前上报,避免占用过多内存
if (eventQueue.length >= MAX_BATCH) {
flushEvents();
return;
}
// 请求空闲回调
scheduleIdleFlush();
}
let flushScheduled = false;
function scheduleIdleFlush() {
if (flushScheduled) return;
flushScheduled = true;
requestIdleCallback((deadline) => {
// 可以分多个空闲周期处理,防止长时间占用主线程
while (eventQueue.length > 0 && deadline.timeRemaining() > 0) {
const batch = eventQueue.splice(0, MAX_BATCH);
sendBatch(batch);
}
// 如果还有剩余事件,继续请求下一次空闲
if (eventQueue.length > 0) {
flushScheduled = false;
scheduleIdleFlush();
} else {
flushScheduled = false;
}
}, { timeout: 5000 }); // 最多等 5 秒,避免事件永远发不出去
}
function sendBatch(batch) {
console.log('上报事件批次:', batch.length, '条');
// 实际发送 fetch 或 navigator.sendBeacon
}
// 模拟收集事件
['click', 'scroll', 'input'].forEach((type) => {
document.addEventListener(type, (e) => trackEvent({ type, detail: e.type }));
});
关键点解析
- 利用
timeRemaining()将长任务切分成小片段,每个空闲周期只处理一小批。 - 配合
timeout确保数据不会无限期推迟。 - 使用
flushScheduled标志防止重复请求回调。
四、更优雅的调度:结合 requestAnimationFrame
有时我们想执行一些“预渲染”的工作,比如提前计算下一屏数据的布局。这类任务虽然不需要立刻上屏,但与即将到来的帧有关。可以结合 rAF 在帧之前准备数据,再利用 rIC 执行剩余清理:
let pendingData = null;
function prefetchData() {
// 模拟异步获取数据
fetch('/api/next-page').then(res => res.json()).then(data => {
pendingData = data;
scheduleProcessing();
});
}
function scheduleProcessing() {
requestAnimationFrame(() => {
// 在帧开始前,如果有数据就先快速处理一部分(如计算位置)
if (pendingData) {
preprocess(pendingData);
}
// 剩下不紧急的缓存写入等工作,留给空闲时间
requestIdleCallback(() => {
if (pendingData) {
cacheData(pendingData);
pendingData = null;
}
});
});
}
五、注意事项与踩坑
-
不要在 rIC 里操作 DOM
空闲回调执行时,很可能浏览器已经完成了本轮布局和绘制。此时修改 DOM 可能触发强制重排,抵消性能收益。DOM 相关工作请交给requestAnimationFrame。 -
没有空闲时,回调可能永远不执行(除非设了 timeout)
如果页面持续有复杂的动画或用户输入,空闲时间可能一直为 0。务必为关键任务(如数据上传)加上合理的 timeout。 -
兼容性处理
requestIdleCallback的浏览器支持已经很好,但对于老旧环境,可以使用setTimeout模拟一个降级方案:
window.requestIdleCallback = window.requestIdleCallback ||
function(cb, options) {
const start = Date.now();
return setTimeout(() => {
cb({
didTimeout: true,
timeRemaining: () => Math.max(0, 50 - (Date.now() - start))
});
}, (options && options.timeout) || 1);
};
window.cancelIdleCallback = window.cancelIdleCallback || clearTimeout;
结语
requestIdleCallback 体现了一种“非侵入式”的性能优化哲学:在保证核心体验流畅的前提下,默默完成那些“锦上添花”的工作。结合有策略的调度和超时控制,它能帮助你把主线程的每一毫秒都用到极致,而用户几乎感知不到任何额外开销。
下次当你准备执行日志上报、数据预加载或统计计算时,不妨想一想:这件事,真的需要立刻做吗?
如果这篇分享对你有启发,欢迎在实际项目中尝试并反馈效果。