針對長時間工作進行最佳化調整

您一直提到「不要阻斷主執行緒」並「分散長時間的工作」,但這究竟代表什麼意思?

提升 JavaScript 應用程式快速執行速度的常見建議,通常對應的是以下建議:

  • 「不要封鎖主執行緒。」
  • 「妥善處理長時間的工作。」

這是很棒的建議,但需要做哪些方面的工作?「較少」JavaScript 固然很好,但這會自動等同於互動性更高的使用者介面嗎?也許,但不一定。

要瞭解如何以 JavaScript 將工作最佳化,首先您需要瞭解什麼是工作,以及瀏覽器處理工作的方式。

什麼是工作?

「工作」是指瀏覽器所做的任何工作。這項工作包括轉譯、剖析 HTML 和 CSS、執行 JavaScript,以及您無法直接控管的其他工作類型。總而言之,您編寫的 JavaScript 也許是最主要的工作來源。

Chrome 開發人員工具的效能分類器中所示工作的視覺化資訊。工作位於堆疊頂端,其中含有點擊事件處理常式、函式呼叫,以及其下方的更多項目。此工作還包括部分轉譯工作。
click 事件處理常式啟動的工作,如 Chrome 開發人員工具的效能分析器所示。

與 JavaScript 相關的工作會對效能造成以下影響:

  • 當瀏覽器在啟動期間下載 JavaScript 檔案時,它會將工作排入佇列來剖析和編譯該 JavaScript,以便稍後執行。
  • 在網頁的生命週期中,如果 JavaScript 執行了互動 (例如透過事件處理常式、以 JavaScript 為基礎的動畫,以及分析收集等背景活動),工作就會排入佇列。

除了網路工作站和類似的 API 外,這些作業全都在主執行緒上進行。

主執行緒為何?

主執行緒是大部分工作在瀏覽器中執行的位置,以及您編寫的所有 JavaScript 會在該位置執行。

主執行緒一次只能處理一項工作。任何耗時超過 50 毫秒的工作都是「長時間工作」。如果工作時間超過 50 毫秒,工作的總時間減 50 毫秒,就稱為工作的「封鎖期間」

瀏覽器會在工作執行中任何長度的工作期間封鎖互動,但只要工作執行時間太長,使用者就無法察覺這個情形。然而,當使用者嘗試與網頁互動時,如果有許多工作長時間執行,使用者介面會無回應,甚至在主執行緒長時間遭到封鎖的情況下,甚至毀損。

Chrome 開發人員工具的效能分析器中的長時間工作。任務的阻塞部分 (超過 50 毫秒) 以紅色對角條紋圖案呈現。
Chrome 效能分析器中顯示的長時間工作。長時間的任務會在工作角落以紅色三角形表示,以及工作的阻斷部分以沿對角紅色條紋填滿。

如要避免主執行緒遭封鎖的時間過長,你可以將長時間的工作分成幾個小型工作。

單一長時間工作與同一項工作分割為較短的工作。長時間工作是一個大矩形,而區塊的工作則有五個小方塊,其寬度與長時間的工作相等。
以視覺化方式呈現單一長時間工作與同個工作分成五項較短的工作。

這點非常重要,因為當工作分裂時,瀏覽器可以更快地回應優先順序較高的工作,包括使用者互動。之後,剩下的工作就會執行到完成,確保一開始排入佇列的工作都確實完成。

描繪中斷任務有助於促進使用者互動的描述。在頂端,長時間的工作會阻止事件處理常式執行,直到工作完成為止。在底部,分割的工作會允許事件處理常式的執行速度比其他情況更快。
以視覺化方式呈現工作時間太長、瀏覽器無法快速回應互動,或將較長的工作拆分為較小的工作時,互動時所發生的互動情形。

在上圖的最上方,由使用者互動排入佇列的事件處理常式在開始之前必須等待一項長時間工作才能開始,這會延遲互動的發生。在這個情境中,使用者可能已註意到延遲。底部的事件處理常式可以更早開始執行,且互動可能為「即時」

現在,您已瞭解分散工作的重要性,接下來將說明如何在 JavaScript 中執行這些操作。

工作管理策略

在軟體架構中,有個常見建議是將工作分割成較小的功能:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

