JS中比較讓人頭疼的問(wèn)題之一要算異步事件了,比如我們經(jīng)常要等后臺(tái)返回?cái)?shù)據(jù)后進(jìn)行dom操作,又比如我們要設(shè)置一個(gè)定時(shí)器完成特定的要求。在這些同步與異步事件里,異步事件肯定是在同步事件之后的,但是異步事件之間又是怎么樣的一個(gè)順序呢,比如多個(gè)setTimeout事件又是怎么樣一個(gè)執(zhí)行順序?這就涉及到事件循環(huán):Event Loop。
JS的單線程
雖然現(xiàn)在的JS可以用來(lái)做多方面的開(kāi)發(fā),但是最初的JS是瀏覽器的專用語(yǔ)言,用來(lái)操作DOM。所以從誕生之初,JS就被設(shè)計(jì)成單線程語(yǔ)言,原因是不想讓瀏覽器變得太復(fù)雜,因?yàn)槎嗑€程需要共享資源、且有可能修改彼此的運(yùn)行結(jié)果,對(duì)于一種網(wǎng)頁(yè)腳本語(yǔ)言來(lái)說(shuō),這就太復(fù)雜了。如果 JavaScript 同時(shí)有兩個(gè)線程,一個(gè)線程在網(wǎng)頁(yè) DOM 節(jié)點(diǎn)上添加內(nèi)容,另一個(gè)線程刪除了這個(gè)節(jié)點(diǎn),這時(shí)瀏覽器應(yīng)該以哪個(gè)線程為準(zhǔn)?是不是還要有鎖機(jī)制?所以,為了避免復(fù)雜性,JavaScript 一開(kāi)始就是單線程,這已經(jīng)成了這門語(yǔ)言的核心特征,將來(lái)也不會(huì)改變。
但是這種單線程機(jī)制卻制造了另一個(gè)麻煩,假如一個(gè)操作需花費(fèi)很長(zhǎng)時(shí)間,那么此時(shí)瀏覽器就會(huì)一直等待這個(gè)操作完成,就會(huì)造成不好的體驗(yàn)。因此,JS的另一個(gè)事件就是異步事件。異步事件是專門將一些事件以隊(duì)列的形式儲(chǔ)存到瀏覽器的任務(wù)隊(duì)列中,等同步事件執(zhí)行完后再去執(zhí)行,這樣就避免了頁(yè)面堵塞。
JavaScript 引擎怎么知道異步任務(wù)有沒(méi)有結(jié)果,能不能進(jìn)入主線程呢?答案就是引擎在不停地檢查,一遍又一遍,只要同步任務(wù)執(zhí)行完了,引擎就會(huì)去檢查那些掛起來(lái)的異步任務(wù),是不是可以進(jìn)入主線程了。這種循環(huán)檢查的機(jī)制,就叫做事件循環(huán)(Event Loop)。
瀏覽器的事件循環(huán)

如上圖所示,js中的基本數(shù)據(jù)與對(duì)象都會(huì)儲(chǔ)存在棧內(nèi)存中,其中復(fù)雜類型數(shù)據(jù)對(duì)象會(huì)在堆內(nèi)存儲(chǔ)存其數(shù)據(jù)結(jié)構(gòu),棧內(nèi)存儲(chǔ)存的是對(duì)這個(gè)數(shù)據(jù)結(jié)構(gòu)的引用。
執(zhí)行棧
javaScript是單線程,也就是說(shuō)只有一個(gè)主線程,主線程有一個(gè)棧。當(dāng)JS代碼執(zhí)行時(shí),代碼會(huì)被推入執(zhí)行棧中進(jìn)行運(yùn)行,運(yùn)行代碼的過(guò)程中,同步事件會(huì)立即執(zhí)行,其中Dom、Ajax以及SetTimeout等異步事件會(huì)注冊(cè)回調(diào)函數(shù),放入事件回調(diào)隊(duì)列中,等同步代碼執(zhí)行完之后執(zhí)行。這樣一個(gè)循環(huán)便是瀏覽器的Event Loop。

