面試率超高的JS事件循環(huán),看這篇就夠了
大家好,我是 CUGGZ。
事件循環(huán)是 JavaScript 中一個非常重要的概念,下面就來看看瀏覽器和 Node.js 中的事件循環(huán)的原理,以及兩者之間的差異!
1、異步執(zhí)行原理
(1)單線程的JavaScript
我們知道,JavaScript是一種單線程語言,它主要用來與用戶互動,以及操作DOM。
JavaScript 有同步和異步的概念,這就解決了代碼阻塞的問題:
- 同步:如果在一個函數(shù)返回的時候,調(diào)用者就能夠得到預期結(jié)果,那么這個函數(shù)就是同步的。
- 異步:如果在函數(shù)返回的時候,調(diào)用者還不能夠得到預期結(jié)果,而是需要在將來通過一定的手段得到,那么這個函數(shù)就是異步的。
那單線程有什么好處呢?
- 在 JS 運行的時候可能會阻止 UI 渲染,這說明了兩個線程是互斥的。這是因為 JS 可以修改 DOM,如果在 JS 執(zhí)行的時候 UI 線程還在工作,就可能導致不能安全的渲染 UI。
- 得益于 JS 是單線程運行的,可以達到節(jié)省內(nèi)存,節(jié)約上下文切換時間的好處。
(2)多線程的瀏覽器
JS 是單線程的,在同一個時間只能做一件事情,那為什么瀏覽器可以同時執(zhí)行異步任務呢?
這是因為瀏覽器是多線程的,當 JS 需要執(zhí)行異步任務時,瀏覽器會另外啟動一個線程去執(zhí)行該任務。也就是說,JavaScript是單線程的指的是執(zhí)行JavaScript代碼的線程只有一個,是瀏覽器提供的JavaScript引擎線程(主線程)。除此之外,瀏覽器中還有定時器線程、 HTTP 請求線程等線程,這些線程主要不是來執(zhí)行 JS 代碼的。
比如主線程中需要發(fā)送數(shù)據(jù)請求,就會把這個任務交給異步 HTTP 請求線程去執(zhí)行,等請求數(shù)據(jù)返回之后,再將 callback 里需要執(zhí)行的 JS 回調(diào)交給 JS 引擎線程去執(zhí)行。也就是說,瀏覽器才是真正執(zhí)行發(fā)送請求這個任務的角色,而 JS 只是負責執(zhí)行最后的回調(diào)處理。所以這里的異步不是 JS 自身實現(xiàn)的,而是瀏覽器為其提供的能力。
下圖是Chrome瀏覽器的架構(gòu)圖:
可以看到,Chrome不僅擁有多個進程,還有多個線程。以渲染進程為例,就包含GUI渲染線程、JS引擎線程、事件觸發(fā)線程、定時器觸發(fā)線程、異步HTTP請求線程。這些線程為 JS 在瀏覽器中完成異步任務提供了基礎。
2、瀏覽器事件循環(huán)
JavaScript的任務分為兩種同步和異步:
- 同步任務: 在主線程上排隊執(zhí)行的任務,只有一個任務執(zhí)行完畢,才能執(zhí)行下一個任務,
- 異步任務: 不進入主線程,而是放在任務隊列中,若有多個異步任務則需要在任務隊列中排隊等待,任務隊列類似于緩沖區(qū),任務下一步會被移到執(zhí)行棧然后主線程執(zhí)行調(diào)用棧的任務。
上面提到了任務隊列和執(zhí)行棧,下面就先來看看這兩個概念。
(1)執(zhí)行棧與任務隊列
執(zhí)行棧:從名字可以看出,執(zhí)行棧使用到的是數(shù)據(jù)結(jié)構(gòu)中的棧結(jié)構(gòu), 它是一個存儲函數(shù)調(diào)用的棧結(jié)構(gòu),遵循先進后出的原則。它主要負責跟蹤所有要執(zhí)行的代碼。 每當一個函數(shù)執(zhí)行完成時,就會從堆棧中彈出(pop)該執(zhí)行完成函數(shù);如果有代碼需要進去執(zhí)行的話,就進行 push 操作。以下圖為例:
當執(zhí)行這段代碼時,首先會執(zhí)行一個 main 函數(shù),然后執(zhí)行我們的代碼。根據(jù)先進后出的原則,后執(zhí)行的函數(shù)會先彈出棧,在圖中也可以發(fā)現(xiàn),foo 函數(shù)后執(zhí)行,當執(zhí)行完畢后就從棧中彈出了。
JavaScript在按順序執(zhí)行執(zhí)行棧中的方法時,每次執(zhí)行一個方法,都會為它生成獨有的執(zhí)行環(huán)境(上下文),當這個方法執(zhí)行完成后,就會銷毀當前的執(zhí)行環(huán)境,并從棧中彈出此方法,然后繼續(xù)執(zhí)行下一個方法。
任務隊列: 從名字中可以看出,任務隊列使用到的是數(shù)據(jù)結(jié)構(gòu)中的隊列結(jié)構(gòu),它用來保存異步任務,遵循先進先出的原則。它主要負責將新的任務發(fā)送到隊列中進行處理。
JavaScript在執(zhí)行代碼時,會將同步的代碼按照順序排在執(zhí)行棧中,然后依次執(zhí)行里面的函數(shù)。當遇到異步任務時,就將其放入任務隊列中,等待當前執(zhí)行棧所有同步代碼執(zhí)行完成之后,就會從異步任務隊列中取出已完成的異步任務的回調(diào)并將其放入執(zhí)行棧中繼續(xù)執(zhí)行,如此循環(huán)往復,直到執(zhí)行完所有任務。
JavaScript任務的執(zhí)行順序如下:
在事件驅(qū)動的模式下,至少包含了一個執(zhí)行循環(huán)來檢測任務隊列中是否有新任務。通過不斷循環(huán),去取出異步任務的回調(diào)來執(zhí)行,這個過程就是事件循環(huán),每一次循環(huán)就是一個事件周期。
(2)宏任務和微任務
任務隊列其實不止一種,根據(jù)任務種類的不同,可以分為微任務(micro task)隊列和宏任務(macro task)隊列。常見的任務如下:
- 宏任務: script( 整體代碼)、setTimeout、setInterval、I/O、UI 交互事件、setImmediate(Node.js 環(huán)境)。
- 微任務: Promise、MutaionObserver、process.nextTick(Node.js 環(huán)境)。
任務隊列執(zhí)行順序如下:
可以看到,Eventloop 在處理宏任務和微任務的邏輯時的執(zhí)行情況如下:
- JavaScript 引擎首先從宏任務隊列中取出第一個任務;
- 執(zhí)行完畢后,再將微任務中的所有任務取出,按照順序分別全部執(zhí)行(這里包括不僅指開始執(zhí)行時隊列里的微任務),如果在這一步過程中產(chǎn)生新的微任務,也需要執(zhí)行,也就是說在執(zhí)行微任務過程中產(chǎn)生的新的微任務并不會推遲到下一個循環(huán)中執(zhí)行,而是在當前的循環(huán)中繼續(xù)執(zhí)行。
- 然后再從宏任務隊列中取下一個,執(zhí)行完畢后,再次將 microtask queue 中的全部取出,循環(huán)往復,直到兩個 queue 中的任務都取完。
也是就是說,一次 Eventloop 循環(huán)會處理一個宏任務和所有這次循環(huán)中產(chǎn)生的微任務。
下面通過一個例子來體會事件循環(huán):
代碼輸出結(jié)果如下:
那這段代碼執(zhí)行過程是怎么的呢?
- 遇到第一個console,它是同步代碼,加入執(zhí)行棧,執(zhí)行并出棧,打印出"同步代碼1"。
- 遇到setTimeout,它是一個宏任務,加入宏任務隊列。
- 遇到new Promise 中的console,它是同步代碼,加入執(zhí)行棧,執(zhí)行并出棧,打印出"同步代碼2"。
- 遇到Promise then,它是一個微任務,加入微任務隊列。
- 遇到第三個console,它是同步代碼,加入執(zhí)行棧,執(zhí)行并出棧,打印出"同步代碼3"。
- 此時執(zhí)行棧為空,去執(zhí)行微任務隊列中所有任務,打印出"promise.then"。
- 執(zhí)行完微任務隊列中的任務,就去執(zhí)行宏任務隊列中的一個任務,打印出"setTimeout"。
從上面的宏任務和微任務的工作流程中,可以得出以下結(jié)論:
- 微任務和宏任務是綁定的,每個宏任務在執(zhí)行時,會創(chuàng)建自己的微任務隊列。
- 微任務的執(zhí)行時長會影響當前宏任務的時長。比如一個宏任務在執(zhí)行過程中,產(chǎn)生了 10 個微任務,執(zhí)行每個微任務的時間是 10ms,那么執(zhí)行這 10 個微任務的時間就是 100ms,也可以說這 10 個微任務讓宏任務的執(zhí)行時間延長了 100ms。
- 在一個宏任務中,分別創(chuàng)建一個用于回調(diào)的宏任務和微任務,無論什么情況下,微任務都早于宏任務執(zhí)行(優(yōu)先級更高)。
那么問題來了,為什么要將任務隊列分為微任務和宏任務呢,他們之間的本質(zhì)區(qū)別是什么呢?
JavaScript在遇到異步任務時,會將此任務交給其他線程來執(zhí)行(比如遇到setTimeout任務,會交給定時器觸發(fā)線程去執(zhí)行,待計時結(jié)束,就會將定時器回調(diào)任務放入任務隊列等待主線程來取出執(zhí)行),主線程會繼續(xù)執(zhí)行后面的同步任務。
對于微任務,比如promise.then,當執(zhí)行promise.then時,瀏覽器引擎不會將異步任務交給其他瀏覽器的線程去執(zhí)行,而是將任務回調(diào)存在一個隊列中,當執(zhí)行棧中的任務執(zhí)行完之后,就去執(zhí)行promise.then所在的微任務隊列。
所以,宏任務和微任務的本質(zhì)區(qū)別如下:
- 微任務:不需要特定的異步線程去執(zhí)行,沒有明確的異步任務去執(zhí)行,只有回調(diào)。
- 宏任務:需要特定的異步線程去執(zhí)行,有明確的異步任務去執(zhí)行,有回調(diào)。
3、Node.js的事件循環(huán)
(1)事件循環(huán)的概念
對于Node.js的事件循環(huán),官網(wǎng)的描述如下:
When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL, which is not covered in this document) which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.
翻譯一下就是:當Node.js啟動時,它會初始化一個事件循環(huán),來處理輸入的腳本,這個腳本可能進行異步API的調(diào)用、調(diào)度計時器或調(diào)用process.nextTick(),然后開始處理事件循環(huán)。
JavaScript和Node.js是基于V8 引擎的,瀏覽器中包含的異步方式在 NodeJS 中也是一樣的。除此之外,Node.js中還有一些其他的異步形式:
- 文件 I/O:異步加載本地文件。
- setImmediate():與 setTimeout 設置 0ms 類似,在某些同步任務完成后立馬執(zhí)行。
- process.nextTick():在某些同步任務完成后立馬執(zhí)行。
- server.close、socket.on('close',...)等:關(guān)閉回調(diào)。
這些異步任務的執(zhí)行就需要依靠Node.js的事件循環(huán)機制了。
Node.js 中的 Event Loop 和瀏覽器中的是完全不相同的東西。Node.js使用V8作為js的解析引擎,而I/O處理方面使用了自己設計的libuv,libuv是一個基于事件驅(qū)動的跨平臺抽象層,封裝了不同操作系統(tǒng)一些底層特性,對外提供統(tǒng)一的API,事件循環(huán)機制也是它里面的實現(xiàn)的,如下圖所示:
根據(jù)上圖,可以看到Node.js的運行機制如下:
- V8引擎負責解析JavaScript腳本。
- 解析后的代碼,調(diào)用Node API。
- libuv庫負責Node API的執(zhí)行。它將不同的任務分配給不同的線程,形成一個Event Loop(事件循環(huán)),以異步的方式將任務的執(zhí)行結(jié)果返回給V8引擎。
- V8引擎將結(jié)果返回給用戶。
(2)事件循環(huán)的流程
其中l(wèi)ibuv引擎中的事件循環(huán)分為 6 個階段,它們會按照順序反復運行。每當進入某一個階段的時候,都會從對應的回調(diào)隊列中取出函數(shù)去執(zhí)行。當隊列為空或者執(zhí)行的回調(diào)函數(shù)數(shù)量到達系統(tǒng)設定的閾值,就會進入下一階段。下面 是Eventloop 事件循環(huán)的流程:
整個流程分為六個階段,當這六個階段執(zhí)行完一次之后,才可以算得上執(zhí)行了一次 Eventloop 的循環(huán)過程。下面來看下這六個階段都做了哪些事:
- timers 階段:執(zhí)行timer(setTimeout、setInterval)的回調(diào),由 poll 階段控制。
- I/O callbacks 階段:主要執(zhí)行系統(tǒng)級別的回調(diào)函數(shù),比如 TCP 連接失敗的回調(diào)。
- idle, prepare 階段:僅Node.js內(nèi)部使用,可以忽略。
- poll 階段:輪詢等待新的鏈接和請求等事件,執(zhí)行 I/O 回調(diào)等。
- check 階段:執(zhí)行 setImmediate() 的回調(diào)。
- close callbacks 階段:執(zhí)行關(guān)閉請求的回調(diào)函數(shù),比如socket.on('close', ...)。
注意:上面每個階段都會去執(zhí)行完當前階段的任務隊列,然后繼續(xù)執(zhí)行當前階段的微任務隊列,只有當前階段所有微任務都執(zhí)行完了,才會進入下個階段,這里也是與瀏覽器中邏輯差異較大的地方。
其中,這里面比較重要的就是第四階段:poll,這一階段中,系統(tǒng)主要做兩件事:
- 回到 timer 階段執(zhí)行回調(diào)。
- 執(zhí)行 I/O 回調(diào)。
在進入該階段時如果沒有設定了 timer 的話,會出現(xiàn)以下情況:
(1)如果 poll 隊列不為空,會遍歷回調(diào)隊列并同步執(zhí)行,直到隊列為空或者達到系統(tǒng)限制。
(2)如果 poll 隊列為空時,會出現(xiàn)以下情況:
- 如果有 setImmediate 回調(diào)需要執(zhí)行,poll 階段會停止并且進入到 check 階段執(zhí)行回調(diào)。
- 如果沒有 setImmediate 回調(diào)需要執(zhí)行,會等待回調(diào)被加入到隊列中并立即執(zhí)行回調(diào),這里同樣會有個超時時間設置防止一直等待下去。
當設定了 timer 且 poll 隊列為空,則會判斷是否有 timer 超時,如果有的就會回到 timer 階段執(zhí)行回調(diào)。
這一過程的具體執(zhí)行流程如下圖所示:
(3)宏任務和微任務
Node.js事件循環(huán)的異步隊列也分為兩種:宏任務隊列和微任務隊列。
- 常見的宏任務:setTimeout、setInterval、 setImmediate、script(整體代碼)、 I/O 操作等。
- 常見的微任務:process.nextTick、new Promise().then(回調(diào))等。
(4)process.nextTick()
上面提到了process.nextTick(),它是node中新引入的一個任務隊列,它會在上述各個階段結(jié)束時,在進入下一個階段之前立即執(zhí)行。
Node.js官方文檔的解釋如下:
process.nextTick()is not technically part of the event loop. Instead, thenextTickQueuewill be processed after the current operation is completed, regardless of the current phase of the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.
例如下面的代碼:
輸出結(jié)果如下:
可以看到,process.nextTick()是優(yōu)先于promise的回調(diào)執(zhí)行。
(5)setImmediate 和 setTimeout
上面還提到了setImmediate 和 setTimeout,這兩者很相似,主要區(qū)別在于調(diào)用時機的不同:
- setImmediate:在poll階段完成時執(zhí)行,即check階段。
- setTimeout:在poll階段為空閑時,且設定時間到達后執(zhí)行,但它在timer階段執(zhí)行。
例如下面的代碼:
輸出結(jié)果如下:
在上面代碼的執(zhí)行過程中,第一輪循環(huán)后,分別將 setTimeout 和 setImmediate 加入了各自階段的任務隊列。第二輪循環(huán)首先進入timers 階段,執(zhí)行定時器隊列回調(diào),然后 pending callbacks和poll 階段沒有任務,因此進入check 階段執(zhí)行 setImmediate 回調(diào)。所以最后輸出為timeout、setImmediate。###= 4. Node與瀏覽器事件循環(huán)的差異 Node.js與瀏覽器的事件循環(huán)的差異如下:
- Node.js:microtask 在事件循環(huán)的各個階段之間執(zhí)行。
- 瀏覽器:microtask 在事件循環(huán)的 macrotask 執(zhí)行完之后執(zhí)行。? ?
Nodejs和瀏覽器的事件循環(huán)流程對比如下:
- 執(zhí)行全局的 Script 代碼(與瀏覽器無差)。
- 把微任務隊列清空:注意,Node 清空微任務隊列的手法比較特別。在瀏覽器中,我們只有一個微任務隊列需要接受處理;但在 Node 中,有兩類微任務隊列:next-tick 隊列和其它隊列。其中這個 next-tick 隊列,專門用來收斂 process.nextTick 派發(fā)的異步任務。在清空隊列時,優(yōu)先清空 next-tick 隊列中的任務,隨后才會清空其它微任務。
- 開始執(zhí)行 macro-task(宏任務)。注意,Node 執(zhí)行宏任務的方式與瀏覽器不同:在瀏覽器中,我們每次出隊并執(zhí)行一個宏任務;而在 Node 中,我們每次會嘗試清空當前階段對應宏任務隊列里的所有任務(除非達到系統(tǒng)限制)。
- 步驟3開始,會進入 3 -> 2 -> 3 -> 2…的循環(huán)。