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

您可能聽過「不要阻斷主執行緒」和「分割長時間的工作」,但這兩項建議的意思是什麼?

一般來說,要讓 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 效能分析器中顯示的 saveSettings 函式相同,只是加入了 yield 語句。結果是,原本單一的工作現在已分成五項獨立的工作,每項工作對應一個函式。
saveSettings() 函式現在會將其子項函式做為獨立工作執行。

專屬的排程器 API

setTimeout 是分割工作實用的做法,但可能會有缺點:當您透過延後程式碼,將其延後至後續工作中執行,以便交出主執行緒時,該工作會新增至佇列的結尾

如果您負責控制網頁上的所有程式碼,您可以建立自己的排程器,為工作排定優先順序,但第三方指令碼不會使用排程器。因此,您無法在這些環境中優先處理工作。您只能將其分割,或明確讓使用者進行互動。

瀏覽器支援

  • Chrome:94。
  • Edge:94。
  • Firefox:在標記後方。
  • Safari:不支援。

資料來源

排程器 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 以持續產生的內建收益

瀏覽器支援

  • Chrome:129。
  • Edge:129。
  • Firefox:不支援。
  • Safari:不支援。

資料來源

scheduler.yield() 是專門用於將控制權交給瀏覽器主執行緒的 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()

三張圖表分別顯示沒有讓出、讓出,以及讓出和繼續的作業。如未使用 yield,就會有長時間的工作。使用交出功能後,任務的時間會縮短,但可能會遭到其他不相關的任務中斷。使用產生和繼續後,工作會變得更短,但執行順序會保留。
使用 scheduler.yield() 時,即使獲得收益點,工作執行作業仍會從上次中斷的地方繼續作業。

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

不使用 isInputPending()

瀏覽器支援

  • Chrome:87。
  • 邊緣:87。
  • Firefox:不支援。
  • Safari:不支援。

資料來源

isInputPending() API 提供一種方法,可檢查使用者是否嘗試與網頁互動,並只在輸入內容處於待處理狀態時產生。

這樣一來,如果沒有待處理的輸入內容,JavaScript 就會繼續執行,而不是在任務佇列的後端結束並產生。對於可能不會將執行緒交還給主執行緒的網站,這項功能可大幅改善效能 (詳見「意圖要出貨」)。

不過,自從推出該 API 以來,我們對產生收益的瞭解已大幅提升,尤其是在推出 INP 之後。我們不再建議使用此 API,而是建議無論輸入內容是否處於待處理狀態,都一律傳回值,原因如下:

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

結論

管理工作雖然不容易,但可確保網頁更快回應使用者互動。關於管理工作和排定工作的優先順序,並沒有一體適用的建議,而是要提供幾種不同的技巧。以下是管理工作時需要考量的重點:

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

只要使用一或多個這類工具,您就能在應用程式中安排工作,讓應用程式優先處理使用者的需求,同時確保較不重要的工作也能完成。這樣一來,使用者體驗將會更佳,使用者也能更順暢地操作應用程式,享受更愉快的使用體驗。

特別感謝 Philip Walton 對本指南進行技術審查。

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