使用 requestIdleCallback

Paul Lewis

许多网站和应用都需要执行大量脚本。JavaScript 通常需要尽快运行,但同时您也不希望它妨碍用户。如果您在用户滚动网页时发送分析数据,或者在用户点按按钮时向 DOM 附加元素,您的 Web 应用可能会变得无响应,从而导致用户体验不佳。

使用 requestIdleCallback 调度非必需工作。

好消息是,现在有一个 API 可以提供帮助:requestIdleCallback。采用 requestAnimationFrame 后,我们能够正确调度动画并最大限度地提高达到 60fps 的几率,而 requestIdleCallback 在帧结束时有空闲时间或用户处于非活动状态时也会调度工作。这意味着,您有机会在不妨碍用户的情况下完成工作。该功能从 Chrome 47 开始提供,因此您可以立即使用 Chrome Canary 试用该功能!这项功能目前处于实验阶段,规范仍处于动态变化中,因此未来可能会发生变化。

为什么我应使用 requestIdleCallback?

自行安排非必需工作非常困难。由于在 requestAnimationFrame 回调执行后,还需要运行样式计算、布局、绘制和其他浏览器内部工作,因此无法准确确定剩余的帧时间。自家开发的解决方案无法解决这些问题。为了确保用户以某种方式进行互动,您还需要将监听器附加到每种互动事件(scrolltouchclick),即使您不需要它们来实现功能,也只需这样做,即可绝对确定用户未进行互动。另一方面,浏览器确切知道帧结束时有多少时间可用,以及用户是否正在互动,因此通过 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 不可用时,您会直接调用函数,因此以这种方式进行模拟也不会太差。有了这个补丁,如果 requestIdleCallback 可用,您的调用将静默重定向,这非常棒。

不过,目前假设它存在。

使用 requestIdleCallback

调用 requestIdleCallback 与调用 requestAnimationFrame 非常相似,因为它将回调函数作为第一个参数:

requestIdleCallback(myNonEssentialWork);

调用 myNonEssentialWork 时,系统会向其提供一个 deadline 对象,其中包含一个函数,该函数会返回一个数字,表示工作剩余时间:

function myNonEssentialWork (deadline) {
    while (deadline.timeRemaining() > 0)
    doWorkIfNeeded();
}

您可以调用 timeRemaining 函数来获取最新值。当 timeRemaining() 返回零时,如果您还有其他工作要做,可以安排另一个 requestIdleCallback

function myNonEssentialWork (deadline) {
    while (deadline.timeRemaining() > 0 && tasks.length > 0)
    doWorkIfNeeded();

    if (tasks.length > 0)
    requestIdleCallback(myNonEssentialWork);
}

保证调用您的函数

如果工作非常繁忙,您会怎么做?您可能会担心系统永远不会调用回调。虽然 requestIdleCallbackrequestAnimationFrame 相似,但也存在不同之处,它接受一个可选的第二个参数:一个具有超时属性的 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 更改

requestIdleCallback 真正有助于提升性能的另一种情况是,您需要进行非必需的 DOM 更改,例如向不断增长的延迟加载列表的末尾添加项。我们来看看 requestIdleCallback 实际上是如何适应典型框架的。

典型的帧。

浏览器可能会太忙而无法在给定帧中运行任何回调,因此您不应期望在帧结束时有任何空闲时间来执行任何其他工作。这与 setImmediate 等每帧运行的代码不同。

如果回调帧结束时触发,则会安排在当前帧提交后运行,这意味着样式更改已应用,并且重要的是布局已计算。如果我们在空闲回调中进行 DOM 更改,这些布局计算将失效。如果下一个帧中有任何类型的布局读取(例如 getBoundingClientRectclientWidth 等),浏览器将不得不执行强制同步布局,这可能会成为性能瓶颈。

不触发空闲回调中的 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,是因为它填补了 Web 平台上的一个非常现实的空白。很难推断是否缺少活动,但没有 JavaScript API 可用于确定帧结束时的空闲时间,因此您只能做出猜测。setTimeoutsetIntervalsetImmediate 等 API 可用于安排工作,但它们不会像 requestIdleCallback 那样设定时间以避免用户互动。
  • 如果我错过了截止日期,会怎么样? 如果 timeRemaining() 返回零,但您选择运行更长时间,则可以放心地执行此操作,而不必担心浏览器会停止您的工作。不过,浏览器会为您提供截止期限,以便您尽量确保用户获得顺畅的体验,因此除非有充分的理由,否则您应始终遵守截止期限。
  • timeRemaining() 是否有返回的最大值? 是的,目前为 50 毫秒。在尝试保持应用响应速度时,对用户互动的所有响应都应控制在 100 毫秒内。在大多数情况下,如果用户进行互动,50 毫秒的时间窗口应足以完成空闲回调,并让浏览器响应用户的互动。您可能会连续安排多个空闲回调(如果浏览器确定有足够的时间运行这些回调)。
  • 在 requestIdleCallback 中,有没有任何类型的工作不应执行? 理想情况下,您应将工作拆分成具有相对可预测特征的小块(微任务)。例如,更改 DOM 的执行时间将是不可预测的,因为它会触发样式计算、布局、绘制和合成。因此,您应仅按照上述建议在 requestAnimationFrame 回调中进行 DOM 更改。另一个需要注意的事项是解析(或拒绝)Promise,因为回调会在空闲回调完成后立即执行,即使没有剩余时间也是如此。
  • 帧结束时是否始终会收到 requestIdleCallback 不一定。每当帧结束时或用户处于非活跃状态时,浏览器都会安排回调。您不应期望系统按帧调用回调,如果您需要回调在给定时间范围内运行,则应使用超时功能。
  • 我可以有多个 requestIdleCallback 回调吗? 可以,就像您可以有多个 requestAnimationFrame 回调一样。不过,值得注意的是,如果第一个回调在其回调期间用尽了剩余时间,则任何其他回调都将没有剩余时间。然后,其他回调必须等到浏览器下次空闲时才能运行。根据您要完成的工作,最好使用单个空闲回调并在其中划分工作。或者,您也可以使用超时功能,确保没有回调因时间不足而被饿死。
  • 如果我在一个空闲回调中设置了另一个空闲回调,会出现什么情况? 系统会安排尽快运行新的空闲回调,从下一个帧(而不是当前帧)开始。

已进入空闲状态!

requestIdleCallback 是一种非常棒的方法,可确保您可以运行代码,但不会妨碍用户。它简单易用且非常灵活。不过,这项功能仍处于起步阶段,规范尚未完全确定,因此我们欢迎您提供任何反馈。

请在 Chrome Canary 中试用该功能,并在您的项目中试用,然后告诉我们您的使用体验!