在這個範例中,有一個名為 saveSettings() 的函式會呼叫五種函式來驗證表單、顯示輪轉選單、將資料傳送至應用程式後端、更新使用者介面,以及傳送數據分析。

從概念上來說,saveSettings() 的架構良好。如果需要對其中一個函式進行偵錯,可以掃遍專案樹狀結構,確認各項函式的功用。像這樣分散工作可方便瀏覽及維護專案。

但這可能是一項潛在問題,原因是 JavaScript 不會單獨執行這些函式,因為這些函式是在 saveSettings() 函式中執行。這表示這五項函式將視為單一工作來執行。

如同 Chrome 的效能分析器所示,saveSettings 功能的運作方式。頂層函式會呼叫另外五個函式,而所有作業都會在封鎖主執行緒的長時間工作中進行。
單一函式 saveSettings(),可呼叫五個函式。這項工作會做為一項長時間的單體式工作的一部分執行。

在最佳情況下,即使只有一個函式,也能為工作的總長度造成 50 毫秒以上的時間。在最糟糕的情況下,更多這類工作可以長時間執行,尤其是在資源受限的裝置上。

手動延後執行程式碼

開發人員曾經使用 setTimeout() 將工作分割成較小的工作的方法。透過這項技巧,您會將函式傳遞至 setTimeout()。即使指定 0 逾時,這會將回呼延後執行到個別工作中。

function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

這就是所謂的「邊序」,最適合用於依序執行的一系列函式。

不過,程式碼不一定每次都會以這種方式整理。例如,您可能需要以迴圈方式處理大量資料,且如果多次疊代,這項工作可能會花費很長的時間。

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

在這裡使用 setTimeout() 會因為開發人員的人體工學而造成問題,即使每次的疊代作業都能快速執行,整個資料陣列的處理時間仍可能會非常長。因為分數都這麼多,setTimeout() 並非工作適用的工具,至少不是以這種方式使用時。

使用 async/await 建立收益點

為確保使用者在低優先順序的工作之前發生重要的工作,您可以短暫中斷工作佇列來讓主執行緒進入主執行緒,讓瀏覽器有機會執行更重要的工作。

如先前所述,setTimeout 可用來產生主執行緒。不過,為了方便閱讀,您可以在 Promise 中呼叫 setTimeout,並將 resolve 方法做為回呼傳遞。

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

yieldToMain() 函式的好處是,您可以在任何 async 函式中對其進行 await。您可以根據先前的範例建構要執行的函式陣列,並在每次執行函式後產生函式陣列:

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread:
    await yieldToMain();
  }
}

因此,單體式工作現已拆分為不同的工作。

Chrome 效能分析器顯示的 keepSettings 功能相同,只不過實際產生的結果。結果就是一次單體式工作,現在可分割成五個不同工作,每個函式各一個工作。
saveSettings() 函式現在會將其子項函式做為獨立工作執行。

專屬的排程器 API

setTimeout 是分割工作的有效方式,但可能會有缺點:如果將程式碼延後在後續工作中執行,從而產生到主執行緒,該工作會新增至佇列的「結尾」

如果您負責控制網頁上的所有程式碼,您可以建立自己的排程器,為工作排定優先順序,但第三方指令碼不會使用排程器。實際上,您無法在這類環境中「優先」工作。您只能加以分割,或明確用來引發使用者互動。

瀏覽器支援

  • 94
  • 94
  • x

來源

排程器 API 提供 postTask() 函式,可以更精細地安排工作排程,也可以幫助瀏覽器排定工作的優先順序,讓低優先順序工作產生主執行緒。postTask() 會使用承諾,且接受以下三種 priority 設定之一:

  • 'background',適用於優先順序最低的工作。
  • 'user-visible' 適用於中優先順序的工作。如未設定 priority,這是預設值。
  • 'user-blocking':用於執行需要以高優先順序執行的重要工作。

以下列程式碼為例,其中 postTask() API 會盡可能以最高優先順序執行三項工作,其餘兩項工作則以最低的優先順序執行。

function saveSettings () {
  // Validate the form at high priority
  scheduler.postTask(validateForm, {priority: 'user-blocking'});

  // Show the spinner at high priority:
  scheduler.postTask(showSpinner, {priority: 'user-blocking'});

  // Update the database in the background:
  scheduler.postTask(saveToDatabase, {priority: 'background'});

  // Update the user interface at high priority:
  scheduler.postTask(updateUI, {priority: 'user-blocking'});

  // Send analytics data in the background:
  scheduler.postTask(sendAnalytics, {priority: 'background'});
};

