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

您也可以縮減其行為,而必須改回使用 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() 傳回 0,如果您仍有更多作業,可以排定另一個 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() 會傳回 0。
  • 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 變更,這些版面配置的計算就會失效。如果下一個頁框中有任何讀取版面配置的功能 (例如 getBoundingClientRectclientWidth 等),瀏覽器就必須執行強制同步版面配置,這可能會是效能瓶頸。

「閒置回呼」中無法觸發 DOM 變更的另一個原因是,變更 DOM 的時間是無法預測的,因此我們可以輕鬆超過瀏覽器提供的期限。

最佳做法是只在 requestAnimationFrame 回呼內進行 DOM 變更,因為瀏覽器會根據該類型的工作安排 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 的機制,那就很適合使用這種做法。這個 API 之所以存在,是因為其填補了網路平台的實際落差。要推斷缺乏活動並不容易,但目前沒有任何 JavaScript API 能判斷頁框末端的空時間,因此建議您最好進行猜測。setTimeoutsetIntervalsetImmediate 等 API 可用於排定工作,但並未及時避免使用者與 requestIdleCallback 的互動。
  • 如果超過期限,會怎麼樣? 如果 timeRemaining() 傳回 0,但您選擇執行更久,則可放心執行,不會擔心瀏覽器使工作停止。不過,瀏覽器會讓你有期限,確保使用者能享有流暢的體驗,因此,除非有充分理由,否則你依舊會遵循期限。
  • timeRemaining() 會傳回的最高值嗎?是,現在是 50 毫秒。嘗試維護一個回應應用程式時,所有對使用者互動的回應應維持在 100 毫秒以下。在多數情況下,使用者應與 50 毫秒視窗互動,然後允許閒置回呼執行完畢,並讓瀏覽器回應使用者互動。您可能會收到多個閒置的回呼排程 (如果瀏覽器判斷是否有足夠時間執行回呼)。
  • 我不應在 requestIdleCallback 中處理哪種工作? 在理想情況下,您的工作應該置於特徵相對可預測的小型區塊 (微任務)。例如,變更 DOM 會觸發樣式計算、版面配置、繪製和合成,因此會出現無法預測的執行時間。因此,建議您僅按照上述建議,在 requestAnimationFrame 回呼中變更 DOM。請特別留意的是解決 (或拒絕) Promise,因為當閒置回呼完成後,即使現在已經沒有剩餘時間,回呼將立即執行。
  • 影格結束時我一律會收到 requestIdleCallback 嗎? 不行。每當影格結束時,或使用者處於閒置狀態時,瀏覽器就會安排回呼。您不應預期每個頁框都會呼叫回呼,而如果需要在指定時間範圍內執行回呼,則應使用逾時。
  • 可以有多個 requestIdleCallback 回呼嗎?可以,而且非常有可能。您可以擁有多個 requestAnimationFrame 回呼。但請記住,如果第一個回呼在回呼期間用完剩餘的時間,其他回呼就不再有剩餘時間。其他回呼則必須等到瀏覽器下次閒置後才能執行。視您要進行的工作而定,建議您設定一個閒置的回呼,並將工作劃分到其中。或者,您也可以使用逾時,確保沒有任何回呼都浪費時間。
  • 如果在另一個內部設定新的閒置回呼,會發生什麼事? 新的閒置回呼將會排定盡快執行,從「下一個」影格 (而非目前的影格) 開始執行。

閒置!

requestIdleCallback 是確保執行程式碼的絕佳方式,但不會妨礙使用者操作。這項工具簡單易用,而且非常靈活。不過,由於仍處於初期階段,但規格並未充分調整,歡迎提出任何意見回饋。

快在 Chrome Canary 中一探究竟,試用專案功能,並告訴我們你的進度!