许多网站和应用都需要执行大量脚本。您的 JavaScript 通常需要尽快运行,但同时也要避免妨碍用户操作。如果您在用户滚动页面时发送分析数据,或者在用户恰好点按了按钮时将元素附加到 DOM,您的 Web 应用可能会无响应,导致用户体验不佳。
好消息是,现在有一个 API 可以为您提供帮助:requestIdleCallback
。采用 requestAnimationFrame
让我们可以正确调度动画并最大限度提高达到 60fps 的几率,同样,requestIdleCallback
会在帧结束时有空闲时间或用户不活动时调度工作。这意味着您可以在不妨碍用户的情况下完成工作。我们已从 Chrome 47 开始提供该版本,因此,您可以立即试用 Chrome Canary 版!这是一项实验性功能,相关规范仍在变化之中,因此将来可能会发生变化。
为什么我应使用 requestIdleCallback?
自行调度非必要的工作非常困难。由于在执行 requestAnimationFrame
回调后,需要运行样式计算、布局、绘制和其他浏览器内部操作,因此很难准确算出剩余的帧时间。家庭自营解决方案无法考虑到上述任何情况。为了确保用户不会以某种方式进行互动,您还需要将监听器附加到每种互动事件(scroll
、touch
、click
)中,即使不需要这些监听器即可实现功能,这样做可以绝对保证用户不会与之互动。另一方面,浏览器确切知道帧结束时有多少时间以及用户是否正在互动,因此通过 requestIdleCallback
,我们获得了一个 API,使我们能够以最高效的方式充分利用空闲时间。
下面我们来更详细地了解一下它,以及如何使用它。
检查 requestIdleCallback
这是requestIdleCallback
的早期版本,因此在使用前,您应该先确认是否可以使用:
if ('requestIdleCallback' in window) {
// Use requestIdleCallback to schedule work.
} else {
// Do what you’d do today.
}
您还可以填充其行为(需要回退到 setTimeout
):
window.requestIdleCallback =
window.requestIdleCallback ||
function (cb) {
var start = Date.now();
return setTimeout(function () {
cb({
didTimeout: false,
timeRemaining: function () {
return Math.max(0, 50 - (Date.now() - start));
}
});
}, 1);
}
window.cancelIdleCallback =
window.cancelIdleCallback ||
function (id) {
clearTimeout(id);
}
使用 setTimeout
并不好,因为它不像 requestIdleCallback
那样了解空闲时间,但由于在 requestIdleCallback
不可用时您会直接调用函数,因此以这种方式进行填充也没什么坏处。使用 shim 时,如果 requestIdleCallback
可用,您的通话将被静默重定向,这非常有用。
不过,现在我们假设它确实存在。
使用 requestIdleCallback
调用 requestIdleCallback
与 requestAnimationFrame
非常相似,因为它接受回调函数作为其第一个参数:
requestIdleCallback(myNonEssentialWork);
调用 myNonEssentialWork
时,系统会为它提供一个 deadline
对象,其中包含一个函数,该函数会返回一个数字,表示工作剩余时间:
function myNonEssentialWork (deadline) {
while (deadline.timeRemaining() > 0)
doWorkIfNeeded();
}
可以调用 timeRemaining
函数来获取最新值。当 timeRemaining()
返回 0 时,如果您仍需要执行更多操作,可以安排另一个 requestIdleCallback
:
function myNonEssentialWork (deadline) {
while (deadline.timeRemaining() > 0 && tasks.length > 0)
doWorkIfNeeded();
if (tasks.length > 0)
requestIdleCallback(myNonEssentialWork);
}
保证您的函数得到调用
如果工作繁忙,您会怎么做?您可能担心系统可能永远不会调用您的回调。虽然 requestIdleCallback
类似于 requestAnimationFrame
,但不同之处在于它接受可选的第二个参数:具有超时属性的 options 对象。如果设置了此超时,浏览器将获得执行回调的时间(以毫秒为单位):
// Wait at most two seconds before processing events.
requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });
如果回调因超时触发而执行,您会注意到两点:
timeRemaining()
将返回零。deadline
对象的didTimeout
属性将为 true。
如果您看到 didTimeout
为 true,很可能只是想运行相应工作并完成处理:
function myNonEssentialWork (deadline) {
// Use any remaining time, or, if timed out, just run through the tasks.
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) &&
tasks.length > 0)
doWorkIfNeeded();
if (tasks.length > 0)
requestIdleCallback(myNonEssentialWork);
}
由于此超时可能会给用户造成中断(该工作可能会导致您的应用无响应或卡顿),因此请谨慎设置此参数。如果可以,让浏览器决定何时调用回调。
使用 requestIdleCallback 发送分析数据
我们来看一下如何使用 requestIdleCallback
发送分析数据。在本例中,我们可能需要跟踪事件,例如点按导航菜单。不过,由于这类事件通常会以动画形式显示在屏幕上,因此我们希望避免立即将该事件发送到 Google Analytics(分析)。我们将创建要发送的事件数组,并请求在未来的某个时间点发送这些事件:
var eventsToSend = [];
function onNavOpenClick () {
// Animate the menu.
menu.classList.add('open');
// Store the event for later.
eventsToSend.push(
{
category: 'button',
action: 'click',
label: 'nav',
value: 'open'
});
schedulePendingEvents();
}
现在,我们需要使用 requestIdleCallback
处理所有待处理的事件:
function schedulePendingEvents() {
// Only schedule the rIC if one has not already been set.
if (isRequestIdleCallbackScheduled)
return;
isRequestIdleCallbackScheduled = true;
if ('requestIdleCallback' in window) {
// Wait at most two seconds before processing events.
requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });
} else {
processPendingAnalyticsEvents();
}
}
如此处所示,我已将超时设置为 2 秒,但该值取决于您的应用。对于分析数据而言,使用超时可确保在合理的时间范围内报告数据,而不是仅在未来的某个时间点报告。
最后,我们需要编写 requestIdleCallback
将执行的函数。
function processPendingAnalyticsEvents (deadline) {
// Reset the boolean so future rICs can be set.
isRequestIdleCallbackScheduled = false;
// If there is no deadline, just run as long as necessary.
// This will be the case if requestIdleCallback doesn’t exist.
if (typeof deadline === 'undefined')
deadline = { timeRemaining: function () { return Number.MAX_VALUE } };
// Go for as long as there is time remaining and work to do.
while (deadline.timeRemaining() > 0 && eventsToSend.length > 0) {
var evt = eventsToSend.pop();
ga('send', 'event',
evt.category,
evt.action,
evt.label,
evt.value);
}
// Check if there are more events still to send.
if (eventsToSend.length > 0)
schedulePendingEvents();
}
在此示例中,我假设如果 requestIdleCallback
不存在,应立即发送分析数据。但在生产应用中,最好通过设置超时来延迟发送,以确保不与任何交互发生冲突并导致卡顿。
使用 requestIdleCallback 进行 DOM 更改
需要执行非必要的 DOM 更改(例如,在不断增长的延迟加载列表末尾添加项)时,requestIdleCallback
确实有助于提高性能的另一种情况是。我们来看一下 requestIdleCallback
实际上是如何放入典型帧的。
浏览器可能过于繁忙,无法在给定帧中运行任何回调,因此不应期望帧结束时会有任何空闲时间来执行更多工作。这使得它与 setImmediate
之类的代码有所不同,后者按帧运行。
如果回调在帧结束时触发,它将被安排在当前帧之后执行,这意味着系统将应用样式更改,重要的是,计算布局。如果在空闲回调内进行 DOM 更改,这些布局计算将会失效。如果下一帧中有任何类型的布局读取(例如 getBoundingClientRect
、clientWidth
等),浏览器将必须执行强制同步布局,这可能会造成性能瓶颈。
在空闲回调中不触发 DOM 更改的另一个原因是,更改 DOM 的时间影响不可预测,因此我们很容易超过浏览器提供的期限。
最佳做法是仅在 requestAnimationFrame
回调内进行 DOM 更改,因为浏览器会根据此类工作进行安排。这意味着我们的代码需要使用文档 fragment,然后将其附加到下一个 requestAnimationFrame
回调中。如果您使用的是 VDOM 库,可以使用 requestIdleCallback
进行更改,但是您会在下一个 requestAnimationFrame
回调(而不是空闲回调)中应用 DOM 补丁。
了解了这一点,我们来看一下代码:
function processPendingElements (deadline) {
// If there is no deadline, just run as long as necessary.
if (typeof deadline === 'undefined')
deadline = { timeRemaining: function () { return Number.MAX_VALUE } };
if (!documentFragment)
documentFragment = document.createDocumentFragment();
// Go for as long as there is time remaining and work to do.
while (deadline.timeRemaining() > 0 && elementsToAdd.length > 0) {
// Create the element.
var elToAdd = elementsToAdd.pop();
var el = document.createElement(elToAdd.tag);
el.textContent = elToAdd.content;
// Add it to the fragment.
documentFragment.appendChild(el);
// Don't append to the document immediately, wait for the next
// requestAnimationFrame callback.
scheduleVisualUpdateIfNeeded();
}
// Check if there are more events still to send.
if (elementsToAdd.length > 0)
scheduleElementCreation();
}
在此示例中,我创建了该元素并使用 textContent
属性填充它,但您的元素创建代码可能更复杂!创建元素后,调用 scheduleVisualUpdateIfNeeded
,这将设置一个 requestAnimationFrame
回调,而该回调又会将文档 fragment 附加到正文:
function scheduleVisualUpdateIfNeeded() {
if (isVisualUpdateScheduled)
return;
isVisualUpdateScheduled = true;
requestAnimationFrame(appendDocumentFragment);
}
function appendDocumentFragment() {
// Append the fragment and reset.
document.body.appendChild(documentFragment);
documentFragment = null;
}
一切正常,现在我们向 DOM 附加项目时,卡顿现象大大减少。非常好!
常见问题解答
- 有 polyfill?
很遗憾,不会。但如果您希望透明地重定向到
setTimeout
,可以使用一个 shim。此 API 之所以存在,是因为它填补了网络平台存在的空白。推断活动状态是否不足非常困难,但不存在可以确定帧结束时空闲时间的 JavaScript API,因此最多只能进行猜测。setTimeout
、setInterval
或setImmediate
等 API 可用于调度工作,但不会像其requestIdleCallback
那样设置时间,以避免用户互动。 - 如果我超过了截止日期,会出现什么情况?
如果
timeRemaining()
返回 0,但您选择运行更长时间,您无需担心浏览器会停止运行。不过,浏览器规定了让您尽量确保用户获得顺畅体验的期限,因此,除非有充分的理由,否则您应始终遵守该期限。 timeRemaining()
会返回最大值吗? 是的,目前是 50 毫秒。尝试让应用保持快速响应时,对用户交互的所有响应都应保持在 100 毫秒以内。在大多数情况下,如果用户与 50 毫秒的窗口进行互动,应允许完成闲置回调,并允许浏览器响应用户的交互。您可能会连续收到多个空闲回调(如果浏览器确定有足够的时间运行这些回调)。- 是否有任何我不应该在 requestIdleCallback 中执行的工作?
理想情况下,您所做的工作应分成具有相对可预测特征的小块(微任务)。例如,更改 DOM 有难以预测的执行时间,因为它会触发样式计算、布局、绘制和合成。因此,您应仅在
requestAnimationFrame
回调中进行 DOM 更改(如上所述)。另一个需要注意的事项是解析(或拒绝)Promise,因为即使没有剩余时间,回调也会在空闲回调完成后立即执行。 - 我是否总会在帧结束时收到
requestIdleCallback
? 不一定。每当帧结束时有空闲时间时,或者用户处于不活动状态时,浏览器就会安排回调。您不应期望按帧调用回调;如果您需要它在给定的时间范围内运行,则应使用超时。 - 我可以使用多个
requestIdleCallback
回调吗? 可以,这样做几乎就像您可以创建多个requestAnimationFrame
回调。但值得注意的是,如果第一个回调用完了其回调期间的剩余时间,那么任何其他回调都没有剩余时间。然后,其他回调必须等待浏览器再次进入空闲状态,然后才能运行。根据您尝试完成的工作,最好使用单个空闲回调,并将工作划分到其中。或者,您可以利用超时来确保任何回调都不会出现时间不足的情况。 - 如果我在另一个回调内设置了新的空闲回调,会发生什么情况? 新的空闲回调将被安排尽快从下一帧(而不是当前帧)开始运行。
等着瞧吧!
requestIdleCallback
可以确保您可以运行代码,同时又不会妨碍用户操作。它简单易用,也非常灵活。不过,目前还处于早期阶段,相关规范尚未完全确定,因此欢迎您提供任何反馈。
请在 Chrome Canary 版中试用此版本,并在您的项目中体验它,并告知我们您的进展!