這裡排定工作的優先順序時,得以根據瀏覽器優先的工作 (例如使用者互動) 安排工作的優先順序。

keepSettings 功能如 Chrome 的效能分析器所示,但使用了 postTask。postTask 會分割每個函式 keepSettings 執行作業,並排定這些函式的優先順序,確保使用者互動可在不封鎖的情況下執行。
saveSettings() 執行時,函式會使用 postTask() 安排個別函式。面向使用者的重要工作排定為高優先順序,使用者不知道的工作是排定在背景執行。這樣一來,由於工作會「同時」妥善劃分並排定優先順序,因此使用者互動能更快執行。

這是 postTask() 的簡化使用範例。您可以將不同 TaskController 物件例項化,藉此在任務之間共用優先順序,包括視需要變更不同 TaskController 執行個體的優先順序。

使用即將推出的 scheduler.yield() API 繼續內建收益

除了排程器 API 外,建議還提供 scheduler.yield(),這是專為產生瀏覽器主執行緒而設計的 API。此 API 使用與本指南先前示範的 yieldToMain() 函式類似:

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread with the scheduler
    // API's own yielding mechanism:
    await scheduler.yield();
  }
}

此程式碼大致上熟悉,但不使用 yieldToMain(),而是使用 await scheduler.yield()

三個圖表呈現工作,卻未產生、產生及產生並延續。如果沒有產出,就需要很長的任務。產生收益時,還有更多時間較短的工作,但可能會被其他不相關的工作中斷。在產生和連續作業的過程中,有更多時間較短的工作,但執行順序仍會保留。
使用 scheduler.yield() 時,即使獲得收益點,工作執行作業仍會從上次中斷的地方繼續作業。

scheduler.yield() 的優勢是持續,這表示如果產生的工作中途產生,其他排程工作會在收益點之後按照相同的順序繼續處理。這樣可避免第三方指令碼中的程式碼中斷程式碼執行的順序。

由於 user-blocking 優先順序較高,將 scheduler.postTask()priority: 'user-blocking' 搭配使用的機率較高,因此可先當做替代選項使用。

使用 setTimeout() (或 scheduler.postTask() 搭配 priority: 'user-visibile' 或不明確 priority) 會安排在佇列背面工作,進而讓其他待處理工作在接續之前執行。

不使用 isInputPending()

瀏覽器支援

  • 87
  • 87
  • x
  • x

isInputPending() API 可讓您檢查使用者是否已嘗試與網頁互動,並且只有在輸入內容為待處理狀態時才能獲得收益。

這樣 JavaScript 就能在沒有待處理的輸入內容時繼續進行,而不是產生及最終在工作佇列末端。假如網站可能無法傳回主要執行緒,效能會大幅提升,詳情請參閱「Intent to Ship」一節。

然而,自推出該 API 以來,我們對於收益的瞭解也有所提升,特別是 INP 推出後。我們已不再建議使用這個 API;建議您基於下列幾項原因,無論輸入內容是否待處理

  • 儘管使用者在某些情況下有互動,isInputPending() 仍可能錯誤傳回 false
  • 輸入內容並非唯一應產生工作的情況。提供回應式網頁,動畫和其他定期使用者介面更新也同樣重要。
  • 此後,我們導入了更全面的收益 API,能夠解決 scheduler.postTask()scheduler.yield() 等產生方面的疑慮。

結論

管理工作並不容易,但這麼做可確保網頁能更快回應使用者互動。關於管理工作和排定工作的優先順序,並沒有一體適用的建議,而是要提供幾種不同的技巧。再複習一下,管理工作時應留意這些重點:

  • 針對面向使用者的重要工作產出至主執行緒。
  • 使用 postTask() 安排工作優先順序。
  • 建議你嘗試使用 scheduler.yield()
  • 最後,盡量減少函式中的工作

您可以運用一或多種工具規劃應用程式工作,優先處理使用者的需求,同時確保較不重要的工作依然能夠完成。這表示系統的回應速度更快,使用上也更加愉快。

特別感謝 Philip Walton 為他提供本指南的技術審查。

縮圖圖片來源:Unsplash 網站上,由 Amirali Mirhashemian 提供。