您可能聽過「不要阻斷主執行緒」和「分割長時間的工作」,但這些做法有何意義?
發布日期:2022 年 9 月 30 日,上次更新日期:2024 年 12 月 19 日
一般來說,要讓 JavaScript 應用程式保持快速,通常可以遵循以下建議:
- 「不要封鎖主執行緒。」
- 「拆分執行時間較長的任務」。
這是很好的建議,但需要做哪些工作?雖然減少 JavaScript 的運送量是件好事,但這是否會自動等同於更有回應的使用者介面?也許有,也許沒有。
如要瞭解如何在 JavaScript 中最佳化工作,您必須先瞭解工作是什麼,以及瀏覽器如何處理工作。
什麼是工作?
工作是指瀏覽器執行的任何獨立工作。這項工作包括轉譯、剖析 HTML 和 CSS、執行 JavaScript,以及其他您可能無法直接控制的工作。在所有這些工作中,您編寫的 JavaScript 可能會產生最多工作。
與 JavaScript 相關聯的工作會以幾種方式影響效能:
- 瀏覽器在啟動期間下載 JavaScript 檔案時,會將工作排入佇列,以便剖析及編譯該 JavaScript,以便稍後執行。
- 在網頁生命週期的其他時間,當 JavaScript 執行工作時,系統會將工作排入佇列,例如透過事件處理常式回應互動、JavaScript 驅動的動畫,以及分析收集等背景活動。
除了 Web Workers 和類似的 API 以外,所有這些內容都會在主執行緒中發生。
什麼是主執行緒?
主執行緒是指在瀏覽器中執行的大部分工作,以及執行您編寫的幾乎所有 JavaScript 的地方。
主要執行緒一次只能處理一項工作。任何耗時超過 50 毫秒的工作都是長時間工作。如果工作耗時超過 50 毫秒,則工作總時間減去 50 毫秒即為工作阻斷期。
瀏覽器會在任何長度的任務執行期間阻止互動,但只要任務執行時間不太長,使用者就不會察覺。不過,如果有許多耗時的作業,當使用者嘗試與網頁互動時,使用者介面就會變得無回應,甚至可能會損壞,因為主要執行緒會長時間遭到封鎖。
為避免主執行緒長時間遭到封鎖,您可以將長時間的工作拆成多個較小的工作。
這一點很重要,因為當工作分割後,瀏覽器就能更快回應優先順序較高的工作,包括使用者互動。之後,系統會執行剩餘的工作,確保您最初排入的工作會完成。
在前述圖表的頂端,由使用者互動排入佇列的事件處理常式必須等待單一長時間工作才能開始,這會延遲互動作業。在這種情況下,使用者可能會發現延遲情形。在底部,事件處理常式可以更早開始執行,因此互動可能會讓人覺得「即時」。
瞭解分割工作的重要性後,您可以學習如何在 JavaScript 中分割工作。
工作管理策略
軟體架構中常見的建議是將工作拆分為較小的函式:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
在這個範例中,有一個名為 saveSettings()
的函式,會呼叫五個函式來驗證表單、顯示旋轉圖示、將資料傳送至應用程式後端、更新使用者介面,以及傳送分析資料。
從概念上來說,saveSettings()
的架構設計良好。如果您需要對其中一個函式進行偵錯,可以瀏覽專案樹狀結構,瞭解每個函式的功能。這樣一來,您就能更輕鬆地瀏覽及維護專案。
不過,這裡可能會發生的問題是,JavaScript 不會將這些函式分別做為工作執行,因為這些函式是在 saveSettings()
函式中執行。這表示所有五個函式都會以單一工作執行。
在最佳情況下,即使只有其中一個函式,也可能會讓工作總長度增加 50 毫秒以上。在最糟的情況下,這些工作可能會執行更長的時間,尤其是在資源受限的裝置上。
在這種情況下,saveSettings()
會因使用者點擊而觸發,且由於瀏覽器必須等到整個函式執行完畢才能顯示回應,因此這個長時間任務的結果是 UI 反應緩慢,且會被評估為 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()
scheduler.yield()
是專門用於將控制權交給瀏覽器主執行緒的 API。
這不是語言層級語法或特殊結構;scheduler.yield()
只是會傳回 Promise
的函式,而 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();
}
不過,scheduler.yield()
相較於其他產生方法的真正優勢,在於其後續作業的優先順序,也就是說,如果您在任務中途產生,則目前任務的後續作業會在前面執行任何其他類似任務。
這樣可避免其他工作來源 (例如第三方指令碼的工作) 中斷程式碼的執行順序。
跨瀏覽器支援
scheduler.yield()
目前尚未在所有瀏覽器中支援,因此需要備用方案。
其中一個解決方案是將 scheduler-polyfill
放入建構項目,然後直接使用 scheduler.yield()
;polyfill 會處理回退至其他工作排程函式,因此在各瀏覽器中運作的方式也相同。
或者,您也可以使用較少行數的簡易版本,在 scheduler.yield()
無法使用時,只使用包裝在 Promise 中的 setTimeout
做為備用方案。
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 毫秒內產生至主執行緒。
不要使用 isInputPending()
isInputPending()
API 提供一種方法,可檢查使用者是否嘗試與網頁互動,並只在輸入內容處於待處理狀態時產生。
這樣一來,如果沒有待處理的輸入內容,JavaScript 就會繼續執行,而不是在工作佇列的後端結束並產生。對於可能不會將資料傳回至主執行緒的網站,這項功能可帶來顯著的效能提升,詳情請參閱「意圖要推出」一文。
不過,自從推出該 API 以來,我們對產生收益的理解已大幅提升,尤其是在推出 INP 之後。我們不再建議使用此 API,而是建議無論輸入內容是否處於待處理狀態,都一律傳回值,原因如下:
- 即使使用者在某些情況下已進行互動,
isInputPending()
仍可能會錯誤傳回false
。 - 輸入並非工作應產生的唯一結果。動畫和其他定期的使用者介面更新,對於提供回應式網頁同樣至關重要。
- 自此之後,我們推出了更全面的 yield API,可解決 yield 問題,例如
scheduler.postTask()
和scheduler.yield()
。
結論
管理工作雖然很困難,但這麼做可確保網頁更快回應使用者互動。管理及排定工作優先順序並沒有單一最佳做法,但有許多不同的技巧。以下是管理工作時需要考量的重點:
- 為關鍵的使用者面向工作,讓出主要執行緒。
- 使用
scheduler.yield()
(搭配跨瀏覽器備用方案),以符合人體工學的方式產生並取得優先順序的續行作業 - 最後,盡量減少函式中的作業。
如要進一步瞭解 scheduler.yield()
、明確的工作排程相對 scheduler.postTask()
和工作優先順序,請參閱 優先順序工作排程 API 文件。
只要使用一或多個這些工具,您就能在應用程式中安排工作,讓應用程式優先處理使用者的需求,同時確保較不重要的工作仍能完成。這樣一來,使用者體驗將會更佳,使用者也能更順暢地操作應用程式,享受更愉快的使用體驗。
特別感謝 Philip Walton 對本指南進行技術審查。
縮圖圖片來源:Unsplash,由 Amirali Mirhashemian 提供。