本文的內容是瀏覽器的事件迴圈,並不是 nodejs 的事件迴圈,不要將兩者混淆。
文章原始內容來自 Google Developer Day China 2018 的一個講座,作者 Jake Archibald,我只是記錄並翻譯一下而已。其實這不是他首次分享這個內容,因此在 youtube 上搜他的名字和 Event Loop 能搜到講座錄影,有條件的開發者可以聽聽原版
我們先從一段程式碼開始
document.body.appendChild(el)
el.style.display = 'none'
複製程式碼
這兩句程式碼先把一個元素新增到 body,然後隱藏它。從直觀上來理解,可能大部分人覺得如此操作會導致頁面閃動,因此編碼時經常會交換兩句的順序:先隱藏再新增。
但實際上兩者寫法都不會造成閃動,因為他們都是同步程式碼。瀏覽器會把同步程式碼捆綁在一起執行,然後以執行結果為當前狀態進行渲染。因此無論兩句是什麼順序,瀏覽器都會執行完成後再一起渲染,因此結果是相同的。(除非同步程式碼中有獲取當前計算樣式的程式碼,後面會提到)
從本質上看,JS 是單程式的,也就是一次只能執行一個任務(或者說方法)。與之相對人不是單程式的,我們可以一邊動手一邊動腳;一邊跑步一邊說話,因此我們很難體會“阻塞”的概念。在 JS 中,阻塞值得就是因為某個任務(方法)執行時間太長,導致其他任務難以被執行的情況。
非同步佇列
但事實上有些任務的確是需要等待一會兒再處理的,例如 setTimeout
,或者非同步請求等。因此把主程式卡住等待返回會嚴重影響效率和體驗,所以 JS 還增加了非同步佇列 (task queue) 來解決這個問題。
每次碰到非同步操作,就把操作新增到非同步佇列中。等待主程式為空(即沒有同步程式碼需要執行了),就去執行非同步佇列。執行完成後再回到主程式。
以 setTimeout(callback, ms)
為例:
初始狀態:非同步開關關閉(因為非同步佇列為空)。然後 ms 毫秒後新增一個任務 T 到佇列中
現在非同步佇列不為空了,非同步開關開啟,然後主程式(白色方塊)進入到非同步佇列,準備去執行黃色的 timeout 任務。
渲染過程
頁面並不是時時刻刻被渲染的,瀏覽器會有固定的節奏去渲染頁面,稱為 render steps。它內部分為 3 個小步驟,分別是
- Structure - 構建 DOM 樹的結構
- Layout - 確認每個 DOM 的大致位置(排版)
- Paint - 繪製每個 DOM 具體的內容(繪製)
我們考慮如下的程式碼:
button.addEventListener('click', () => {
while(true);
})
複製程式碼
點選後會導致非同步佇列永遠執行,因此不單單主程式,渲染過程也同樣被阻塞而無法執行,因此頁面無法再選中(因為選中時頁面表現有所變化,文字有背景色,滑鼠也變成 text),也無法再更換內容。(但滑鼠卻可以動!)
如果我們把程式碼改成這樣
function loop() {
setTimeout(loop, 0)
}
loop()
複製程式碼
每個非同步任務的執行效果都是加入一個新的非同步任務,新的非同步任務將在下一次被執行,因此就不會存在阻塞。主程式和渲染過程都能正常進行。
requestAnimationFrame
是一個特別的非同步任務,只是註冊的方法不加入非同步佇列,而是加入渲染這一邊的佇列中,它在渲染的三個步驟之前被執行。通常用來處理渲染相關的工作。
我們來看一下 setTimeout
和 requestAnimationFrame
的差別。假設我們有一個元素 box,並且有一個 moveBoxForwardOnePixel
方法,作用是讓這個元素向右移動 1 畫素。
// 方法 1
function callback() {
moveBoxForwardOnePixel();
requestAnimationFrame(callback)
}
callback()
// 方法 2
function callback() {
moveBoxForwardOnePixel();
setTimeout(callback, 0)
}
callback()
複製程式碼
有這樣兩種方法來讓 box 移動起來。但實際測試發現,使用 setTimeout
移動的 box 要比 requestAnimationFrame
速度快得多。這表明單位時間內 callback
被呼叫的次數是不一樣的。
這是因為 setTimeout
在每次執行結束時都把自己新增到非同步佇列。等渲染過程的時候(不是每次執行非同步佇列都會進到渲染迴圈)非同步佇列已經執行過很多次了,所以渲染部分會一下會更新很多畫素,而不是 1 畫素。requestAnimationFrame
只在渲染過程之前執行,因此嚴格遵守“執行一次渲染一次”,所以一次只移動 1 畫素,是我們預期的方式。
如果在低端環境相容,常規也會寫作 setTimeout(callback, 1000 / 60)
來大致模擬 60 fps 的情況,但本質上 setTimeout
並不適合用來處理渲染相關的工作。因此和渲染動畫相關的,多用 requestAnimationFrame
,不會有掉幀的問題(即某一幀沒有渲染,下一幀把兩次的結果一起渲染了)
同步程式碼的合併
開頭說過,一段同步程式碼修改同一個元素的屬性,瀏覽器會直接優化到最後一個。例如
box.style.display = 'none'
box.style.display = 'block'
box.style.display = 'none'
複製程式碼
瀏覽器會直接隱藏元素,相當於只執行了最後一句。這是一種優化策略。
但有時候也會給我們造成困擾。例如如下程式碼:
box.style.transform = 'translateX(1000px)'
box.style.tranition = 'transform 1s ease'
box.style.transform = 'translateX(500px)'
複製程式碼
我們的本意是從讓 box 元素的位置從 0 一下子 移動到 1000,然後 動畫移動 到 500。
但實際情況是從 0 動畫移動 到 500。這也是由於瀏覽器的合併優化造成的。第一句設定位置到 1000 的程式碼被忽略了。
解決方法有 2 個:
-
我們剛才提過的
requestAnimationFrame
。思路是讓設定 box 的初始位置(第一句程式碼)在同步程式碼執行;讓設定 box 的動畫效果(第二句程式碼)和設定 box 的重點位置(第三句程式碼)放到下一幀執行。但要注意,
requestAnimationFrame
是在渲染過程 之前 執行的,因此直接寫成box.style.transform = 'translateX(1000px)' requestAnimationFrame(() => { box.style.tranition = 'transform 1s ease' box.style.transform = 'translateX(500px)' }) 複製程式碼
是無效的,因為這樣這三句程式碼依然是在同一幀中出現。那如何讓後兩句程式碼放到下一幀呢?這時候我們想到一句話:沒有什麼問題是一個
requestAnimationFrame
解決不了的,如果有,那就用兩個:box.style.transform = 'translateX(1000px)' requestAnimationFrame(() => { requestAnimationFrame(() => { box.style.transition = 'transform 1s ease' box.style.transform = 'translateX(500px)' }) }) 複製程式碼
在渲染過程之前,再一次註冊
requestAnimationFrame
,這就能夠讓後兩句程式碼放到下一幀去執行了,問題解決。(當然程式碼看上去有點奇怪) -
你之所以沒有在平時的程式碼中看到這樣奇葩的巢狀用法,是因為還有更簡單的實現方式,並且同樣能夠解決問題。這個問題的根源在於瀏覽器的合併優化,那麼打斷它的優化,就能解決問題。
box.style.transform = 'translateX(1000px)' getComputedStyle(box) // 虛擬碼,只要獲取一下當前的計算樣式即可 box.style.transition = 'transform 1s ease' box.style.transform = 'translateX(500px)' 複製程式碼
Microtasks
現在我們要引入“第三個”非同步佇列,叫做 Microtasks (規範中也稱為 Jobs)。
Microtasks are usually scheduled for things that should happen straight after the currently executing script, such as reacting to a batch of actions, or to make something async without taking the penalty of a whole new task.
簡單來說, Microtasks 就是在 當次 事件迴圈的 結尾 立刻執行 的任務。Promise.then()
內部的程式碼就屬於 microtasks。相對而言,之前的非同步佇列 (Task queue) 也叫做 macrotasks,不過一般還是簡稱為 tasks。
function callback() {
Promise.resolve().then(callback)
}
callback()
複製程式碼
這段程式碼是在執行 microtasks 的時候,又把自己新增到了 microtasks 中,看上去是和那個 setTimeout
內部繼續 setTimeout
類似。但實際效果卻和第一段 addEventListener
內部 while(true)
一樣,是會阻塞主程式的。這和 microtasks 內部的執行機制有關。
我們現在已經有了 3 個非同步佇列了,它們是
- Tasks (in
setTimeout
) - Animation callbacks (in
requestAnimationFrame
) - Microtasks (in
Promise.then
)
他們的執行特點是:
-
Tasks 只執行一個。執行完了就進入主程式,主程式可能決定進入其他兩個非同步佇列,也可能自己執行到空了再回來。
補充:對於“只執行一個”的理解,可以考慮設定 2 個相同時間的
timeout
,兩個並不會一起執行,而依然是分批的。 -
Animation callbacks 執行佇列裡的全部任務,但如果任務本身又新增 Animation callback 就不會當場執行了,因為那是下一個迴圈
補充:同 Tasks,可以考慮連續呼叫兩句
requestAnimationFrame
,它們會在同一次事件迴圈內執行,有別於 Tasks -
Microtasks 直接執行到空佇列才繼續。因此如果任務本身又新增 Microtasks,也會一直執行下去。所以上面的例子才會產生阻塞。
補充:因為是當次執行,因此如果既設定了
setTimeout(0)
又設定了Promise.then()
,優先執行 Microtasks。
一段神奇的程式碼
考慮如下的程式碼:
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('microtask 1'))
console.log('listener 1')
})
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('microtask 2'))
console.log('listener 2')
})
複製程式碼
在瀏覽器上執行後點選按鈕,會按順序列印
listener 1
microtask 1
listener 2
microtask 2
複製程式碼
但如果在上面程式碼的最後加上 button.click()
列印順序會 有所區別:
listener 1
listener 2
microtask 1
microtask 2
複製程式碼
主要是 listener 2
和 microtask 1
次序的問題,原因如下:
-
使用者直接點選的時候,瀏覽器先後觸發 2 個 listener。第一個 listener 觸發完成 (
listener 1
) 之後,佇列空了,就先列印了 microtask 1。然後再執行下一個 listener。重點在於瀏覽器並不實現知道有幾個 listener,因此它發現一個執行一個,執行完了再看後面還有沒有。 -
而使用
button.click()
時,瀏覽器的內部實現是把 2 個 listener 都同步執行。因此listener 1
之後,執行佇列還沒空,還要繼續執行listener 2
之後才行。所以listener 2
會早於microtask 1
。重點在於瀏覽器的內部實現,click
方法會先採集有哪些 listener,再依次觸發。
這個差別最大的應用在於自動化測試指令碼。在這裡可以看出,使用自動化指令碼測試和真正的使用者操作還是有細微的差別。如果程式碼中有類似的情況,要格外注意了。
針對其他瀏覽器如何表現這個問題,在原作者的一篇 2015 年的部落格中有所提及。其中設計的 case 更加完整,但當時各種瀏覽器給出了不一樣的輸出結果,因此他還在部落格中分析了一波誰對誰錯。直到今天雖然沒有標準指明應該怎樣,但所有瀏覽器都以如上分析的方式執行。
再來兩個測試題
第一題:
console.log('Start')
setTimeout(() => console.log('Timeout 1'), 0)
setTimeout(() => console.log('Timeout 2'), 0)
Promise.resolve().then(() => {
for(let i=0; i<100000; i++) {}
console.log('Promise 1')
})
Promise.resolve().then(() => console.log('Promise 2'))
console.log('End');
複製程式碼
第二題:(在瀏覽器上點選按鈕)
let button = document.querySelector('#button');
button.addEventListener('click', function CB1() {
console.log('Listener 1');
setTimeout(() => console.log('Timeout 1'))
Promise.resolve().then(() => console.log('Promise 1'))
});
button.addEventListener('click', function CB1() {
console.log('Listener 2');
setTimeout(() => console.log('Timeout 2'))
Promise.resolve().then(() => console.log('Promise 2'))
});
複製程式碼
公佈答案:
- 第一題: Start, End, Promise 1, Promise 2, Timeout 1, Timeout 2
- 第二題: Listener 1, Promise 1, Listener 2, Promise 2, Timeout 1, Timeout 2
這兩個題目來自一篇相關文章(連結在最後),其中還有詳細的分析,我這裡就不重複了。
相關文章
JavaScript: How is callback execution strategy for promises different than DOM events callback?