您可能聽過「不要阻斷主執行緒」和「將長時間執行的工作拆開」, 但這些話是什麼意思?
發布日期:2022 年 9 月 30 日,上次更新時間:2024 年 12 月 19 日
保持 JavaScript 應用程式快速運作的常見建議通常可歸納為以下幾點:
- 「不要封鎖主執行緒。」
- 「將長任務拆分成多個子任務。」
這項建議很棒,但需要進行哪些工作?運送較少 JavaScript 是好事,但這是否會自動等同於回應速度更快的 UI 呢?也許會,也許不會。
如要瞭解如何最佳化 JavaScript 中的工作,您必須先瞭解工作是什麼,以及瀏覽器如何處理工作。
什麼是工作?
工作是指瀏覽器執行的任何獨立作業。包括算繪、剖析 HTML 和 CSS、執行 JavaScript,以及其他您可能無法直接控制的工作。在所有這些項目中,您編寫的 JavaScript 可能是工作量最大的來源。

click
事件處理常式啟動的工作。
與 JavaScript 相關聯的工作會透過幾種方式影響效能:
- 瀏覽器在啟動時下載 JavaScript 檔案後,會將剖析及編譯該 JavaScript 的工作加入佇列,以便稍後執行。
- 在網頁生命週期的其他時間,當 JavaScript 執行工作時,工作會排入佇列,例如透過事件處理常式回應互動、JavaScript 驅動的動畫,以及分析資料收集等背景活動。
除了 網頁工作人員和類似的 API 之外,所有這些作業都會在主執行緒上進行。
什麼是主執行緒?
主執行緒是瀏覽器中執行大多數工作的執行緒,您編寫的幾乎所有 JavaScript 程式碼都會在此執行。
主執行緒一次只能處理一項工作。凡是耗時超過 50 毫秒的工作,都屬於長時間工作。如果工作超過 50 毫秒,工作總時間減去 50 毫秒,就是工作的「封鎖期」。
瀏覽器會在執行任何長度的工作時封鎖互動,但只要工作執行時間不會太長,使用者就不會察覺。不過,如果使用者嘗試與含有大量耗時工作的網頁互動,使用者介面就會感覺沒有回應,如果主要執行緒遭到封鎖的時間過長,甚至可能無法運作。

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

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

在上圖頂端,使用者互動排入佇列的事件處理常式必須等待單一長時間工作完成,才能開始執行,這會延遲互動的發生。在這種情況下,使用者可能會注意到延遲。在底部,事件處理常式可以更快開始執行,互動可能感覺即時。
您已瞭解將工作細分成多個部分的重要性,接下來可以學習如何在 JavaScript 中執行這項操作。
工作管理策略
軟體架構中常見的建議是將工作拆分成較小的函式:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
在這個範例中,有名為 saveSettings()
的函式會呼叫五個函式,以驗證表單、顯示微調器、將資料傳送至應用程式後端、更新使用者介面,以及傳送 Analytics。
從概念上來說,saveSettings()
的架構設計良好。如要偵錯其中一個函式,可以遍歷專案樹狀結構,瞭解每個函式的作用。這樣劃分工作可讓專案更易於瀏覽及維護。
不過,這裡的潛在問題是,JavaScript 不會將這些函式視為獨立工作執行,因為這些函式是在 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()
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();
}

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 毫秒才會產生主執行緒。

請勿使用 isInputPending()
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 提供。