如何跟面試官解釋事件循環(huán)
關(guān)于事件循環(huán)的問題面試官都尤其的偏愛,所以說準(zhǔn)備面試如果不搞懂事件循環(huán)是非常危險的。
當(dāng)面試官問你了解瀏覽器事件循環(huán)嗎?這只是一個開始,接下來:
為什么js在瀏覽器中有事件循環(huán)機制 事件循環(huán)有哪些任務(wù) 為什么要用微任務(wù),只有宏任務(wù)不行嗎 瀏覽器中事件循環(huán)機制怎么執(zhí)行的?與Node中有何區(qū)別 setTimeout為什么沒有按寫好的延遲時間執(zhí)行? ...
這一系列圍繞事件循環(huán)的問題都有可能會一步一步的讓你回答
本文圍繞以下幾個內(nèi)容來展開,讓你輕松的回答面試官關(guān)于事件循環(huán)系列問題。

為什么會有事件循環(huán)機制
JavaScript的一大特點就是單線程,也就是說,同一時間只能做一件事。那為什么要設(shè)計成單線程呢,多線程效率不是更高嗎?
有這樣一個場景:假定JavaScript同時有兩個線程,一個線程在某個DOM節(jié)點上添加內(nèi)容,另一個線程刪除了這個節(jié)點,這時瀏覽器應(yīng)該以哪個線程為準(zhǔn)?
所以,JavaScript從誕生就是單線程。但是單線程就導(dǎo)致有很多任務(wù)需要排隊,只有一個任務(wù)執(zhí)行完才能執(zhí)行后一個任務(wù)。如果某個執(zhí)行時間太長,就容易造成阻塞;為了解決這一問題,JavaScript引入了事件循環(huán)機制
事件循環(huán)是什么
Javascript單線程任務(wù)被分為同步任務(wù)和異步任務(wù)。
同步任務(wù):立即執(zhí)行的任務(wù),在主線程上排隊執(zhí)行,前一個任務(wù)執(zhí)行完畢,才能執(zhí)行后一個任務(wù); 異步任務(wù):異步執(zhí)行的任務(wù),不進(jìn)入主線程, 而是在異步任務(wù)有了結(jié)果后,將注冊的回調(diào)函數(shù)放入 任務(wù)隊列中等待主線程空閑的時候讀取執(zhí)行。
注意:異步函數(shù)在相應(yīng)輔助線程中處理完成后,即異步函數(shù)達(dá)到觸發(fā)條件了,就把
回調(diào)函數(shù)推入任務(wù)隊列中,而不是說注冊一個異步任務(wù)就會被放在這個任務(wù)隊列中
同步任務(wù)與異步任務(wù)流程圖:

從上面流程圖中可以看到,主線程不斷從任務(wù)隊列中讀取事件,這個過程是循環(huán)不斷的,這種運行機制就叫做Event Loop(事件循環(huán))!
事件循環(huán)中的兩種任務(wù)
在JavaScript中,除了廣義的同步任務(wù)和異步任務(wù),還可以細(xì)分,一種是宏任務(wù)(MacroTask)也叫Task,一種叫微任務(wù)(MicroTask)。
二者執(zhí)行順序流程圖如下:

