面試季臨近,Event Loop
這個概念也開始熱了,部落格上到處都在寫,面試到處都在問,於是我也藉此機會查閱了一些相關資料彌補自己的知識盲區,把自己學習完之後對於瀏覽器的 Event Loop
寫一篇個人總結,有理解不對之處歡迎大佬指正~
這篇暫不做
Node
環境下的Event Loop
的討論
JavaScript 裡的棧和佇列
在說 Event Loop
之前,我們要先理解棧(stack
)和佇列(queue
)的概念。
棧和佇列,兩者都是線性結構,但是棧遵循的是後進先出(last in first off,LIFO
),開口封底。而佇列遵循的是先進先出 (fisrt in first out,FIFO
),兩頭通透。
Event Loop
得以順利執行,它所依賴的容器環境,就和這兩個概念有關。
我們知道,在 js
程式碼執行過程中,會生成一個當前環境的“執行上下文( 執行環境 / 作用域)”,用於存放當前環境中的變數,這個上下文環境被生成以後,就會被推入js
的執行棧。一旦執行完成,那麼這個執行上下文就會被執行棧彈出,裡面相關的變數會被銷燬,在下一輪垃圾收集到來的時候,環境裡的變數佔據的記憶體就能得以釋放。
這個執行棧,也可以理解為JavaScript
的單一執行緒,所有程式碼都跑在這個裡面,以同步的方式依次執行,或者阻塞,這就是同步場景。
那麼非同步場景呢?顯然就需要一個獨立於“執行棧”之外的容器,專門管理這些非同步的狀態,於是在“主執行緒”、“執行棧”之外,有了一個 Task
的佇列結構,專門用於管理非同步邏輯。所有非同步操作的回撥,都會暫時被塞入這個佇列。Event Loop
處在兩者之間,扮演一個大管家的角色,它會以一個固定的時間間隔不斷輪詢,當它發現主執行緒空閒,就會去到 Task
佇列裡拿一個非同步回撥,把它塞入執行棧中執行,一段時間後,主執行緒執行完成,彈出上下文環境,再次空閒,Event Loop
又會執行同樣的操作。。。依次迴圈,於是構成了一套完整的事件迴圈執行機制。
上圖是筆者在 Google 上找的,比較簡潔地描繪了整個過程,只不過其中多了
heap
(堆)的概念,堆和棧,簡單來說,堆是留給開發者分配的記憶體空間,而棧是原生編譯器要使用的記憶體空間,二者獨立。
microtask 和 macrotask
如果只想應付普通點的面試,上面一節的內容就足夠了,但是想要答出下面的這條面試題,就必須再次深入 Event Loop
,瞭解任務佇列的深層原理:microtask
(微任務)和 macrotask
(巨集任務)。
面試題
// 請給出下面這段程式碼執行後,log 的列印順序
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
// log 列印順序:script start -> async2 end -> Promise -> script end -> promise1 -> promise2 -> async1 end -> setTimeout
複製程式碼
如果只有一個單一的 Task
佇列,就不存在上面的順序問題了。但事實情況是,瀏覽器會根據任務性質的不同,將不同的任務源塞入不同的佇列中,任務源可以分為微任務(microtask
) 和巨集任務(macrotask
),介於瀏覽器對兩種不同任務源佇列中回撥函式的讀取機制,造成了上述程式碼中的執行順序問題。
上圖摘自《掘金小冊:前端面試之道》
過程解析
讓我們首先來分析一下上述程式碼的執行流程:
JavaScript
解析引擎在指令碼開頭碰到了console.log
於是列印script strt
- 解析引擎解析至
async1()
,async1
執行環境被推入執行棧,解析引擎進入async1
內部 - 引擎發現
async1
內部呼叫了async2
,於是繼續進入async 2
,並將async 2
執行環境推入執行棧 - 引擎碰到
console.log
,於是列印async2 end
async2
函式執行完成,返回了一個Promise.resolve(undefined)
,此時,該回撥被推入 microtask ,async1
函式中的執行權被讓出,等待主執行緒空閒- 引擎解析至
setTimeout
,等待 0ms 後將其回撥推入 macrotask,執行權繼續讓出 - 引擎指標繼續下移,直到碰到了
Promise
,解析進入注入函式的內部,碰到console.log
,於是列印Promise
,再往下,碰到了resolve
,此時,該回撥被推入 microtask ,執行權被讓出 - 引擎繼續往下,碰到
console.log
,列印完script end
- 至此,主執行緒空閒,Event Loop 事件迴圈啟動,開始從 microtask 裡拿出 promise 回撥,放入主執行緒執行,首先拿出最早注入的
async2
的Promise.resolve(undefined)
執行,此時 await 操作符解析該表示式,得到結果 undefined,並將 async1 [Promise] 函式 標誌為 resolve 狀態,將 await 後面的程式碼作為回撥,繼續推入 microtask,等待執行,執行權被讓出 - 此時主執行緒沒有可執行的程式碼,再次空閒,Event Loop 啟動,去 microtask 中拿到之前的
new Promise
回撥,放入主執行緒執行,列印結果promise1
和promise2
- 主執行緒空閒,
Event Loop
去microtask
裡拿aysnc1
的回撥,列印出async1 end
- 最後,主執行緒空閒,
microtask
佇列空,Event Loop
去macrotask
裡拿到setTimeout
的回撥,放入主執行緒,列印最後的setTimeout
常見的微任務和巨集任務
微任務包括 process.nextTick
,promise
,MutationObserver
,其中 process.nextTick
為 Node 獨有。
巨集任務包括 script
, setTimeout
,setInterval
,setImmediate
,I/O
,UI rendering
。
關於 async
和 await
上述的面試題裡,大部分邏輯解釋下來都很好懂,除了一處,就是 await
後的 console.log(async1 end)
和 new Promise
resolve
後的回撥,到底哪個先執行?由於瀏覽器底層的解析引擎實現不同,對於不同的瀏覽器其結果可能不一樣(最新版的 chrome 瀏覽器對於 await 的處理變快了,async1 會先於 promise 1 列印)。
但是相比於這個執行順序,上述題目衍生出的一個更重要的問題,是對於 async/await
的理解。
對於 async/await
的更詳細解釋,大家可以參照這篇 理解 JavaScript 的 async/await,懶得看的童鞋可以看下面的結論:
- 一個函式,只要被
async
關鍵字包裝過,就會返回一個promise
,如果該函式有返回值,那麼這個返回值就會作為then
處理的response
,如果沒有返回值,那麼then
就處理undefined
await
表示式,只能用在被async
包裝過的函式裡,否則會報錯await
表示式後接的函式返回值,型別可以為promise
,或者其他任何的值,await
後的程式碼在當前執行環境下,會被阻塞至拿到該函式呼叫後的結果,等拿到結果後,會將await
後面的程式碼繼續包裝成新的promise
,並將之前拿到的結果作為response
傳入其中,同時讓出執行緒控權async/await
本質上是Generator
的語法糖
巨集任務與微任務,哪個先執行?
關於這個問題,眾說紛紜,很多大佬都說是巨集任務先於微任務執行,但是程式碼的執行結果卻顯示是微任務先執行。 先看看大佬們的解釋:
這裡很多人會有個誤區,認為微任務快於巨集任務,其實是錯誤的。因為巨集任務中包括了
script
,瀏覽器會先執行一個巨集任務,接下來有非同步程式碼的話才會先執行微任務。 《掘金小冊:前端面試之道》
也就是說,Event Loop
抓取回撥的邏輯是先執行巨集任務,再執行微任務,再執行巨集任務。。。以此迴圈,本質上來說,當前執行棧裡的程式碼都屬於巨集任務,於是等待執行棧清空,巨集任務執行完成,瀏覽器回去 microtask
裡抓取微任務來執行,除非 microtask
裡沒有,才會去 macrotask
抓取任務執行。