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

閒置!

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

歡迎在 Chrome Canary 中試用這項功能,並在專案中測試,然後告訴我們使用心得!