使用 requestIdleCallback

Paul Lewis

許多網站和應用程式有許多執行指令碼需要執行,您的 JavaScript 通常必須盡快執行,但又不想讓使用者看到。如果您在使用者捲動網頁時傳送數據分析資料,或者當使用者輕觸按鈕時,將元素附加至 DOM,您的網頁應用程式可能會沒有回應,導致使用者體驗不佳。

使用 requestIdleCallback 安排非必要的工作。

好消息是,現在有一個 API 可提供協助:requestIdleCallback。就像採用 requestAnimationFrame 後,我們得以正確安排動畫時間,盡可能提高達到 60 FPS 的機率,就像在影格結束時或使用者閒置時,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() 傳回零,如果仍有其他工作,可以安排另一個 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 變更

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

閒置了!

requestIdleCallback 是確保您可以執行程式碼,卻不會妨礙使用者操作的實用方法。它簡單好用,而且極具彈性。不過這項設計仍處於早期階段,而且規格尚未設定完成,歡迎您提供任何意見回饋。

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