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

各位都說「不要封鎖主執行緒」 「分段執行很長的工作」 但這些事情是什麼意思?

傑瑞米.瓦格納 (Jeremy Wagner)
Jeremy Wagner

如果您閱讀過許多關於網站效能的知識,提高 JavaScript 應用程式速度的建議做法通常涉及以下幾點:

  • 「不要封鎖主執行緒。」
  • 「拆分長時間的工作。」

這代表什麼意思?以運送的 JavaScript 來說很不錯,但對於整個網頁生命週期而言,這會自動適用於精簡的使用者介面嗎?或許會,但也許不會。

如要掌握在 JavaScript 中最佳化工作的重要性,您必須瞭解工作的作用以及瀏覽器處理這些工作的方式,而簡單瞭解工作為何。

什麼是工作?

「工作」是指瀏覽器執行的任何獨立工作,工作內容包括轉譯、剖析 HTML 和 CSS、執行您撰寫的 JavaScript 程式碼,以及您無法直接控制的其他事項。最重要的是,您寫入和部署至網路的 JavaScript 是工作的主要來源。

Chrome 開發人員工具效能設定檔所描述工作的螢幕截圖。工作位於堆疊頂端,其下方包含點擊事件處理常式、函式呼叫及其他項目。這項工作也會在右側也會進行一些算繪作業。
在 Chrome 開發人員工具的效能分析器中,由 click 事件處理常式啟動的工作描述。

工作會以幾種方式影響效能。舉例來說,當瀏覽器在啟動期間下載 JavaScript 檔案時,會將工作排入佇列,以便剖析和編譯 JavaScript 以便執行。在網頁生命週期中,系統會在 JavaScript 執行工作時啟動工作,例如透過事件處理常式、以 JavaScript 驅動的動畫,以及收集數據分析等背景活動。所有這些工作 (網路工作站和類似 API 除外) 都發生在主執行緒。

主要執行緒為何?

主執行緒是大多數工作在瀏覽器中執行的位置。之所以稱為主要執行緒,原因為一個執行緒,也就是您編寫的幾乎所有 JavaScript 都能執行的工作。

主要執行緒一次只能處理一項工作。當工作時間超過特定時間點 (含 50 毫秒) 之後,系統就會將工作歸類為「長時間工作」。如果使用者在長時間工作執行期間嘗試與網頁互動,或是需要進行重要的轉譯更新,則瀏覽器處理這項作業的時間將有所延遲。這會造成互動或轉譯延遲。

Chrome 開發人員工具效能分析器中的長時間工作。工作的封鎖部分 (超過 50 毫秒) 以紅色對角條紋表示。
Chrome 效能分析器中所描述的長時間工作。長時間工作會以紅色三角形表示,在工作的角落會以紅色三角形表示,而該工作的封鎖部分填滿了對角線的紅色條紋。

您必須細分工作。換句話說,這項工作需要完成一項長時間的工作,並分成較少執行個別執行時間較短的工作。

單一長時間工作與同一項工作拆分為較短的工作。長時間的工作是一條大矩形,而區塊任務是指五個較小的方塊,其寬度與長時間工作相同。
以視覺化方式呈現單一長時間工作,將該工作細分為五個較短的工作。

當工作拆分後,瀏覽器就有更多機會回應優先順序較高的工作,其中包括使用者互動情形。

描繪拆分工作如何有助於使用者互動。在頂端,長時間工作會阻止事件處理常式在工作完成之前執行。在底部,區塊分割的工作會允許事件處理常式的執行速度比原本預期要快。

在上圖上方,由使用者互動排入佇列的事件處理常式必須等候一個長時間的工作才能執行,因此會延遲發生互動。在底部,事件處理常式有機會執行更早的執行。由於事件處理常式有機會在較小的工作之間執行,因此執行會比必須等待長時間工作完成的時間更快。在頂端範例中,使用者可能注意到延遲時間;在底部,這類互動看到的可能卻是「瞬間」

不過,問題在於「分工處理長時間的工作」和「不要封鎖主執行緒」這些建議不夠具體,除非您已經知道如何做這些事。這就是本指南的說明。