異步過(guò)程
但是在回調(diào)隊(duì)列中這些事件又是怎么樣一個(gè)執(zhí)行順序呢?實(shí)際上異步隊(duì)列存在兩個(gè)隊(duì)列,一個(gè)宏任務(wù)隊(duì)列,一個(gè)微任務(wù)隊(duì)列,其這就涉及到兩個(gè)概念:
- 宏任務(wù)(MacroTask):
包括整體代碼script,setTimeout、setInterval、setImmediate、I/O、UI渲染 - 微任務(wù)(MicroTask):
Promise、process.nextTick、Object.observe、MutationObserver
在棧內(nèi)存中代碼執(zhí)行完后,瀏覽器空閑,立即處理回調(diào)隊(duì)列,將回調(diào)隊(duì)列中的宏任務(wù)隊(duì)列中的事件推入執(zhí)行棧中執(zhí)行。
- 首先會(huì)執(zhí)行宏任務(wù),如果宏任務(wù)中存在宏任務(wù),則會(huì)把該任務(wù)放到宏任務(wù)隊(duì)列中。如果該任務(wù)里存在微任務(wù),則把微任務(wù)放在微任務(wù)隊(duì)列。
- 在這個(gè)宏任務(wù)執(zhí)行完后,首先去看微任務(wù)隊(duì)列中是否有任務(wù),然后把微任務(wù)推到執(zhí)行棧中執(zhí)行。
執(zhí)行完微任務(wù)隊(duì)列,這一次循環(huán)就結(jié)束了,然后再進(jìn)行在宏任務(wù)隊(duì)列中進(jìn)行下一個(gè)宏任務(wù),微任務(wù),直至回調(diào)隊(duì)列清空。
上述事件歸納后,以下例說(shuō)明:

