引言
“我们页面加载完还要上报用户行为、预加载下一屏数据、提前解析埋点配置、顺便把离线包也更新一下……”
产品经理指着需求文档,一脸真诚地看着我:“这些都是必须做的,不影响首屏吧?”
我点点头:“不影响,都放 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)。每一帧的工作流程大致是:
- 处理用户输入(点击、滚动等)
- 执行 JavaScript 任务(宏任务)
- 执行 requestAnimationFrame 回调
- 布局、绘制、合成
- 空闲时间:如果还有剩余时间(< 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 来分片执行?评论区说说你的方案!