工作管理策略

在軟體架構中,常見的建議是將工作拆分為較小的功能。因此,您可以享有更好的程式碼可讀性,以及專案可維護性。這也有助於更容易編寫測試。

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

在這個範例中,有一個名為 saveSettings() 的函式會呼叫其中五個函式來執行工作,例如驗證表單、顯示旋轉圖示、傳送資料等等。就概念上而言,這個架構是結構良好的架構。假使您需要對上述其中一項函式進行偵錯,則可週遊專案樹狀結構來瞭解各個函式的功能。

然而,由於 JavaScript 是在 saveSettings() 函式中執行,因此 JavaScript 不會將這些函式做為個別工作執行。這表示五項函式全都會視為單一工作來執行。

storeSettings 功能,如 Chrome 效能分析器中所述。頂層函式會呼叫其他五個函式,但所有工作都會發生在一個會封鎖主執行緒的長時間工作中。
呼叫五個函式的單一函式 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() 並非適合這項工作的工具,至少不建議這麼做。

除了 setTimeout() 以外,還有其他 API 可讓您將程式碼延後到後續工作中。其中一個就是使用 postMessage() 來縮短逾時時間。你也可以使用 requestIdleCallback() 來拆分工作,請小心!requestIdleCallback() 會將工作排在最低優先順序,且僅在瀏覽器閒置期間,以最低優先順序安排工作。當主要執行緒擁塞時,透過 requestIdleCallback() 排程的工作可能就無法執行。

使用 async/await 來建立收益點

本指南其餘部分會提到「熱門會話串」的特色,但這到底是什麼意思?為什麼要這麼做?何時採取這個做法?

當工作分拆開來時,瀏覽器的內部優先機制還可以決定其他工作的優先順序。產生主要執行緒的其中一種方式,就是使用 Promise 的組合,以呼叫 setTimeout() 來解析:

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

saveSettings() 函式中,只要在每個函式呼叫後 await 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:
    await yieldToMain();
  }
}

結果就是現在的單體式工作現已拆分為不同的工作。

Chrome 效能分析器所呈現的 keepSettings 功能,只在有產生的情形發生。結果就是現在的單體式工作現在分為五個不同的工作,每個函式一個工作。
saveSettings() 函式現在會以獨立工作的形式執行子項函式。

使用以承諾為基礎的方法產生 (而非手動使用 setTimeout()) 的好處是更適合人體工學。收益點經過宣告,因此更容易撰寫、閱讀和理解。

只在必要時產生

如果你有許多工作,但只想在使用者嘗試與網頁互動時產生,這時該怎麼做?這就是 isInputPending() 的設計宗旨。

isInputPending() 是隨時都可執行的函式,用於判斷使用者是否嘗試與網頁元素互動:呼叫 isInputPending() 會傳回 true。如果沒有,則會傳回 false

假設您有需要執行的任務佇列,但又不想動到任何輸入內容。以下程式碼會同時使用 isInputPending() 和我們的自訂 yieldToMain() 函式,確保在使用者嘗試與網頁互動時,輸入內容不會延遲:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

saveSettings() 執行時,系統會循環處理佇列中的工作。如果 isInputPending() 在迴圈期間傳回 truesaveSettings() 會呼叫 yieldToMain(),以便處理使用者輸入內容。否則,它會將下一個工作移出佇列前端,並持續執行。直到所有工作都結束為止。

說明如何在 Chrome 效能分析器中執行 saveSettings 功能。結果工作會封鎖主執行緒,直到 isInputPending 傳回 true 時,工作才會產生至主要執行緒。
saveSettings() 會執行五項工作的工作佇列,但使用者在第二個工作項目執行時,已點選開啟選單。isInputPending() 將產生主執行緒來處理互動,並繼續執行其餘工作。

isInputPending() 與產生的機制搭配使用,有助於讓瀏覽器停止處理的任何工作,以便回應重要的使用者互動。這麼做能提高你的專頁在許多情況下可以回應使用者的工作。