分析:
循環(huán)1:
- 【task隊(duì)列:script ;microtask隊(duì)列:】
1.首先整個(gè)代碼被推到執(zhí)行棧中執(zhí)行,這是一個(gè)宏任務(wù)(整個(gè)script代碼)
2.運(yùn)行中,同步代碼立即執(zhí)行,new Promise中的fn是立即執(zhí)行的。setTimeout被放在宏任務(wù)隊(duì)列中,promise1、promise2被放在微任務(wù)隊(duì)列中。 - 【task隊(duì)列:setTimeout ;microtask隊(duì)列:promise1、promise2】
3.宏任務(wù)script執(zhí)行完后,執(zhí)行微任務(wù)隊(duì)列,取出microtask隊(duì)列,推入執(zhí)行棧執(zhí)行,第一次循環(huán)到此結(jié)束。
循環(huán)2:
- 【task隊(duì)列:setTimeout ;microtask隊(duì)列:】
4.取出宏任務(wù)中的setTimeout推入執(zhí)行棧執(zhí)行,如果有微任務(wù)則,則被放在微任務(wù)隊(duì)列(這里沒(méi)有)。
5.宏任務(wù)執(zhí)行完,去微任務(wù)隊(duì)列執(zhí)行(微任務(wù)隊(duì)列為空)。 - 【task隊(duì)列:;microtask隊(duì)列:】
6.宏任務(wù)隊(duì)列為空,循環(huán)至此結(jié)束。
Nodejs 事件循環(huán)
nodejs中的事件循環(huán)跟瀏覽器不一樣,瀏覽器的循環(huán)是遵循ES標(biāo)準(zhǔn)里的,nodejs里的循環(huán)是通過(guò)LIBUV庫(kù)實(shí)現(xiàn)的。
當(dāng) Node.js 啟動(dòng)時(shí),會(huì)做這幾件事
- 初始化 event loop
- 開(kāi)始執(zhí)行腳本(或者進(jìn)入 REPL,本文不涉及 REPL)。這些腳本有可能會(huì)調(diào)用一些異步 API、設(shè)定計(jì)時(shí)器或者調(diào)用 process.nextTick()
- 開(kāi)始處理 event loop
nodejs的Event Loop 一共有6個(gè)階段:
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
其中我們主要需要關(guān)注的是timers、poll、check階段:
- timers 階段:這個(gè)階段執(zhí)行 setTimeout 和 setInterval 的回調(diào)函數(shù)。
- I/O callbacks 階段:不在 timers 階段、close callbacks 階段和 check 階段這三個(gè)階段執(zhí)行的回調(diào),都由此階段負(fù)責(zé),這幾乎包含了所有回調(diào)函數(shù)。
- idle, prepare 階段(譯注:看起來(lái)是兩個(gè)階段,不過(guò)這不重要):event loop 內(nèi)部使用的階段(譯注:我們不用關(guān)心這個(gè)階段)
- poll 階段:獲取新的 I/O 事件。在某些場(chǎng)景下 Node.js 會(huì)阻塞在這個(gè)階段。
- check 階段:執(zhí)行 setImmediate() 的回調(diào)函數(shù)。
- close callbacks 階段:執(zhí)行關(guān)閉事件的回調(diào)函數(shù),如 socket.on('close', fn) 里的 fn。
timers階段
計(jì)時(shí)器實(shí)際上是在指定多久以后可以執(zhí)行某個(gè)回調(diào)函數(shù),而不是指定某個(gè)函數(shù)的確切執(zhí)行時(shí)間。當(dāng)指定的時(shí)間達(dá)到后,計(jì)時(shí)器的回調(diào)函數(shù)會(huì)盡早被執(zhí)行。如果操作系統(tǒng)很忙,或者 Node.js 正在執(zhí)行一個(gè)耗時(shí)的函數(shù),那么計(jì)時(shí)器的回調(diào)函數(shù)就會(huì)被推遲執(zhí)行。
poll 階段(輪詢階段)
poll 階段有兩個(gè)功能:
- 如果發(fā)現(xiàn)計(jì)時(shí)器的時(shí)間到了,就繞回到 timers 階段執(zhí)行計(jì)時(shí)器的回調(diào)。
- 然后再,執(zhí)行 poll 隊(duì)列里的回調(diào)。
當(dāng) event loop 進(jìn)入 poll 階段,如果發(fā)現(xiàn)沒(méi)有計(jì)時(shí)器,就會(huì):
- 如果 poll 隊(duì)列不是空的,event loop 就會(huì)依次執(zhí)行隊(duì)列里的回調(diào)函數(shù),直到隊(duì)列被清空或者到達(dá) poll 階段的時(shí)間上限。
- 如果 poll 隊(duì)列是空的,就會(huì):
- 如果有 setImmediate() 任務(wù),event loop 就結(jié)束 poll 階段去往 check 階段。
- 如果沒(méi)有 setImmediate() 任務(wù),event loop 就會(huì)等待新的回調(diào)函數(shù)進(jìn)入 poll 隊(duì)列,并立即執(zhí)行它。
一旦 poll 隊(duì)列為空,event loop 就會(huì)檢查計(jì)時(shí)器有沒(méi)有到期,如果有計(jì)時(shí)器到期了,event loop 就會(huì)回到 timers 階段執(zhí)行計(jì)時(shí)器的回調(diào)。
check 階段
這個(gè)階段允許開(kāi)發(fā)者在 poll 階段結(jié)束后立即執(zhí)行一些函數(shù)。如果 poll 階段空閑了,同時(shí)存在 setImmediate() 任務(wù),event loop 就會(huì)進(jìn)入 check 階段,執(zhí)行setImmediate() 回調(diào)。
Event Loop 大體流程
每一個(gè)階段都有一個(gè)隊(duì)列,我們只關(guān)注 timers、poll、check階段來(lái)分析一下,我們?cè)谟妹钚羞\(yùn)行 node server.js 命令時(shí),發(fā)生了什么:
1、Node.js啟動(dòng),初始化 Event Loop
2、運(yùn)行server.js腳本內(nèi)容
3、開(kāi)始運(yùn)行Event Loop
4、timers階段看腳本里是否設(shè)置定時(shí)器setTimeout,比如一個(gè)4ms延遲與一個(gè)100ms延遲的定時(shí)器,把它放到timers隊(duì)列中,進(jìn)入下一步,I/O callbacks 階段,idle, prepare 階段,這兩個(gè)階段都不會(huì)停留。
5、進(jìn)入poll(輪詢)階段,首先它會(huì)查看定時(shí)器時(shí)間是否到了,比如4ms到了,他就進(jìn)入下一階段check階段、close callbacks 階段,然后回到timers階段執(zhí)行設(shè)置的4ms回調(diào)函數(shù),接著繼續(xù)第4步到第5步。4ms沒(méi)到,則停留在這一階段,處理poll隊(duì)列里的任務(wù),直到4ms到、100ms到,然后循環(huán)回到timers階段執(zhí)行回調(diào)。
這里有一個(gè)問(wèn)題:當(dāng)poll階段在處理任務(wù)1時(shí),比如這個(gè)任務(wù)1要花費(fèi)100ms,在這100ms期間,setTimeout定時(shí)器到了,則它的回調(diào)會(huì)等poll處理玩任務(wù)1后立即循環(huán)進(jìn)入timers階段執(zhí)行
6、從poll階段進(jìn)入check階段時(shí),主要是看是否有setImmediate() 任務(wù),如果有則立即執(zhí)行,然后再進(jìn)入close callbacks 階段,進(jìn)行循環(huán),進(jìn)入timers階段。
setImmediate() vs setTimeout()
setImmediate 和 setTimeout 很相似,但是其回調(diào)函數(shù)的調(diào)用時(shí)機(jī)卻不一樣。
setImmediate() 的作用是在當(dāng)前 poll 階段結(jié)束后調(diào)用一個(gè)函數(shù)。 setTimeout() 的作用是在一段時(shí)間后調(diào)用一個(gè)函數(shù)。一般來(lái)說(shuō) setImmediate 會(huì)先于 setTimeout 執(zhí)行,但是第一次啟動(dòng)的時(shí)候不一樣,這兩者的回調(diào)的執(zhí)行順序取決于 setTimeout 和 setImmediate 被調(diào)用時(shí)的環(huán)境。
例如:
setTimeout(()=>{
console.log('setTiomeout')
},0)
setInmediate(()=>{
console.log('setInmediate')
})
為什么會(huì)發(fā)生這種情況呢?因?yàn)槲覀儐?dòng)node.js時(shí), node會(huì)做三件事, 初始化event loop,運(yùn)行腳本,開(kāi)始event loop。運(yùn)行腳本與開(kāi)始event loop這兩件事不是同時(shí)執(zhí)行的,它兩中間間隔多少并不清楚,這跟環(huán)境性能有關(guān)。然后要注意的一點(diǎn),setTimeout的延遲時(shí)間最小為4ms,所以這里的0相當(dāng)于4。
- 可能兩者間隔5ms,當(dāng)進(jìn)入timers階段的時(shí)候,node發(fā)現(xiàn),4ms已經(jīng)過(guò)了,立即執(zhí)行setTimeout定時(shí)器回調(diào),然后執(zhí)行setImmediate。
- 也可能兩者間隔3ms,當(dāng)進(jìn)入timers階段的時(shí)候,node發(fā)現(xiàn),4ms還沒(méi)過(guò),就進(jìn)入下一階段,一直到checked,執(zhí)行setImmediate,然后等到4ms時(shí)再執(zhí)行setTimeout。
process.nextTick()
從技術(shù)上來(lái)講 process.nextTick() 并不是 event loop 的一部分。實(shí)際上,event loop 再次進(jìn)入循環(huán)前,會(huì)去先執(zhí)行process.nextTick()。
setTimeout(()=>{
console.log('setTiomeout')
},0)
setInmediate(()=>{
console.log('setInmediate')
})
proces.nextTick(()=>{
console.log('nextTick')
})
上述代碼中nextTick先于其它兩個(gè)執(zhí)行,Vue中有Vue.nextTick()方法就是類似的思想。
注:
本篇文章參考:
Event Loop、計(jì)時(shí)器、nextTick
這一次,徹底弄懂 JavaScript 執(zhí)行機(jī)制
從event loop規(guī)范探究javaScript異步及瀏覽器更新渲染時(shí)機(jī)