每次單個宏任務(wù)執(zhí)行完畢后, 檢查微任務(wù)隊列是否為空, 如果不為空,會按照先入先出的規(guī)則全部執(zhí)行完微任務(wù)后, 清空微任務(wù)隊列, 然后再執(zhí)行下一個宏任務(wù),如此循環(huán)
如何區(qū)分宏任務(wù)與微任務(wù)呢?
宏任務(wù):macrotask,又稱為task, 可以理解為每次執(zhí)行棧執(zhí)行的代碼就是一個宏任務(wù)(包括每次從事件隊列中獲取一個事件回調(diào)并放到執(zhí)行棧中執(zhí)行)。
一般包括:script(可以理解為外層同步代碼)、setTimeout、setInterval 、setImmediate、I/O操作
微任務(wù):microtask, 又稱為job, 可以理解是在當(dāng)前 task 執(zhí)行結(jié)束后立即執(zhí)行的任務(wù)。包括: Promise.then/cath /finally回調(diào)(平時常見的)、 MutationObserver回調(diào)(html5新特性)
為什么要有微任務(wù)呢?
既然我們知道了微任務(wù)與宏任務(wù),但異步任務(wù)為什么要區(qū)分宏任務(wù)與微任務(wù)呢,只有宏任務(wù)不可以嗎?
因為事件隊列其實是一個“先進(jìn)先出”的數(shù)據(jù)結(jié)構(gòu),排在前面的事件會優(yōu)先被主線程讀取, 那如果突然來了一個優(yōu)先級更高的任務(wù),還讓去人家排隊,就很不理性化, 所以需要引入微任務(wù)。
舉一個現(xiàn)實生活中的例子:
就是我們?nèi)ャy行辦理業(yè)務(wù)時, 并不是到了就能辦理, 而是需要先取號排隊, 等到柜臺業(yè)務(wù)員辦理完當(dāng)前客戶業(yè)務(wù)才能繼續(xù)叫號進(jìn)行下一個。
這時每一個來辦理業(yè)務(wù)的人就可以認(rèn)為是銀行柜員的一個宏任務(wù)來存在, 當(dāng)辦理到你的業(yè)務(wù)時, 你本來只是要重新綁定一下手機號, 但是突然想到明天要參加婚禮,需要隨份子錢, 此時你和柜員說你要取money, 這時候柜員不能告訴你,讓你重新取號排隊(不合理的要求)。
其實這時候就相當(dāng)于你突然提出了一個新的任務(wù),這個任務(wù)就相當(dāng)于是一個
微任務(wù),它要在下一個宏任務(wù)之前完成。
在當(dāng)前的微任務(wù)沒有執(zhí)行完成時,是不會執(zhí)行下一個宏任務(wù)的。
面試中如果問到這里,基本已經(jīng)了解事件循環(huán)的理論掌握情況, 接下來可能就會說,來做一下下面幾道題吧, 考察你的實際理解到什么程度。
事件循環(huán)典型題目分析
案例1:代碼執(zhí)行結(jié)果是什么
async function async1() {
console.log("async1 start")
await async2()
console.log("async1 end")
}
async function async2(){
console.log("async2")
}
console.log("script start")
setTimeout(function(){
console.log("setTimeout")
}, 0)
async1()
new Promise(function(resolve){
console.log("promise1")
resolve()
}).then(function(){
console.log("promise2")
})
console.log("script end")
這里我們討論瀏覽器中的執(zhí)行結(jié)果,分析:
建立執(zhí)行上下文,先執(zhí)行同步任務(wù),輸出『script start』
往下執(zhí)行,遇到
setTimeout,將其回調(diào)函數(shù)放入宏任務(wù)隊列,等待執(zhí)行繼續(xù)往下執(zhí)行,調(diào)用
async1:是同步任務(wù),輸出『async1 start』 接下來 await async2(), 這里的代碼相當(dāng)于new Promise(()=>{async2()}),而將 await 后面的全部代碼放到.then()中去;所以輸出『async2』,把async2()后面的代碼放到微任務(wù)中繼續(xù)執(zhí)行,有個new Promise 輸出『promise1』,當(dāng)
resolve后,將.then()的回調(diào)函數(shù)放到微任務(wù)隊列中(記住Promise本身是同步的立即執(zhí)行函數(shù),then是異步執(zhí)行函數(shù))。繼續(xù)往下執(zhí)行, 輸出『script end』,此時調(diào)用棧被清空,可以執(zhí)行異步任務(wù)
開始第一次事件循環(huán):7.1 由于整個script 算一個宏任務(wù),因此該宏任務(wù)已經(jīng)執(zhí)行完畢 7.2 檢查微任務(wù)隊列, 發(fā)現(xiàn)其中放入了2個微任務(wù)(分別在3.2步,4步放入), 執(zhí)行輸出『async1 end』,『promise2』,第一次循環(huán)結(jié)束
開始第二次循環(huán):
從宏任務(wù)開始, 檢查宏任務(wù)隊列中有 setTimeout回調(diào), 輸出『setTimeout』檢查微任務(wù)隊列,無可執(zhí)行的微任務(wù), 第二次循環(huán)結(jié)束
注意:async/await底層是基于Promise封裝的,所以await前面的代碼相當(dāng)于new Promise,是同步進(jìn)行的,await后面的代碼相當(dāng)于.then回調(diào),才是異步進(jìn)行的。
最后執(zhí)行結(jié)果如下:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
關(guān)于第3步代碼執(zhí)行分析:
async function async1() {
console.log("async1 start")
await async2()
console.log("async1 end")
}
改為Promise寫法就是:
async function async1() {
new Promise((resolve, reject) =>{
console.log("async1 start")
resolve(async2())
}).then(()=>{
// 執(zhí)行 async1 函數(shù)await之后的語句
console.log("async1 end")
})
}
再看下面一道題
案例2:代碼執(zhí)行結(jié)果是什么
console.log("start");
setTimeout(() => {
console.log("children2")
Promise.resolve().then(() =>{
console.log("children3")
})
}, 0)
new Promise(function(resolve, reject){
console.log("children4")
setTimeout(function(){
console.log("children5")
resolve("children6")
}, 0)
}).then(res =>{
console.log("children7")
setTimeout(() =>{
console.log(res)
}, 0)
})
分析執(zhí)行順序:
首先將整體代碼作為一個宏任務(wù)執(zhí)行,輸出『start』
接著遇到
setTimeout,0ms后將其回調(diào)函數(shù)放入宏任務(wù)隊列接下來遇到
Promise, 由于Promise本身是立即執(zhí)行函數(shù), 所以先輸出『children4』3-1. 在
Promise中遇到setTimeout, 將其回調(diào)放入宏任務(wù)隊列中;整體代碼執(zhí)行完畢然后檢查并執(zhí)行所有微任務(wù), 因為沒有微任務(wù), 所以第一次事件循環(huán)結(jié)束,開始第二輪
執(zhí)行
第2步放入的宏任務(wù),輸出『children2』 5-1. 遇到Promise,并直接調(diào)用了resolve,將.then回調(diào)加入都微任務(wù)隊列中檢查并執(zhí)行所有微任務(wù), 輸出『children3』, 沒有多余的微任務(wù), 所以第二輪事件循環(huán)結(jié)束,開始第三輪事件循環(huán)
執(zhí)行
3-1中放入的宏任務(wù), 輸出『children5』, 并且調(diào)用了resolve, 所以將對應(yīng)的.then回調(diào)放入到微任務(wù)隊列中檢查并執(zhí)行所以微任務(wù), 輸出『children7』,遇到
setTimeout,將其加入到宏任務(wù)隊列中,開始第四輪事件循環(huán)執(zhí)行
第8步加入的宏任務(wù), 輸出『children6』, 沒有任何微任務(wù), 第四輪事件循環(huán)結(jié)束。
最后執(zhí)行結(jié)果:
start
children4
children2
children3
children5
children7
children6
注意:有的小伙伴在第3步中容易錯誤的將.then的回調(diào)放入微任務(wù)隊列;因為沒有調(diào)用
resolve或者reject之前是不算異步任務(wù)完成的, 所以不能將回調(diào)放入事件隊列
Node和瀏覽器的事件循環(huán)的區(qū)別?
Node的事件循環(huán)是libuv實現(xiàn)的,引用一張官網(wǎng)的圖:

