window.reqeustIdleCallback方法详解
它和 requestAnimationFrame 一样吗?
最初我以为这个函数就是和实现动画的 requestAnimationFrame 拥有相同的行为,因为它们的使用方法非常类似,但实际使用后发现它们的差别还是蛮大的。本文主要对这个神秘的函数进行一些说明和分析。
关于 requestAnimationFrame 相关内容看我之前文章《JavaScript 中 requestAnimationFrame 函数的简单介绍 用以实现流畅的动画》、《将 requestAnimationFrame 与 React Hooks 一起使用》
定义和用法
首先来看一下它的定义和用法,MDN 是这样定义它的:
这是一个实验中的功能,window.requestIdleCallback() 将一个(即将)在浏览器空闲时间执行的函数加入队列,这使得开发者在主事件循环中可以执行低优先级工作,而不影响对延迟敏感的事件,如动画和输入响应。
通过这个定义,我们发现它的执行时机在浏览器的“空闲”状态,那么怎样定义这个状态呢?
浏览器每一帧都需要完成这些任务:
-
处理用户交互 -
JS 执行 -
一帧的开始,处理视窗变化、页面滚动等 -
requestAnimationFrame(rAF) -
重排(layout) -
绘制(draw)
在这些步骤完成后,如果时间消耗还没超过 16ms,则浏览器还有余力去处理其他的任务,我们在 reqeustIdleCallback 中传入的回调将在此时执行;相反,如果时间消耗太大,则回调不执行,任务会顺延到下个帧浏览器空闲的时候再执行。而如果浏览器一直都很忙,那任务就会一再被推迟,很可能需要消耗大量时间后才得到执行。为了解决这个问题,可以在注册任务的时候提供一个 timeout 参数指定超时时间,在超时时间之内,该任务会被优先放在浏览器的执行队列中。
下面来看下它的用法:
// 这些只为了表明一些参数定义 type Dealine = { timeRemaining: () => number // 当前剩余的可用时间 didTimeout: boolean // 是否超时 } type Tick = (deadline: Dealine) => void; type Options = { timeout: number }; // 可以提供一个超时时间,配合上面的 didTimeout 一起用 type RequestIdleCallback = (tick: Tick, options?: Options) => number // 类似于 rAF 返回一个句柄,可以把它传入 cancelIdleCallback 取消掉任务
一个常见的用法是,当有剩余时间或者 timeout 发生时执行一些任务,通常将任务保存在一个队列中便于进行调度。
// 待执行的任务队列 const taskQueue = []; const tick = function(deadline) { const remaining = deadline.timeRemaining(); while (remaining > 0 || didTimeout) { // 如果超时,或者还有剩余执行时间,则执行这里的任务 // 执行任务队列中的任务 const currentTask = taskQueue.shift(); exec(currentTask); } // 再次注册,在下一个间隙继续执行 taskQueue 中的任务 requestIdleCallback(tick, { timeout: 500 }); }; requestIdleCallback(tick, { timeout: 500 });
reqeustIdleCallback 的执行行为
requestAnimationFrame 大家经常拿来实现动画,因为它是一个“靠谱的”函数,如果页面没有阻塞,那么这个函数每 16ms 左右调用一次;requestIdleCallback 则不同,它的执行间隔是不固定的,取决于浏览器此时正在执行的任务,下面举几个例子来看下。
我们简单地在页面中注册 requestIdleCallback,先不提供 timeout 参数
const tick = function(deadline) { const remaining = deadline.timeRemaining(); if (remaining > 0) { // do some stuff } requestIdleCallback(tick); }; requestIdleCallback(tick);
场景一,页面中同时使用 requestAnimationFrame 函数循环注册一个事件,使页面发生重绘。
const cutiePie = document.querySelector('.cutie-pie'); const t = () => { cutiePie.style.transform = `translate(${1 - Math.random() * 100}px, 0)`; requestAnimationFrame(t); } requestAnimationFrame(t);
通过时间轴查看 requestIdleCallback 在 requestAnimationFrame、重排和绘制之后执行,执行间隔和 requestAnimationFrame 相应,在 16ms 左右,这符合上文提到的每一帧中浏览器执行任务的顺序。
场景二,我们在场景一的基础上停止动画,
此时页面完全静止,重排和绘制都停止了,但是浏览器仍然在注册 requestIdleCallback 并执行其回调,执行间隔在 50ms 左右,并没有以类似 requestAnimationFrame 的 16ms 间隔执行。
场景三,在场景一和场景二的基础上,页面分别不定时执行一个超过 16ms 的任务。
从上面两个场景可以看出,无论页面处于动态还是静止,如果有任务执行时间过长,则这一帧中 requestIdleCallback 不会被执行,而是被延迟到下一帧。
场景四,上面的情形都没有附加 timeout 参数,现在我们在场景二静止的页面中给 requestIdleCallback 加上 timeout 参数再看看:
const tick = function(deadline) { const remaining = deadline.timeRemaining(); if (remaining > 0) { // do some stuff } requestIdleCallback(tick, {timeout: 500}); }; requestIdleCallback(tick, {timeout: 500});
执行间隔变到 5-20ms 左右,变得相当混乱,原因可能是浏览器增加了额外的工作检验任务是否已经超时,可见附加 timeout 属性想让它变得“靠谱”是要付出代价的,其调用频率将大幅上升。
通过以上分析,我们得知 requestAnimationFrame 的执行规律符合上文对浏览器空闲时间的描述,如果一帧中任务的执行时间超过了一定的时间(粗略估计在 20ms 左右),则任务会顺延到下一帧中执行。那利用它进行卡顿监控是否可行呢?即收集两次执行回调的间隔以判断有无消耗时间较长的任务阻塞线程。首先如果不加 timeout 参数是不可行的,试想如果页面每一帧执行时间都在 20ms 左右,则我们注册的任务会持续被顺延,而此时页面并不卡顿(fps 还在 50 左右),但是如果添加了 timeout 参数,则这个函数的调用频率大幅提高,甚至比 requestAnimationFrame 还要频繁,然后结合其兼容性来看,综合性能可能还不如后者。
最长执行时间
如果 requestIdleCallback 的执行阻塞线程太久,就可能发生卡顿了,每一帧中 requestIdleCallback 回调的最长的执行时间是 50ms(这是建议的,但是你也可以做坏事),即回调中deadline.timeRemaining()
的最大值小于 50,这个阈值是 RAIL 模型定义的。
通常人类对 100ms 以内的延迟无感,而一旦超过这个阈值,则可能感觉到卡顿(jank)。下表中列举了一些延迟时间和用户体验的对应关系:
试想在某种理想情况下,浏览器开始执行 requestIdleCallback 中的回调任务,同时用户立即输入一些文字,此时浏览器在处理回调任务,输入事件被挂起,等回调执行完成后,用户输入事件对应的回调得到执行(oninput, onchange 等),最后发生 layout 和 repaint,用户输入的内容才能出现在屏幕上。以上这一切都要在 100ms 之内完成,RAIL 模型将其分为了两段,每一段 50ms,分别用于处理两个阶段的任务,具体见下图:
longtask 的定义也是基于此模型,它表示执行时间 50ms 以上的任务,阻塞线程 50ms 以上可能引起交互时间延迟,造成紊乱的动画和滚动,在 performance 面板中任务右上角有一个清晰的角标。
使用建议
基于此 API 的特殊性,提供一些使用建议:
- 只在低优先级的任务中使用它,因为你无法控制它的执行时机。比如给后台发送一些不怎么重要的监控数据,或者进行某种页面检查。
- 不要在其中修改 DOM 元素,因为它在一个任务周期的 layout 结束之后才执行,如果你修改了 DOM,则会再次引发重排,这会对性能产生一定的影响。推荐的做法是创建一个 documentFragment 保存对 dom 的修改,并注册 requestAnimationFrame 来应用这些修改。
- 不在其中执行难以预测执行时间的任务,比如以 Promise 的形式执行某个接口请求。
- 只在必需的时候使用 timeout 选项,浏览器会花费额外的开销在检查是否超时上,产生一些性能损失。
React 如何 polyfill
React16.6 之后在任务调度中意图使用 requestIdleCallback 这个函数,但是它的兼容性并不好,Safari、安卓 8.1 以下、IE 等都是重灾区,所以 React 做了一个 Polyfill,它是怎么做的呢?这里简要介绍下 React16.13.1 中实现的步骤。
React 维护了两个小顶堆 taskQueue 和 timerQueue,前者保存等待被调度的任务,后者保存调度中的任务,它们的排列依据分别是任务的超时时间和过期时间。到达超时时间的任务会从 timerQueue 移动到 taskQueue 中,而在过期时间之内 taskQueue 中的任务期望得到执行,React 调度的核心主要是以下几点:1. 何时把超时的任务从 timerQueue 转移到 taskQueue;2. taskQueue 中任务的执行时机,以及后续任务的衔接;3. 何时暂停执行任务,把资源回交给浏览器。
使用 unstable_scheduleCallback 注册任务的时候可以提供两个参数,delay 表示任务的超时时长,timeout 表示任务的过期时长(如果没有指定,根据优先程度任务会被分配默认的 timeout 时长)。如果没有提供 delay,则任务被直接放到 taskQueue 中等待处理;如果提供了 delay,则任务被放置在 timerQueue 中,此时如果 taskQueue 为空,且当前任务在 timerQueue 的堆顶(当前任务的超时时间最近),则使用 requestHostTimeout 启动定时器(setTimeout),在到达当前任务的超时时间时执行 handleTimeout ,此函数调用 advanceTimers 将 timerQueue 中的任务转移到 taskQueue 中,此时如果 taskQueue 没有开启执行则调用 requestHostCallback 启动它,否则继续递归地执行 handleTimeout 处理下一个 timerQueue 中的任务。
那么 taskQueue 如何启动呢?在支持 MessageChannel 的环境中是利用它来实现的:
const channel = new MessageChannel(); const port = channel.port2; // performWorkUntilDeadline:执行我们注册的任务 channel.port1.onmessage = performWorkUntilDeadline; // 启动 taskQueue 的执行,但是没有立即执行,而是使用 Message Channel,因为它的执行时机是在浏览器每帧的绘制之后 requestHostCallback = function(callback) { ... port.postMessage(null); };
在上面的代码中,port1 收到 message 后开始执行 performWorkUntilDeadline,此时处于一帧绘制结束、下一帧即将开始之际,这个函数先依据当前的时间戳估算出该帧的过期时间(deadline 默认是在当前时间戳的基础上加 5ms),然后调用 flushWork,这个函数在 taskQueue 中任务执行之前重置一些状态,再进行一波性能分析,接着它调用了 workLoop 执行 taskQueue 中的任务。
终于可以执行我们注册的任务了!但在执行任务之前,还要做一件事,就是调用我们上面提到过的 advanceTimers,将 timerQueue 中超时的任务转移到 taskQueue 中。此时我们终于可以在 5ms 的时间分片里执行 taskQueue 中的任务了,每执行完一项任务,都会执行一下 advanceTimers 拉取超时任务,然后如果此时还没到达分片的时间阈值,则继续执行下一项任务直至到达 deadline。此时如果 taskQueue 中还有任务,则调用上文提到的 requestHostCallback 继续在下一帧的 5ms 间隙里执行任务直到任务穷尽;如果没有更多任务了,则检查 timerQueue 中是否有任务,有则使用 requestHostTimeout 启动定时器,没有则任务完全结束。
上面所说的时间分片很好地将浏览器绘制之后的空闲时间利用起来了,看到这里是不是很像我们的 requestIdleCallback 呢?5ms 时间分片在有频繁交互、重绘的页面确实是不错的选择,但如果页面基本是静态的,可以将一个时间分片拉长吗?在 React 源码中确实是对此进行了考虑的,这里利用了一个支持度不算太高的 BOM API navigator.scheduling.isInputPending, 它表示用户的输入是否被挂起,也就是我们上文提到的用户输入没有及时得到反馈。如果页面没有发生交互,且不需要重绘(needsPaint === false,这是程序内的一个全局变量),则 React 会把时间分片提升到 300ms(maxYieldInterval),虽然这个时间远超反应延迟,但是 taskQueue 中每一项任务执行完成后都会去检测有没有用户交互和重绘,如果有则立即把资源回交给浏览器,所以不用担心会因此发生卡顿。
整个过程的大致图解如下:
文章来源:公众号 前端 Q
码云笔记 » window.reqeustIdleCallback方法详解