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

您可能聽過「不要阻斷主執行緒」和「將長時間執行的工作拆開」, 但這些話是什麼意思?

發布日期:2022 年 9 月 30 日,上次更新時間:2024 年 12 月 19 日

保持 JavaScript 應用程式快速運作的常見建議通常可歸納為以下幾點:

  • 「不要封鎖主執行緒。」
  • 「將長任務拆分成多個子任務。」

這項建議很棒,但需要進行哪些工作?運送較少 JavaScript 是好事,但這是否會自動等同於回應速度更快的 UI 呢?也許會,也許不會。

如要瞭解如何最佳化 JavaScript 中的工作,您必須先瞭解工作是什麼,以及瀏覽器如何處理工作。

什麼是工作?

工作是指瀏覽器執行的任何獨立作業。包括算繪、剖析 HTML 和 CSS、執行 JavaScript,以及其他您可能無法直接控制的工作。在所有這些項目中,您編寫的 JavaScript 可能是工作量最大的來源。

Chrome 開發人員工具的效能分析器中顯示的工作視覺化圖表。這項工作位於堆疊頂端,下方有點按事件處理常式、函式呼叫和其他項目。這項工作也包含右側的一些算繪作業。
Chrome 開發人員工具的效能分析器中顯示,由 click 事件處理常式啟動的工作。

與 JavaScript 相關聯的工作會透過幾種方式影響效能:

  • 瀏覽器在啟動時下載 JavaScript 檔案後,會將剖析及編譯該 JavaScript 的工作加入佇列,以便稍後執行。
  • 在網頁生命週期的其他時間,當 JavaScript 執行工作時,工作會排入佇列,例如透過事件處理常式回應互動、JavaScript 驅動的動畫,以及分析資料收集等背景活動。

除了 網頁工作人員和類似的 API 之外,所有這些作業都會在主執行緒上進行。

什麼是主執行緒?

主執行緒是瀏覽器中執行大多數工作的執行緒,您編寫的幾乎所有 JavaScript 程式碼都會在此執行。

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

瀏覽器會在執行任何長度的工作時封鎖互動,但只要工作執行時間不會太長,使用者就不會察覺。不過,如果使用者嘗試與含有大量耗時工作的網頁互動,使用者介面就會感覺沒有回應,如果主要執行緒遭到封鎖的時間過長,甚至可能無法運作。

Chrome 開發人員工具效能剖析器中的長時間工作。工作遭到封鎖的部分 (超過 50 毫秒) 會以紅色斜線條紋圖案表示。
Chrome 效能分析器顯示的長時間工作。如果工作時間較長,工作角落會顯示紅色三角形,且工作遭到封鎖的部分會填入紅色斜線圖案。

為避免主執行緒遭到封鎖太久,您可以將長時間執行的工作拆分成多個較小的工作。

一項長時間工作,與同一項工作分成多項短時間工作。長任務是一個大長方形,而分塊任務是五個較小的方塊,加總寬度與長任務相同。
單一長任務的視覺化呈現,與分解成五個短任務的相同任務。

這很重要,因為工作分解後,瀏覽器就能更快回應優先順序較高的工作,包括使用者互動。之後,其餘工作會繼續執行直到完成,確保您最初排入佇列的工作順利完成。

圖片:說明如何將工作分成多個步驟,方便使用者互動。在頂端,長時間執行的工作會阻斷事件處理常式,直到工作完成為止。在底部,分塊工作允許事件處理常式比原本更早執行。
這張圖片顯示,如果工作時間過長,瀏覽器無法及時回應互動,互動會發生什麼情況;如果將時間較長的工作分成較小的工作,又會發生什麼情況。

在上圖頂端,使用者互動排入佇列的事件處理常式必須等待單一長時間工作完成,才能開始執行,這會延遲互動的發生。在這種情況下,使用者可能會注意到延遲。在底部,事件處理常式可以更快開始執行,互動可能感覺即時

您已瞭解將工作細分成多個部分的重要性,接下來可以學習如何在 JavaScript 中執行這項操作。

工作管理策略

軟體架構中常見的建議是將工作拆分成較小的函式:

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

在這個範例中,有名為 saveSettings() 的函式會呼叫五個函式,以驗證表單、顯示微調器、將資料傳送至應用程式後端、更新使用者介面,以及傳送 Analytics。

