你有没有在需求评审之际,胸脯一拍声称“不影响首屏”,然而上线之后却遭用户吐槽“卡顿得跟喝了假冒伪劣酒类一样”?问题常常出在那些被你凭借setTimeout随意扔到队列当中的“并非关键的任务”——它们看上去像是延迟执行,实际上在用户进行滚动、点击的关键时候冒出来争抢CPU,将主线程充斥得满满当当。弄明白浏览器的“空闲时间”机制,是解决这类问题的关键所在。
前端开发里,处理“稍后执行”的惯用办法是setTimeout(fn, 0)。不过呢,它有个核心不足:它只会在指定延迟后,把回调函数放入宏任务队列,对浏览器当下的负载状况完全不在意。用户执行滚动操作时,每一帧只有大概16.6毫秒的渲染时间窗口。要是在这个时候,setTimeout队列里头正好存在着一个会耗费50毫秒来进行计算的任务,那么它就会直接去抢占原本应当被用于布局以及绘制的资源,进而致使页面出现掉帧的情况,出现能够被肉眼清晰看见的卡顿现象。也就是说,setTimeout是一名“不讲究场合”的员工,不管主线程究竟有多忙,它都会强行把任务给插入进去。与之截然不同的是requestIdleCallback,它会在浏览器主线程进入空闲状态之际才去执行任务,宛如一位通情达理的同事,于你忙碌之时静静等候。
requestIdleCallback将一个回调函数当作首个参数来接受,此回调函数会获取一个IdleDeadline对象。借助这个对象,你能够调用timeRemaining()方法,以此知晓当下这一帧还剩余多少空闲时间(一般不超过50毫秒)。针对这种情况,我们可以这样来改写:第二个可以选择的参数是timeout,它被用来设置一个最大的等待时间,要是在这个时间之内,浏览器一直都没有处于空闲状态,那么任务就会被强制性地放置于下一帧去执行,这样的一种设计,保证了非关键任务,既不会对用户交互造成干扰,同时又能够在规定的时间范围之内去完成。
requestIdleCallback(myIdleTask, { timeout: 2000 });
function myIdleTask(deadline) {
// deadline.timeRemaining() 返回当前帧还剩余多少毫秒
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
doOneTask(); // 每次做一小块工作
}
// 如果还有没做完的任务,再注册一次空闲回调
if (tasks.length > 0) {
requestIdleCallback(myIdleTask);
}
}
这个API对于处理那些“得做但不用立刻去做”的任务最为适配,像是发送用户行为日志,预加载非可视区域的数据,解析后台配置,推进离线包更新之类的。要是把这些操作集中起来去执行,就会明显加大主线程的负载量;可借助requestIdleCallback分发到各个空闲的时间段,用户就全然感受不到它们在运行了。
// 页面加载后,要执行一大堆“不重要”的任务
window.addEventListener('load', () => {
setTimeout(() => {
// 解析埋点配置(50ms)
parseAnalyticsConfig();
}, 0);
setTimeout(() => {
// 预加载下一屏图片(20ms)
preloadNextImages();
}, 0);
setTimeout(() => {
// 检查版本更新(30ms)
checkForUpdates();
}, 0);
});
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 });
});
于一个堪称典型样子的电商详情有关的页面那边,软件开发的人员常常会在这内容页面历经各项加载操作结束后,借助setTimeout这种方式一次性去触发好多项不同的任务,具体有以下这些:递补上报浏览的记录,事先加载出下一页商品的种种数据情况,精细解析埋下的标记来进行配置,仔细检查离线包有没有更新。虽然说这些任务全部都被“延迟”到了load此种事件发生之后那般,然而因为setTimeout 0所产生的回调会在同一回涉及宏任务的过程里接连不断地去执行,所以它们事实上会在极为短暂的时间之内像扎堆那样一块运行。就在这个时候,用户正好开始进行滚动页面的操作,而这些需要耗费时间的任务,就会和渲染过程去争抢CPU资源,进而直接造成滚动出现掉帧的情况。
将这些任务去进行拆分,拆分成多个小单元,是借助引入requestIdleCallback来实现的,在IdleDeadline的timeRemaining()的控制之下,会进行分批执行。每次处于空闲时段的时候,只会处理一个或者几个小的任务,要是时间不够充足了,那么就会立刻中断,随后等待下一次出现空闲情况的时候再继续。以这般“细嚼慢咽”之形式,保证了主线程于每一帧里皆存有充裕的时间去处理用户交互以及渲染工作,进而使得滚动体验变得如丝般顺滑。
现代浏览器常常按照每秒60帧的频次来开展渲染工作,也就是说每帧大概是16.6毫秒。处于这一帧存在的时间段里,浏览器会逐个去处理用户输入进来的事件,接着执行JavaScript宏任务,再运转requestAnimationFrame回调,随后开展布局以及绘制操作,最终才会迈入合成阶段。要是上述提到的所有步骤在16.6毫秒这个时间段之内完成了,那么剩余下来的时间就被称作是“空闲时间”,在这个时候,requestIdleCallback的回调才有机会去执行。要是某一帧的JavaScript任务执行的时间过长,那么空闲时间有可能会是零,空闲回调就会被顺延到后续的帧,一直到有空闲周期出现为止。这样的一种机制确保了关键渲染以及交互永远都是优先执行的。
在requestIdleCallback的回调里头,要防止直接更改DOM,由于任何DOM的变动都有可能引发强制回流以及重绘,这不但会耗费本就紧张的空闲时段,还兴许会打乱后续帧的正常显现。另外,开发者不能够假定回调能够一次性做完所有事务,因为timeRemaining()一般来说仅仅给予最多50毫秒的执行区间。任务得被设计成,能够进行拆分的样子,还得是可中断的片段状态,需要每次执行其中的一小部分,不然的话,就有可能会持续占用主线程,这就违背了那个 API 的设计初衷了。
const requestIdleCallback = window.requestIdleCallback || function(cb, options) {
const start = Date.now();
setTimeout(() => {
cb({
timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
didTimeout: false
});
}, 0);
};
唯有那些针对实时性并无要求的任务,才适宜放置于空闲回调之中。动画进行更新、用户输入响应这类关键操作,绝对不可依托它,缘由在于当网络处于繁忙状态或者CPU负载处于高位时,回调极有可能被延迟数秒方可执行。对于务必在限定时间之内达成的任务,一定要借助timeout参数来设定兜底措施。与此同时,当下Safari以及iOS上的某些浏览器还没有对requestIdleCallback予以支持,在生产环境里,要有降级办法的筹备,像是运用setTimeout,或者是将requestAnimationFrame与performance.now相结合来开展模拟。
用于延迟执行的核心定位的setTimeout,它对于浏览器的忙闲状态不予考虑,适合进行精确延时或者拆分长任务,不过容易引发资源争抢现象。紧随浏览器渲染周期的requestAnimationFrame,适合去执行动画以及DOM同步操作,以此保证视觉的流畅性。requestIdleCallback可是专门针对后台非关键任务来设计的,它得在浏览器实实在在空闲的时候才会去执行,这可是优化首屏体验以及滚动流畅度的一个厉害工具呢。弄明白这三者之间的差异,能够协助开发者在不同的场景当中做出更为精准的技术选型。
要是你碰到一个得在前端开展、还绝不能让页面出现卡顿现象的耗时计算任务,除了运用Web Worker之外,你会怎样去结合requestIdleCallback来设计出分片执行的方案呢?欢迎在评论区当中去分享你所具备的实现思路以及代码片段。
