如果你讀了關於 web performance 的東西,保持 JavaScript app 快速的建議往往涉及到這些事
- 不要阻塞
main thread
- 分解
long tasks
要理解為什麼優化 JavaScript 中的 task
很重要
- 你需要了解 task 的作用以及 browser 如何處理它們
task 是 browser 所做的任何離散(discrete)的工作
- task 涉及的工作包括: rendering、 parsing HTML 和 CSS、執行你的 JavaScript code,以及其他你無法直接控制的事情
- 在所有這些事情中,你寫的 JavaScript 是 task 的主要來源
task 以幾種方式影響性能。例如
- 當 browser 在啟動過程中下載一個 JavaScript file 時,它會排隊執行 task,以 parse 和 compile JS,從而使其得以執行
- 在頁面生命週期的後期,當你的 JS 進行工作時,task 會被啟動
- 比如通過 event handlers 驅動 interactions,JavaScript 驅動的 animations,以及 background activity,如 log
- 所有這些東西,除了 web workers 和類似的 API 之外,其他都是在 main thread 上進行的
main thread 是在 browser 中運行大多數 task 的地方
- 你寫的幾乎所有的 JavaScript 都是在這個 thread 上完成的
main thread 一次只能處理一個 task
- 當 task 超過一定的時間點,準確地說,是 50ms,它們被歸類為 long task
- 如果 User 在
long tasks
行時試圖與頁面互動,或者需要進行重要的 rendering update,那麼 browser 在處理這項 task 時就會出現延遲 - 這就造成了 interaction 或 rendering latency
(Chrome 的 performance profiler 中的 long tasks
的角落裡有一個紅色的三角形,任務的阻塞部分用紅色的對角線圖案填充。)
你需要拆分 task
- 將 long task,劃分為單獨運行時間較短的小 task
這點很重要,當 task 被分解時,browser 有更多的機會來 response 更優先的工作,如 user interactions
在上圖的頂部
- 一個 user interaction 的 event handler 必須等待一個
long task
才能運行,這 delays 了 interaction 的發生 - 在底部,該 event handler 有機會更快運行。因為 event handler 有機會在較小的 task 之間運行
- 在上面的例子中,User 可能已經注意到 lag;在下面的例子中,interaction 可能感覺是即時的
但問題是
- 「分解
long tasks
」和「不要阻塞main thread
」的建議不夠具體,除非你已經知道如何做這些事情
這就是這篇文章要解釋的內容
在軟件架構中
- 一個常見的建議是將工作分解成更小的功能。這帶來了更好的 readability,和 project maintainability 的好處
- 這也更容易寫測試
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
上面這例子中,有個 saveSettings()
function
- 其中呼叫了五個 function 來完成工作,如驗證表單、顯示 spinner、發送 data 等等
- 概念上,這是很好的架構。如果需要 debug 這些 function,你可以單獨找出每個 function 的作用
然而,問題是
- JavaScript 並沒有把這些 function 作為單獨的 task 來運行,因為它們是在
saveSettings()
function 中執行的 - 這意味著,所有五個 function 都作為一個 task 運行
- JavaScript 這樣工作是因為它採用
run-to-completion
的任務執行模式 - 意味著不管它阻塞 main thread 多長時間,每個任 task 一直運行到完成為止
下面是一套策略,可以用來分解 task 並確定其優先次序
Developer 使用的一種將 task 分解的方法是 setTimeout()
- 將 function 傳遞給
setTimeout()
。這將 callback 的執行推遲到一個單獨的 task 中,即使指定的 timeout 為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,這很好
- 但你的 code 並不總是這樣組織的
- 例如,你能有大量的 data 需要在一個循環中處理,如果有數以百萬,這個 task 可能需要很長的時間
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
例如,在上面這例子用 setTimeout()
是有問題的
- 它很難實現,而且整個 data set 可能需要很長的時間來處理,即使每個項目都可以很快處理
setTimeout()
並不是這邊合適的工具
除了 setTimeout()
,還有其他 API 允許將 code 的執行推遲到後續
- 使用
postMessage()
來加速 timeouts - 也可以使用
requestIdleCallback()
來分解工作- 但要注意!
requestIdleCallback()
以盡可能低的優先級來安排 task - 而且只在 browser 空閒的時候。當 main thread 擁擠時,用
requestIdleCallback()
調度的 task 可能永遠無法運行
- 但要注意!
yield to the main thread
- 這句話什麼意思?
- 為什麼要這樣做?
- 什麼時候應該這樣做?
當 yield to the main thread
時
- 你給它一個機會來處理比當前排隊的 tesk 更重要的 tesk
- 理想情況下,當你有些關鍵的面向 User 的工作需要盡快執行時,你應該讓位於 main thread
Yielding to the main thread
可以為關鍵 task 創造機會,使其更快地運行
當 task 被分解時,其他 task 可以通過 browser 的內部優先級方案更好地被優先處理
Promise
是一種yield to the main thread
方法是使用- 搭配呼叫
setTimeout()
- 搭配呼叫
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
注意
- 雖然 example return 了一個在 呼叫
setTimeout()
後 resolve 的Promise
,但負責在新 task 中運行其餘 code 的不是Promise
,而是setTimeout()
呼叫 Promise
callback 作為 microtasks 而不是 task 運行,因此不會yield to the main thread
在 saveSettings()
function 中
- 如果每個 function 呼叫後
await``yieldToMain()
function,你可以在每個 task 後yield to the main thread
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();
}
}
你不需要在每個 function 呼叫後都 yeild。例如
- 如果你運行的兩個 function 會導致 user interface 的關鍵性更新,你可能不想在它們之間 yield
- 如果可以的話,讓這些工作先運行,然後考慮在那些不那麼關鍵的或 User 看不到的後台工作的 function 之間 yield
其結果是
- 曾經的單體 task 現在被分解成獨立的 task
saveSettings()
function 現在作為獨立的 task 執行其 child function
- 使用基於
Promise
的方法來yielding
,而不是手動使用setTimeout()
是更好的做法 - Yield points 成為聲明性(declarative)的,因此更容易 code、閱讀和理解
如果有一堆 task
- 但你只想在 User 試圖 interact 時才 yield,那怎麼辦?
- 這就是 isInputPending() 所要做的事情
isInputPending()
可以在任何時候運行,以確定 User 是否試圖與 page interactisInputPending()
will returntrue
. It returnsfalse
otherwise
假設你有個 queue of tasks,但你不希望妨礙任何 input
- 這個 code 同時使用了
isInputPending()
和自定義的yieldToMain()
function - 保證了在 User 試圖 interact 時,input 不會被 delay
async function saveSettings () {
// A task queue of functions
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
];
while (tasks.length > 0) {
// Yield to a pending user input:
if (navigator.scheduling.isInputPending()) {
// There's a pending user input. Yield here:
await yieldToMain();
} else {
// Shift the the task out of the queue:
const task = tasks.shift();
// Run the task:
task();
}
}
}
當 saveSettings()
運行時
- 它將循環處理 queue 中的 task
- 如果
isInputPending()
在 loop 過程中返回true
saveSettings()
將呼叫yieldToMain()
以便處理 User input- 否則,它將連續運行,直到沒有更多的 task 留下
saveSettings()
運行一個 五個task 的 task queue
- 但 User 在運行第二個 task 時打開 menu,
isInputPending()
讓位於 main thread 處理 interaction,並恢復運行其餘的 task isInputPending()
不一定在 User input 後立即 returntrue
- 因為 OS 需要時間來告訴 browser 發生了 interaction。這意味著其他 code 可能已經開始執行了
- (正如上面的截圖中看到的
saveToDatabase()
)
- (正如上面的截圖中看到的
- 即使使用
isInputPending()
,仍然必須限制每個 function 的工作量
當 browser 不支持 isInputPending()
時,退而求其次使用 time-based 的方式
- 這方法,可以為不支持
isInputPending()
的 browser 提供後備方案
async function saveSettings () {
// A task queue of functions
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
];
let deadline = performance.now() + 50;
while (tasks.length > 0) {
// Optional chaining operator used here helps to avoid
// errors in browsers that don't support `isInputPending`:
if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
// There's a pending user input, or the
// deadline has been reached. Yield here:
await yieldToMain();
// Extend the deadline:
deadline += 50;
// Stop the execution of the current loop and
// move onto the next iteration:
continue;
}
// Shift the the task out of the queue:
const task = tasks.shift();
// Run the task:
task();
}
}
到目前為止提到的 API 可以幫助分解 task
- 但它們有個顯著的缺點
- 當通過 deferring code 在後續 task 中運行而
yield to the main thread
時,該 code 會被添加到 task queue 的最末端
如果你控制了頁面上的所有 code,就有可能創建自己的 scheduler,並能進行優先排序
- 但 third-party scripts 不會使用你的 scheduler
- 在這樣的環境中,你並不能真正地對 task 進行優先排序。只能把它分成幾塊,或者明確地 yield to user interactions
- 幸運的是,目前有個專門的 scheduler API 正在開發中,它可以解決這些問題
scheduler API目前提供了 postTask()
function
- caniuse: https://caniuse.com/?search=postTask
postTask()
允許對 task 進行更精細的調度- 也是幫助 browser 確定 task 優先級的一種方式
postTask()
使用Promise
,並接受priority
param
The postTask()
API has three priorities you can use:
'background'
: 最低優先級的 tasks.'user-visible'
: (default) 中等優先級的 task'user-blocking'
: critical tasks, high priority
以下面的 code 為例,postTask()
API 被用來以最高優先級運行三個 task ,其餘兩個任務以最低優先級
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'});
};
saveSettings()
運行時
- function 使用
postTask()
安排各個功能 - User 的關鍵工作被安排在 high priority,而 User 不知道的工作被安排在後台運行
- 這使 user interaction 的執行速度更快,因為工作被分割開來,並被適當地優先處理
- 這是個如何使用
postTask()
的簡單例子。可以實例化不同的TaskController
對象,在 task 之間共享優先級
scheduler API 中有一個 proposed 的部分,目前沒有在任何 browser 中實現
- 就是一個 built-in yielding 的機制。它的使用類似於前面的
yieldToMain()
function
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();
}
}
上面的 code 基本上跟之前的範例很像
- 但沒有使用
yieldToMain()
,而是呼叫和await scheduler.yield()
(沒有 yielding、有 yielding、有 yielding 和 continuation)
當使用 scheduler.yield()
時
- task 的執行會從它離開的地方繼續進行,甚至在 yield point 之後
scheduler.yield()
的好處是 continuation,意味著如果在一組 task 的中間 yield,其他 task 將在 yield point 之後以相同的順序繼續進行- 這就避免了 third-party scripts 篡改你的執行順序
管理 task 是具有挑戰性的
- 但這樣做可以更快 response user interaction
- 對於管理和優先處理任務,沒有一個唯一的建議
這些是在管理 task 時要考慮的主要事項
- 對於關鍵的、面向 User 的任務
Yield to the main thread
- 使用
isInputPending()
在 User 試圖 interact 時,將其切換到 main thread - 使用
postTask()
對任務進行優先排序 - 最後,在 function 中盡可能地少做工作