使用 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.
}

您还可以 shim 其行为,这需要回退到 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

调用 requestIdleCallbackrequestAnimationFrame 非常相似,因为它接受回调函数作为其第一个参数:

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);
}

确保您的函数已调用

如果工作非常繁忙,您会怎么做?您可能担心可能永远不会调用回调。虽然 requestIdleCallback 类似于 requestAnimationFrame,但它的不同之处在于它接受可选的第二个参数:一个具有 Timeout 属性的选项对象。设置此超时后,浏览器将获得一个以毫秒为单位的超时时间,在该时间过后,浏览器必须执行回调:

// 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 之所以存在,是因为它填补了网络平台的实际差距。推断活动不足是很困难的,但不存在 JavaScript API 来确定帧结束时的空闲时间,因此至多只能进行猜测。setTimeoutsetIntervalsetImmediate 等 API 可用于调度工作,但不会对其计时,以避免像 requestIdleCallback 那样进行用户互动。
  • 如果我超过了截止日期,会出现什么情况? 如果 timeRemaining() 返回零,但您选择运行更长时间,则您无需担心浏览器会中断您的工作。不过,浏览器会为您提供尝试确保用户流畅体验的最后期限,因此除非有非常合理的理由,否则您应始终遵循截止日期。
  • timeRemaining() 会返回的最大值是否有上限? 是,当前为 50 毫秒。尝试使应用保持快速响应时,对用户互动的所有响应都应该保持在 100 毫秒以内。如果用户交互,50 毫秒的窗口在大多数情况下应允许空闲回调完成,并允许浏览器响应用户的交互。您可能会连续获得多个空闲回调(如果浏览器确定有足够的时间来运行这些回调)。
  • 是否有任何不应在 requestIdleCallback 中执行的工作? 理想情况下,您应将工作分成多个具有相对可预测特征的小块(微任务)。例如,特别更改 DOM 将导致执行时间不可预测,因为它会触发样式计算、布局、绘制和合成。因此,您只应在 requestAnimationFrame 回调中进行 DOM 更改(如上文建议所述)。另一个需要警惕的事项是解析(或拒绝)promise,因为回调将在空闲回调完成后立即执行,即使没有更多剩余时间也是如此。
  • 我是否总是在帧结束时收到 requestIdleCallback 不一定。每当帧结束时还有空闲时间或用户未活动时,浏览器就会安排回调。您不应期望回调按帧调用,如果您要求回调在给定时间范围内运行,则应利用超时。
  • 我可以有多个 requestIdleCallback 回调吗? 可以,就像您可以有多个 requestAnimationFrame 回调一样。但值得注意的是,如果您的第一次回调用完了其回调期间的剩余时间,那么其他任何回调都没有多余的时间了。然后,其他回调必须等到浏览器下一次空闲时才能运行。根据您尝试完成的工作,最好使用单个空闲回调并将工作划分到该回调中。或者,您可以利用超时来确保没有回调出现时间不足。
  • 如果我在另一个回调内设置一个新的空闲回调,会发生什么情况? 系统会将新的空闲回调函数安排为尽快运行,从下一帧(而非当前帧)开始运行。

空闲!

requestIdleCallback 是确保您可以在不妨碍用户操作的情况下运行代码的绝佳方法。它简单易用,而且非常灵活。不过,现在仍处于早期阶段,相关规范尚未完全确定,因此欢迎您提供任何反馈。

欢迎在 Chrome Canary 版中试用该版本,试用一下您的项目,并告诉我们您的进展!