使用 isInputPending() (特別是在不支援為不支援該功能的瀏覽器提供備用選項時) 的另一種方式,就是搭配使用以時間為準的方法和選用的鏈結運算子

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

透過這種做法,您可以透過以時間為準的方法,在不支援 isInputPending() 的瀏覽器上取得備用方案;透過採用 (及調整) 期限的做法,可依需求將工作拆分,例如產生使用者輸入內容,或按特定時間點完成作業。

目前 API 中的缺口

到目前為止提到的 API 可以幫助您中斷工作,但其缺點很大:當您把程式碼延後到後續工作中執行,並延遲執行到主執行緒時,該程式碼就會新增至工作佇列的結尾處。

如果您能夠控制網頁中的所有程式碼,可以自行建立排程器來排定工作的優先順序,但第三方指令碼不會使用排程器。實際上,您無法優先處理這類環境中的工作。您只能將訊息分割成區塊,或以明確的方式產生使用者互動。

幸好有開發中的專屬排程器 API 目前正在開發階段,可協助解決這些問題。

專屬排程器 API

排程器 API 目前提供 postTask() 函式,在編寫時,可在 Chromium 瀏覽器和 Firefox 中透過旗標進行。postTask() 可讓工作更精細,協助瀏覽器排定工作的優先順序,讓低優先順序的工作產生至主要執行緒。postTask() 採用承諾,並接受 priority 設定。

postTask() API 有三個可用的優先順序:

  • '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'});
};

在這個範例中,工作的排定工作順序,讓瀏覽器能優先執行特定工作 (例如使用者互動)。

此儲存設定功能如 Chrome 效能分析器中所述,但使用 postTask。postTask 會將每個函式 saveSettings 執行,並排定優先順序,讓使用者互動得以在不受到封鎖的情況下執行。
saveSettings() 執行時,函式會使用 postTask() 排定個別函式。為使用者處理的關鍵工作排程為高優先順序,而使用者不知道的工作已排定在背景執行。如此一來,由於作業會拆分並優先順序適當,使用者操作就能更快執行。

這是 postTask() 使用方式的簡易範例。您可以將可在不同工作之間共用優先順序的不同 TaskController 物件例項化,包括視需要變更不同 TaskController 例項的優先順序。

透過 scheduler.yield 內建連續收益

排程器 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();
  }
}

您會發現,大部分程式碼都很熟悉,但應改為呼叫 和 await scheduler.yield(),而非使用 yieldToMain()

三張圖呈現工作未產生、產生,以及產生和持續連續的情形。如果沒有產生結果,就表示工作很長。得到後,有更多工作時間較短,但可能因為其他不相關的工作而中斷。藉由產生和連續,更多工作會較短,但會保留其執行順序。
以視覺化方式呈現工作執行情形,未產生、產生並持續呈現及接續。使用 scheduler.yield() 時,即使在產生點後,工作執行作業仍會從上次中斷的地方繼續執行。

scheduler.yield() 的優點是持續性,也就是說,如果您在一組工作的中間中產生結果,其他已排定的工作會在收益點結束後依照相同順序繼續。這樣可以避免第三方指令碼的程式碼執行程式碼的執行順序。

結語

管理工作雖然不易,但這麼做有助於網頁更快回應使用者互動。針對管理及排定工作優先順序,沒有任何單一建議。而是多種不同的技巧。讓我們重申一下您在管理工作時應考慮的主要事項:

  • 使用者端的重要工作會返回主要執行緒。
  • 在使用者嘗試與網頁互動時,使用 isInputPending() 導向主要執行緒。
  • 使用 postTask() 排定工作的優先順序。
  • 最後,盡量減少函式的工作。

您可以利用其中一或多項工具在應用程式中建構工作結構,以便優先處理使用者的需求,同時確保較不重要的工作繼續完成。這樣可以提供更好的使用者體驗,不僅回應速度更快,使用起來也更愉悅。

特別感謝 Philip Walton 審核本文的技術人員。

主頁橫幅由 Unsplash 提供,由 Amirali Mirhashemian 提供。