您可能聽過「不要阻斷主執行緒」和「分割長時間的工作」,但這些做法究竟是什麼意思?
一般來說,要讓 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 毫秒或更多。在最糟的情況下,這些工作可能會執行更長的時間,尤其是在資源受限的裝置上。
手動延後程式碼執行
開發人員有時會使用 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();
}
}
結果就是,原本單體式的工作現在已拆分為個別工作。
專用排程器 API
setTimeout
是分割工作實用的做法,但可能會有缺點:當您透過延後程式碼,將其延後至後續工作中執行,以便交出主執行緒時,該工作會新增至佇列的結尾。
如果您控制網頁上的所有程式碼,可以自行建立排程器,以便為工作排定優先順序,但第三方指令碼不會使用您的排程器。因此,您無法在這種環境中優先處理工作。您只能將其分割,或明確讓使用者進行互動。
排程器 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'});
};
在這種情況下,系統會以某種方式排定工作優先順序,讓瀏覽器優先處理的工作 (例如使用者互動) 可視需要在兩者之間執行。
以下是 postTask()
的簡單使用範例。您可以將不同的 TaskController
物件例項化,以便在工作之間共用優先順序,包括根據需要變更不同 TaskController
例項的優先順序。
使用 scheduler.yield()
API 搭配繼續動作的內建收益
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();
}
}
這段程式碼大致熟悉,但它使用 await scheduler.yield()
,而非 yieldToMain()
。
scheduler.yield()
的好處是可以繼續執行,也就是說,如果您在一系列工作中途讓出,其他排定的任務會在讓出點後以相同順序繼續執行。這樣可避免第三方指令碼中斷程式碼的執行順序。
不要使用 isInputPending()
isInputPending()
API 提供一種方法,可檢查使用者是否嘗試與網頁互動,並只在輸入內容處於待處理狀態時產生。
這樣一來,如果沒有待處理的輸入內容,JavaScript 就會繼續執行,而不是在工作佇列的後端結束並產生。對於可能不會將執行緒交還給主執行緒的網站,這項功能可大幅改善效能 (詳見「意圖要出貨」)。
不過,自從推出該 API 以來,我們對產生收益的瞭解已大幅提升,尤其是在推出 INP 之後。我們不再建議使用此 API,而是建議無論輸入內容是否處於待處理狀態,都一律傳回值,原因如下:
- 即使使用者在某些情況下已進行互動,
isInputPending()
仍可能會錯誤傳回false
。 - 輸入並非工作應產生的唯一結果。動畫和其他定期的使用者介面更新,對於提供回應式網頁同樣至關重要。
- 自此之後,我們推出了更全面的 yield API,可解決 yield 問題,例如
scheduler.postTask()
和scheduler.yield()
。
結論
管理工作雖然不容易,但可確保網頁更快回應使用者互動。管理及排定工作優先順序並沒有單一最佳做法,但有許多不同的技巧。以下是管理工作時需要考量的重點:
- 將主要執行緒讓出,以便執行關鍵的使用者面向工作。
- 使用
postTask()
排定工作的優先順序。 - 建議您嘗試使用
scheduler.yield()
。 - 最後,盡量減少函式中的作業。
只要使用一或多個這類工具,您就能在應用程式中安排工作,讓應用程式優先處理使用者的需求,同時確保較不重要的工作仍能完成。這樣一來,使用者就能享有更即時、更愉快的使用體驗。
特別感謝 Philip Walton 對本指南進行技術審查。
縮圖圖片來源:Unsplash,由 Amirali Mirhashemian 提供。