使用 requestIdleCallback

Paul Lewis

許多網站和應用程式都需要執行大量指令碼。您的 JavaScript 通常需要盡快執行,但同時您也不希望 JavaScript 妨礙使用者操作。如果您在使用者捲動網頁時傳送數據分析資料,或是在使用者按下按鈕時在 DOM 中附加元素,您的網頁應用程式可能會停止回應,導致使用者體驗不佳。

使用 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 無法使用時可以直接呼叫函式,所以這種方式不會造成乾擾。有了這個填充程式,如果 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);
}

確保系統會呼叫函式

如果工作量很大,你會怎麼做?您可能會擔心系統從未呼叫回呼。雖然 requestIdleCallbackrequestAnimationFrame 很類似,但也不一樣,後者需要選用第二個參數:具有逾時屬性的選項物件。如果設定了這個逾時時間,瀏覽器就會在該時間 (以毫秒為單位) 內執行回呼:

// 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,因為瀏覽器會根據這類工作安排回呼。這表示我們的程式碼需要使用文件片段,然後可在下一個 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 回呼,接著將文件片段附加至主體:

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,是因為這個 API 可以填補網路平台的真正缺口。要判斷是否缺少活動很困難,但沒有 JavaScript API 可用於判斷影格結束時的空閒時間,因此您只能盡力猜測。setTimeoutsetIntervalsetImmediate 等 API 可用於排程工作,但不會像 requestIdleCallback 那樣以時間為準,避免使用者互動。
  • 如果超過期限,會有什麼影響? 如果 timeRemaining() 傳回零,但您選擇執行更長的時間,則無須擔心瀏覽器會停止您的工作。然而,這個瀏覽器在期限內嘗試確保使用者擁有順暢的使用體驗,因此除非有足夠理由,否則請務必遵循期限。
  • timeRemaining() 是否有傳回的最高值? 是,目前是 50 毫秒。在嘗試維持回應式應用程式時,所有對使用者互動的回應都應保持在 100 毫秒以下。如果使用者進行互動,在大多數情況下,50 毫秒的時間應可讓空閒回呼完成,並讓瀏覽器回應使用者的互動。如果瀏覽器判斷有足夠的時間執行閒置回呼,您可能會收到多個閒置回呼的排程。
  • 在 requestIdleCallback 中,有哪些工作不應執行?在理想情況下,任務內容應置於可預測特徵的小型區塊 (微任務)。舉例來說,變更 DOM 的執行時間無法預測,因為這會觸發樣式運算、版面配置、繪製和合成。因此,您應該只在 requestAnimationFrame 回呼中進行 DOM 變更,如上方所述。另一個要留意的事項是解析 (或拒絕) Promise,因為回呼會在空閒回呼完成後立即執行,即使沒有剩餘時間也一樣。
  • 在影格結尾處,我是否一律會收到 requestIdleCallback不一定。瀏覽器會在影格結束時或使用者閒置期間,排定回呼作業。您不應預期回呼會在每個影格中呼叫,如果您需要在特定時間範圍內執行回呼,則應使用逾時時間。
  • 我可以擁有多個 requestIdleCallback 回呼嗎?可以,就像您可以擁有多個 requestAnimationFrame 回呼一樣。不過,請注意,如果第一個回呼會在回呼期間耗盡剩餘時間,則不會有任何時間留給其他回呼。其他回呼必須等到瀏覽器下次閒置時才能執行。視您想完成的工作而定,最好設定單一閒置回呼,並將工作分割於其中。或者,您也可以利用逾時,確保不會有時間損失回呼。
  • 如果我在其他事件中設定新的空閒回呼,會發生什麼情況?系統會安排新的閒置回呼盡快執行,從下一個影格開始 (而非目前影格)。

閒置!

requestIdleCallback 是確保您可以執行程式碼,但不會妨礙使用者體驗的絕佳方法。它簡單好用,而且極具彈性。不過,這項功能仍處於初期階段,規格尚未完全確定,歡迎提供任何意見。

透過 Chrome Canary 來體驗看看,測試您的專案,並告訴我們您目前的進展!