圖中表示的是事件循環(huán)包含的6個階段,大體的task(宏任務(wù))執(zhí)行順序是這樣的:
timers定時器:本階段執(zhí)行已經(jīng)安排的 setTimeout()和setInterval()的回調(diào)函數(shù)。pending callbacks待定回調(diào):執(zhí)行延遲到下一個循環(huán)迭代的 I/O 回調(diào)。 idle, prepare:僅系統(tǒng)內(nèi)部使用,可以不必理會。 poll 輪詢:檢索新的 I/O事件;執(zhí)行與I/O相關(guān)的回調(diào)(幾乎所有情況下,除了關(guān)閉的回調(diào)函數(shù),它們由計時器和setImmediate()排定的之外),其余情況 node 將在此處阻塞。check 檢測: setImmediate()回調(diào)函數(shù)在這里執(zhí)行。close callbacks 關(guān)閉的回調(diào)函數(shù):一些準(zhǔn)備關(guān)閉的回調(diào)函數(shù),如: socket.on('close', ...)。
首先需要知道的是Node版本不同,執(zhí)行順序有所差異。因為Node v11之后, 事件循環(huán)的原理發(fā)生了變化,和瀏覽器執(zhí)行順序趨于一致,都是每執(zhí)行一個宏任務(wù)就執(zhí)行完微任務(wù)隊列。
在Node v10及以前,微任務(wù)和宏任務(wù)在Node的執(zhí)行順序:
執(zhí)行完一個階段的所有任務(wù) 執(zhí)行完 nextTick隊列里面的內(nèi)容然后執(zhí)行完微任務(wù)隊列的內(nèi)容
在Node v10及以前的版本,微任務(wù)會在事件循環(huán)的各個階段之間執(zhí)行,也就是一個階段執(zhí)行完畢,就會去執(zhí)行微任務(wù)隊列的任務(wù):

