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

您可能聽過「不要阻斷主執行緒」和「拆分長時間任務」等建議,但這些建議具體代表什麼意思?

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

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

  • 「不要封鎖主執行緒。」
  • 「拆分長時間任務。」

這項建議很棒,但具體該怎麼做?傳送「較少」JavaScript 是好事,但這是否會自動等同於回應速度更快的使用者介面?也許是,但也可能不是。

如要瞭解如何最佳化 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() 的函式會呼叫五個函式,分別用於驗證表單、顯示旋轉圖示、將資料傳送至應用程式後端、更新使用者介面,以及傳送數據分析資料。

從概念上來說,saveSettings() 的架構良好。如要對其中一個函式進行偵錯,可以周遊專案樹狀結構,瞭解每個函式的作用。這樣拆分工作,專案會更容易瀏覽及維護。

不過,這裡可能會有一個問題,那就是 JavaScript 不會將這些函式視為個別工作執行,因為這些函式是在 saveSettings() 函式中執行。這表示所有五個函式都會以單一工作的形式執行。

Chrome 效能分析器中顯示的 saveSettings 函式。雖然頂層函式會呼叫其他五個函式,但所有工作都會在一個長時間任務中進行,因此在所有工作完成之前,使用者看不到執行函式的結果。
呼叫五個函式的單一函式 saveSettings()。這項工作會做為一個大型單一工作執行,在所有五個函式完成之前,會封鎖任何視覺回應。

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

在這個例子中,saveSettings() 是由使用者點擊觸發,而瀏覽器必須等到整個函式執行完畢才能顯示回應,因此這項長時間任務會導致使用者介面緩慢且沒有回應,並會評估為 Interaction to Next Paint (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: 142.
  • Safari: not supported.

Source

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

這不是語言層級語法或特殊建構函式;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() 的真正優點在於優先處理接續作業,也就是說,如果您在工作中途讓出,系統會先執行目前工作的接續作業,然後才啟動其他類似工作。

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

三張圖表,分別說明不讓出、讓出,以及讓出並接續執行的工作。如果不讓出控制權,就會出現長時間任務。使用 yield 時,會有更多較短的工作,但可能會受到其他不相關的工作干擾。使用 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() 拆分長時間執行的工作

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

舉例來說,如果您有一系列工作要執行,而這些工作通常會加總成一項長時間任務,您可以插入 yield 來拆分工作。

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

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

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

不過,這並非有效率的 yield 做法。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 對本指南進行技術審查。