现代 Web 应用功能越来越丰富,但页面流畅度和首屏渲染速度往往成为用户体验的瓶颈。尤其当我们在页面加载完毕后,还需要做一些“非紧急”工作,比如埋点上报、懒加载资源、离线数据同步等,如果一股脑儿地放到主线程执行,就会引起卡顿、掉帧,甚至影响用户点击体验。
为此,HTML5 标准引入了 requestIdleCallback API,让我们能够在浏览器「空闲时期」调度低优先级任务,优雅地利用每一块空闲窗口,避免打扰关键渲染与交互。
下面,我们将从业务背景切入,依次介绍它的 API 用法和从简单到复杂的典型使用场景,帮助你在实际项目中轻松上手喵 (。•̀ᴗ-)✧。
API速览
// 注册一个空闲回调
const id = requestIdleCallback(cb, options);
// 取消回调
cancelIdleCallback(id);
cb:当浏览器空闲时要执行的函数,接收一个IdleDeadline对象options:一个对象,但目前只有一个属性,即timeout(毫秒),如果浏览器一直很忙,超过这个时间后,也会强制执行你的callback。- 返回值
id:表示此次回调任务的标识,用来取消调度
4是返回的id值,打印出来一个
deadline参数,也就是IdleDeadline,其中didTimeout属性标识回调是否在超时时间前已经执行,值为false的时候代表未超时,回调会在浏览器空闲时正常执行;值为true的时候代表已超时,等待timeout指定的毫秒数后会强制执行。timeRemaining函数标识当前闲置周期的预估剩余毫秒,我们不妨把它的返回值打印出来看一看:
6表示的是返回的
id,50代表预估的剩余毫秒数。
不过好像有个问题,一帧不是才16ms吗?50ms已经远大于16ms了,这是怎么回事?为了解答这个问题,我们不得不进一步研究下requestIdleCallback的执行时机⭐
执行时机分析
我们看到的网页都是由浏览器一帧一帧地绘制出来的,一帧包含用户的交互、js的执行以及requestAnimationFrame的调用,布局计算以及页面的重绘等工作。FPS指每秒钟页面呈现的帧数,用于衡量页面的流畅程度,通常认为FPS为60的时候是比较流畅。
浏览器每秒 60 帧(60Hz),每帧理想只花 16.67ms ,如果某一帧里面要执行的任务不多,在16ms内就完成任务的话,这一帧的空闲时间就可以用来执行
requestIdleCallback的回调。
程序栈为空页面屏幕不刷新时,浏览器处于空闲状态,Chrome 等浏览器会为每次 idle 回调分配最多 50ms 的预算时间。
👉
requestIdleCallback的调用时机是不确定的,每帧结束没有剩余时间就不会被执行,可能会间隔很久,也就是浏览器一直处于繁忙状态的情况,此时会导致回调一直无法执行,这种情况下我们就需要在调用时传入第二个参数timeout。
requestIdleCallback和requestAnimationFrame有什么区别🤔
requestIdleCallback 和 requestAnimationFrame 是两种浏览器提供的异步任务调度 API,它们在执行时机、优先级等地方存在本质区别。
1. 执行时机
requestAnimationFrame(简称 rAF)在下一帧渲染前执行,通常每 16.67ms(60Hz 屏幕)调用一次,用于执行与 UI 渲染密切相关的逻辑。requestIdleCallback(简称 rIC)在主线程空闲时执行,不依赖帧率,空闲时段由浏览器调度器动态决定。
2. 优先级
rAF是高优先级 API,适用于动画更新、页面布局、过渡效果等对帧率敏感的任务。rIC是低优先级 API,适用于后台任务,如数据清理、日志上报、预加载、非关键 DOM 操作等。
3. 参数形式
-
rAF回调函数接收一个高精度时间戳(DOMHighResTimeStamp),表示当前帧开始的时间。 -
rIC回调函数接收一个IdleDeadline对象,其中:timeRemaining()表示浏览器预估当前空闲期剩余的可用时间(单位:毫秒)didTimeout表示当前回调是否因为超时而被强制执行
4. 是否保证执行
rAF会在每一帧触发(除非标签页处于后台或被限制)。rIC不保证一定会执行,若主线程持续繁忙,则可能长时间不被调度。开发者可通过设置timeout参数确保任务在一定时间后被强制执行。
应用场景
requestIdleCallBack适用于预处理、埋点日志、延迟执行等场景。
-
✅预处理:当你需要处理一些无需立即展示的数据,就可以在空闲的时候预处理这些数据。
-
✅埋点日志:可以在浏览器空闲时上报,避免阻塞交互,不影响用户体验。
-
✅延迟执行:有一些并非需要立刻执行的代码可以用
requestIdleCallback来推迟这些任务执行。 -
❌操作dom/更新UI:
requestIdleCallback不适合去操作dom或者是更新UI等和用户交互行为相关的任务,因为执行时机不确定会造成响应迟钝等问题,影响用户体验。 -
❌耗时长任务:虽然是在浏览器空闲执行,但依然运行在主线程上。耗时的长任务同样会导致帧率降低,造成页面卡顿。
-
❌避免在空闲回调中改变 DOM:空闲回调执行的时候,当前帧已经结束绘制了,所有布局的更新和计算也已经完成。如果你做的改变影响了布局,你可能会强制停止浏览器并重新计算,而从另一方面来看,这是不必要的。如果你的回调需要改变 DOM,它应该使用
Window.requestAnimationFrame()来调度它。
队列任务处理
为了进一步理解requestIdleCallback,我们继续来看一下它是如何去处理队列任务的。
let taskHandle = null;
let taskList = [
() => {
console.log('task1')
},
() => {
console.log('task2')
},
() => {
console.log('task3')
}
]
function runTaskQueue(deadline) {
console.log(`deadline: ${deadline.timeRemaining()}`)
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && taskList.length) {
let task = taskList.shift();
task();
}
if (taskList.length) {
taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000} );
} else {
taskHandle = 0;
}
}
requestIdleCallback(runTaskQueue, { timeout: 1000 })
因为空余时间足够,三个任务会在同一帧执行。 执行结果如下:
如果任务时间比较久,就会被放到下一个空闲时间来执行。我们通过
sleep函数来模拟一下:
const sleep = delay => {
for (let start = Date.now(); Date.now() - start <= delay;) {}
}
let taskHandle = null;
let taskList = [
() => {
console.log('task1')
sleep(50)
},
() => {
console.log('task2')
sleep(50)
},
() => {
console.log('task3')
sleep(50)
}
]
function runTaskQueue(deadline) {
console.log(`deadline: ${deadline.timeRemaining()}`)
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && taskList.length) {
let task = taskList.shift();
task();
}
if (taskList.length) {
taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000} );
} else {
taskHandle = 0;
}
}
requestIdleCallback(runTaskQueue, { timeout: 1000 })
从执行结果中打印了三次的deadline可以看出,任务分别在三个空闲时间被执行。
兼容性
目前 requestIdleCallback 在 Safari(尤其是 iOS Safari)上的兼容性较差。
因此建议通过 polyfill 方式保证兼容性:
if (!window.requestIdleCallback) {
window.requestIdleCallback = function (cb) {
const start = Date.now();
return setTimeout(() => {
cb({
didTimeout: false,
timeRemaining: () => Math.max(0, 50 - (Date.now() - start))
});
}, 1);
};
window.cancelIdleCallback = function (id) {
clearTimeout(id);
};
}
不过以上并不是一个好的替代实现,因为使用setTimeout不能像requestIdleCallback能实现在空闲时段执行代码,只能保证将每次传递的运行时间控制在50ms内。