本文的內容是瀏覽器的事件循環,並非 nodejs 的事件循環,不要將二者混淆。javascript
文章原始內容來自 Google Developer Day China 2018 的一個講座,做者 Jake Archibald,我只是記錄並翻譯一下而已。其實這不是他首次分享這個內容,所以在 youtube 上搜他的名字和 Event Loop 能搜到講座錄像,有條件的開發者能夠聽聽原版java
咱們先從一段代碼開始node
document.body.appendChild(el)
el.style.display = 'none'
複製代碼
這兩句代碼先把一個元素添加到 body,而後隱藏它。從直觀上來理解,可能大部分人以爲如此操做會致使頁面閃動,所以編碼時常常會交換兩句的順序:先隱藏再添加。react
但實際上二者寫法都不會形成閃動,由於他們都是同步代碼。瀏覽器會把同步代碼捆綁在一塊兒執行,而後以執行結果爲當前狀態進行渲染。所以不管兩句是什麼順序,瀏覽器都會執行完成後再一塊兒渲染,所以結果是相同的。(除非同步代碼中有獲取當前計算樣式的代碼,後面會提到)promise
從本質上看,JS 是單進程的,也就是一次只能執行一個任務(或者說方法)。與之相對人不是單進程的,咱們能夠一邊動手一邊動腳;一邊跑步一邊說話,所以咱們很難體會「阻塞」的概念。在 JS 中,阻塞值得就是由於某個任務(方法)執行時間太長,致使其餘任務難以被執行的狀況。瀏覽器
但事實上有些任務的確是須要等待一下子再處理的,例如 setTimeout
,或者異步請求等。所以把主進程卡住等待返回會嚴重影響效率和體驗,因此 JS 還增長了異步隊列 (task queue) 來解決這個問題。bash
每次碰到異步操做,就把操做添加到異步隊列中。等待主進程爲空(即沒有同步代碼須要執行了),就去執行異步隊列。執行完成後再回到主進程。app
以 setTimeout(callback, ms)
爲例:dom
初始狀態:異步開關關閉(由於異步隊列爲空)。而後 ms 毫秒後添加一個任務 T 到隊列中異步
如今異步隊列不爲空了,異步開關打開,而後主進程(白色方塊)進入到異步隊列,準備去執行黃色的 timeout 任務。
頁面並非時時刻刻被渲染的,瀏覽器會有固定的節奏去渲染頁面,稱爲 render steps。它內部分爲 3 個小步驟,分別是
咱們考慮以下的代碼:
button.addEventListener('click', () => {
while(true);
})
複製代碼
點擊後會致使異步隊列永遠執行,所以不僅僅主進程,渲染過程也一樣被阻塞而沒法執行,所以頁面沒法再選中(由於選中時頁面表現有所變化,文字有背景色,鼠標也變成 text),也沒法再更換內容。(但鼠標卻能夠動!)
若是咱們把代碼改爲這樣
function loop() {
setTimeout(loop, 0)
}
loop()
複製代碼
每一個異步任務的執行效果都是加入一個新的異步任務,新的異步任務將在下一次被執行,所以就不會存在阻塞。主進程和渲染過程都能正常進行。
是一個特別的異步任務,只是註冊的方法不加入異步隊列,而是加入渲染這一邊的隊列中,它在渲染的三個步驟以前被執行。一般用來處理渲染相關的工做。
咱們來看一下 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 (規範中也稱爲 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 個異步隊列了,它們是
setTimeout
)requestAnimationFrame
)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'))
});
複製代碼
公佈答案:
這兩個題目來自一篇相關文章(連接在最後),其中還有詳細的分析,我這裏就不重複了。
JavaScript: How is callback execution strategy for promises different than DOM events callback?