從概念上來說,saveSettings() 的架構設計良好。如要偵錯其中一個函式,可以遍歷專案樹狀結構,瞭解每個函式的作用。這樣劃分工作可讓專案更易於瀏覽及維護。

不過,這裡的潛在問題是,JavaScript 不會將這些函式視為獨立工作執行,因為這些函式是在 saveSettings() 函式中執行。這表示這五個函式會以一項工作執行。

Chrome 效能剖析器中顯示的 saveSettings 函式。頂層函式會呼叫另外五個函式,但所有工作都在一個長時間執行的工作中進行,因此使用者要等到所有函式完成執行,才能看到函式執行的結果。
單一函式 saveSettings() 會呼叫五個函式。這項工作會以單一大型單體工作的一部分執行,因此在所有五個函式完成前,系統不會提供任何視覺回應。

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

在這個案例中,saveSettings() 是由使用者點擊觸發,由於瀏覽器必須等到整個函式執行完畢,才能顯示回應,因此這項長時間執行的工作會導致 UI 緩慢且沒有回應,並會測得較差的「與下一次算繪間隔 (INP)」分數。

手動延後執行程式碼

為確保重要使用者面向的工作和 UI 回應優先於低優先順序工作,您可以將控制權交給主執行緒,暫時中斷工作,讓瀏覽器有機會執行更重要的工作。

開發人員將工作細分為較小單位的其中一種方法,是使用 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() 強制執行至少 5 毫秒的延遲

就產生而言,setTimeout 還有另一個缺點:使用 setTimeout 將程式碼延後至後續工作執行,藉此產生主執行緒時,該工作會新增至佇列的「結尾」。如果還有其他待處理的工作,系統會先執行這些工作,再執行延遲的程式碼。

專屬的產生 API:scheduler.yield()

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox Technology Preview: supported.
  • Safari: not supported.

Source

scheduler.yield() 專為在瀏覽器中讓出主執行緒而設計。

這不是語言層級的語法或特殊建構函式;scheduler.yield() 只是會傳回 Promise 的函式,該函式會在日後的工作中解析。在該 Promise 解決後 (無論是在明確的 .then() 鏈結中,還是以非同步函式 await 之後),任何鏈結要執行的程式碼都會在該未來工作執行。

實務上:插入 await scheduler.yield() 後,函式會在該點暫停執行,並產生至主執行緒。系統會排定在新的事件迴圈工作執行函式的其餘部分 (稱為函式的「續集」)。該工作開始時,系統會解析等待的 Promise,函式也會從中斷的地方繼續執行。

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

  // Yield to the main thread:
  await scheduler.yield()

  // Work that isn't user-visible, continued in a separate task:
  saveToDatabase();
  sendAnalytics();
}
Chrome 效能剖析器顯示的 saveSettings 函式,現在已分成兩項工作。第一個工作會呼叫兩個函式,然後產生,允許版面配置和繪圖工作發生,並為使用者提供可見的回應。因此,點擊事件會在 64 毫秒內完成,速度快上許多。第二項工作會呼叫最後三個函式。
函式 saveSettings() 的執行作業現在會分成兩項工作。因此,版面配置和繪製作業可以在工作之間執行,讓使用者更快看到視覺回應,如指標互動時間縮短許多所顯示。

不過,scheduler.yield() 相較於其他產生方式的真正優勢在於,系統會優先處理其續傳作業,也就是說,如果您在工作期間產生,系統會執行目前工作的續傳作業,再啟動任何其他類似工作。

這樣一來,其他工作來源的程式碼就不會中斷程式碼的執行順序,例如第三方指令碼的工作。

三張圖表:分別顯示不產生結果、產生結果,以及產生結果並繼續執行的工作。如果沒有產生,就會有長時間工作。如果採用讓步機制,會有更多較短的作業,但可能會遭到其他不相關的作業中斷。透過產生和續傳,會有更多較短的工作,但執行順序會保留。
使用 scheduler.yield() 時,延續作業會從中斷的地方繼續執行,然後再進行其他工作。

跨瀏覽器支援

並非所有瀏覽器都支援 scheduler.yield(),因此需要備用方案。

其中一個解決方案是將 scheduler-polyfill 放入建構作業,然後直接使用 scheduler.yield();Polyfill 會處理回退至其他工作排程函式,因此在不同瀏覽器中的運作方式類似。