瀏覽器與Node執(zhí)行順序分別是什么
setTimeout(()=>{
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(()=>{
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
// 瀏覽器中:
timer1
promise1
timer2
promise2
// 在Node中:
timer1
timer2
promise1
promise2
在這個例子中,Node的邏輯如下(再強調(diào)一下Node v10及以下):
最初timer1和timer2就在timers階段中。開始時首先進(jìn)入timers階段,執(zhí)行timer1的回調(diào)函數(shù),打印timer1,并將promise1.then回調(diào)放入微任務(wù)隊列,同樣的步驟執(zhí)行timer2,打印timer2;至此,timer階段執(zhí)行結(jié)束,event loop進(jìn)入下一個階段之前,執(zhí)行微任務(wù)隊列的所有任務(wù),依次打印promise1、promise2。
setImmediate 的setTimeout的區(qū)別
setImmediate大部分瀏覽器暫時不支持,只有IE10、11支持,具體可見MDN。setImmediate和setTimeout是相似的,但根據(jù)它們被調(diào)用的時間以不同的方式表現(xiàn)。
setImmediate設(shè)計用于在當(dāng)前poll階段完成后check階段執(zhí)行腳本 。setTimeout安排在經(jīng)過最?。╩s)后運行的腳本,在timers階段執(zhí)行。
舉個例子:
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
其執(zhí)行順序為:
遇到setTimeout,雖然設(shè)置的是0毫秒觸發(fā),但是被node.js強制改為1毫秒,塞入times階段 遇到setImmediate塞入check階段 同步代碼執(zhí)行完畢,進(jìn)入Event Loop 先進(jìn)入times階段,檢查當(dāng)前時間過去了1毫秒沒有,如果過了1毫秒,滿足setTimeout條件,執(zhí)行回調(diào),如果沒過1毫秒,跳過 跳過空的階段,進(jìn)入check階段,執(zhí)行setImmediate回調(diào) 可見,1毫秒是個關(guān)鍵點,所以在上面的例子中,setImmediate不一定在setTimeout之前執(zhí)行了。
process.nextTick()與 Promise回調(diào)誰先執(zhí)行?
process.nextTick()是Node環(huán)境下的方法, 所以我們基于Node談?wù)摗?/p>
process.nextTick()是一個特殊的異步API,其不屬于任何的Event Loop階段。事實上Node在遇到這個API時,Event Loop根本就不會繼續(xù)進(jìn)行,會馬上停下來執(zhí)行process.nextTick(),這個執(zhí)行完后才會繼續(xù)Event Loop??梢钥匆幌聜€例子:
var fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
process.nextTick(() => {
console.log('nextTick 2');
});
});
process.nextTick(() => {
console.log('nextTick 1');
});
});
// 執(zhí)行結(jié)果
nextTick 1
setImmediate
nextTick 2
setTimeout
執(zhí)行流程梳理:
代碼都在 readFile回調(diào)中,回調(diào)執(zhí)行時處于poll階段遇到 setTimeout,雖然延時設(shè)置的是0, 但是相當(dāng)于setTimeout(fn,1),將其回調(diào)函數(shù)放入后面的timers階段接下來遇到 setImmediate,將其回調(diào)函數(shù)放入到后面的check階段遇到 process.nextTick, 立即執(zhí)行, 輸出 『nextTick 1』執(zhí)行到下一個階段check,輸出『setImmediate』, 又遇到 nextTick,執(zhí)行輸出『nextTick 2』執(zhí)行到下一個timers階段, 輸出『setTimeout』
這種機制其實類似于我們前面講的微任務(wù),但是并不完全一樣,比如同時有nextTick和Promise的時候,肯定是nextTick先執(zhí)行,原因是nextTick的隊列比Promise隊列優(yōu)先級更高。來看個例子:
setImmediate(() => {
console.log('setImmediate');
});
Promise.resolve().then(()=>{
console.log('promise')
})
process.nextTick(()=>{
console.log('nextTick')
})
// 運行結(jié)果
nextTick
promise
setImmediate
總結(jié)
文章包含了為什么會有事件循環(huán), 事件循環(huán)是什么,事件循環(huán)的運行機制以及Node和瀏覽器中事件循環(huán)的異同點,通過文章的學(xué)習(xí), 面對開篇提出的面試題,相信你都可以輕松的搞定。
關(guān)注公眾號【前端飯圈】獲取文章中的源碼, 另外還準(zhǔn)備了10道關(guān)于事件循環(huán)的面試題, 如果你想檢測以下自己是否完全掌握,可以領(lǐng)取題目, 回復(fù)【事件循環(huán)】即可領(lǐng)取。
參考文章:
https://www.ruanyifeng.com/blog/2014/10/event-loop.html
https://my.oschina.net/u/4390738/blog/3199580
https://www.jianshu.com/p/23fad3814398
https://juejin.cn/post/6844904004653154317
- EOF -