或者,您也可以使用 Promise 包裝的 setTimeout,在 scheduler.yield() 無法使用時做為備援,以幾行程式碼編寫較不複雜的版本。

function yieldToMain () {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }

  // Fall back to yielding with setTimeout.
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

如果瀏覽器不支援 scheduler.yield(),雖然不會優先繼續執行作業,但仍會產生結果,讓瀏覽器保持回應狀態。

最後,如果程式碼的後續作業並非優先處理事項 (例如已知忙碌的網頁,讓出主執行緒可能會導致作業在一段時間內無法完成),程式碼可能無法讓出主執行緒。在這種情況下,scheduler.yield() 可視為一種漸進式強化功能:在支援 scheduler.yield() 的瀏覽器中產生結果,否則繼續執行。

您可以使用以下簡便的單行程式碼,同時偵測功能並等待單一微工作:

// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();

使用 scheduler.yield() 拆分長時間執行的工作

使用上述任一方法的好處是,您可以在任何 async 函式中await使用 scheduler.yield()

舉例來說,如果您有一系列工作要執行,這些工作通常會加總成一項長時間工作,您可以插入產生器,將工作分成多個部分。

async function runJobs(jobQueue) {
  for (const job of jobQueue) {
    // Run the job:
    job();

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

系統會優先處理 runJobs() 的後續作業,但仍允許執行優先順序較高的工作 (例如以視覺化方式回應使用者輸入內容),不必等待可能很長的工作清單完成。

不過,這樣使用產生器並非有效率的做法。scheduler.yield() 快速有效,但會造成一些負擔。如果 jobQueue 中的某些工作非常短,則與執行實際工作相比,產生和恢復的額外負擔可能會快速累積更多時間。

其中一種做法是批次處理工作,只有在上次產生結果後經過足夠時間時,才在工作之間產生結果。常見的期限是 50 毫秒,目的是避免工作變成長時間工作,但您可以調整這個期限,在回應速度和完成工作佇列的時間之間取捨。

async function runJobs(jobQueue, deadline=50) {
  let lastYield = performance.now();

  for (const job of jobQueue) {
    // Run the job:
    job();

    // If it's been longer than the deadline, yield to the main thread:
    if (performance.now() - lastYield > deadline) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

因此,工作會分成多個部分,執行時間不會過長,但執行器大約每 50 毫秒才會產生主執行緒。

Chrome 開發人員工具效能面板中顯示的一系列工作函式,其執行作業會分散在多個工作之間
工作會分批處理,並分成多個工作。

請勿使用 isInputPending()

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

isInputPending() API 可用於檢查使用者是否嘗試與網頁互動,且只會在輸入待處理時產生結果。

這樣一來,如果沒有待處理的輸入內容,JavaScript 就能繼續執行,而不是產生結果並排在工作佇列的後方。如「準備發布」中所述,這項功能可大幅提升效能,因為網站可能不會回報給主執行緒。

不過,自該 API 推出以來,我們對產生量的瞭解日益深入,尤其是導入 INP 後。我們不再建議使用這個 API,而是建議無論輸入是否待處理,都應產生結果,原因如下:

  • 在某些情況下,即使使用者已互動,isInputPending() 仍可能會錯誤傳回 false
  • 工作應產生結果的情況不只輸入。動畫和其他一般使用者介面更新,對於提供回應式網頁同樣重要。
  • 我們隨後推出了更全面的產生 API,可解決產生相關問題,例如 scheduler.postTask()scheduler.yield()

結論

管理工作並不容易,但這麼做可確保網頁能更快回應使用者互動。管理及安排工作優先順序的方法有很多種,沒有單一建議。再次提醒,管理工作時請注意以下幾點:

  • 將重要且使用者可見的工作讓給主要執行緒。
  • 使用 scheduler.yield() (搭配跨瀏覽器備援) 輕鬆產生及取得優先續傳
  • 最後,請盡量減少函式中的工作。

如要進一步瞭解 scheduler.yield()、其明確的任務排程相對 scheduler.postTask(),以及任務優先順序,請參閱「優先任務排程 API 文件」。

您應該能運用一或多項工具,在應用程式中安排工作,優先滿足使用者需求,同時確保完成較不重要的工作。這樣一來,使用者體驗會更優質,使用起來更順暢愉快。

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

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