瀏覽器和 Node.js 的 EventLoop 事件循環(huán)機制知多少?
本文轉(zhuǎn)載自微信公眾號「前端萬有引力」,作者一川 。轉(zhuǎn)載本文請聯(lián)系前端萬有引力公眾號。
1.寫在前面
無論是瀏覽器端還是服務端Node.js,都在使用EventLoop事件循環(huán)機制,都是基于Javascript語言的單線程和非阻塞IO的特點。在EventLoop事件隊列中有宏任務和微任務隊列,分析宏任務和微任務的運行機制,有助于我們理解代碼在瀏覽器中的執(zhí)行邏輯。
那么,我們得思考幾個問題:
- 瀏覽器的EventLoop發(fā)揮著什么作用?
- Node.js服務端的EventLoop發(fā)揮著什么作用?
- 宏任務和微任務分別有哪些方法?
- 宏任務和微任務互相嵌套,執(zhí)行順序是什么樣的?
- Node.js中的Process.nextick和其它微任務方法在一起的時候執(zhí)行順序是什么?
- Vue也有個nextick,它的邏輯又是什么樣的呢?
2.瀏覽器的EventLoop
EventLoop是Javascript引擎異步編程需要著重關注的知識點,也是在學習JS底層原理所必須學習的關鍵。我們知道JS在單線程上執(zhí)行所有的操作,雖然是單線程的,但是總是能夠高效地解決問題,并且會給我們帶來一種『多線程』的錯覺。這其實是通過一些高效合理的數(shù)據(jù)結(jié)構(gòu)來達到這種效果的。
調(diào)用棧(Call Stack)
調(diào)用堆棧:負責追蹤所有要執(zhí)行的代碼。每當調(diào)用堆棧中的函數(shù)執(zhí)行完畢時,就會從棧中彈出此函數(shù),如果有代碼需要輸入就會執(zhí)行PUSH操作。
事件隊列(Event Queue)
事件隊列:負責將新的函數(shù)發(fā)送到隊列中進行處理。事件執(zhí)行隊列符合數(shù)據(jù)結(jié)構(gòu)中的隊列,先進先出的特性,當先進入的事件先執(zhí)行,執(zhí)行完畢先彈出。
每當調(diào)用事件隊列(Event Queue)中的異步函數(shù)時,都會將其發(fā)送到瀏覽器API。根據(jù)調(diào)用棧收到的命令,API開始自己的單線程操作。
比如,在事件執(zhí)行隊列操作setTimeout事件時,會現(xiàn)將其發(fā)送到瀏覽器對應的API,該API會一直等到約定的時間將其送回調(diào)用棧進行處理。即,它將操作發(fā)送到事件隊列中,這樣就形成了一個循環(huán)系統(tǒng),用于Javascript中進行異步操作。
Javascript語言本身是單線程的,而瀏覽器的API充當獨立的線程,事件循環(huán)促進了這一過程,它會不斷檢查調(diào)用棧的代碼是否為空。如果為空,就從事件執(zhí)行隊列中添加到調(diào)用棧中;如果不為空,則優(yōu)先執(zhí)行當前調(diào)用棧中的代碼。
在EventLoop中,每次循環(huán)稱為一次tick。主要順序是:
- 執(zhí)行棧選擇最先進入隊列的宏任務,執(zhí)行其同步代碼直到結(jié)束
- 檢查是否有微任務,如果有則執(zhí)行知道微任務隊列為空
- 如果是在瀏覽器端,那么基本要渲染頁面
- 開始下一輪的循環(huán)tick,執(zhí)行宏任務中的一些異步代碼,如:setTimeout
注意:最先進行調(diào)用棧的宏任務,一般情況下都是最后返回執(zhí)行的結(jié)果。
事實上,EventLoop通過內(nèi)部兩個隊列來實現(xiàn)Event Queue放進來的異步任務。以setTimeout為代表的任務稱為宏任務,放在宏任務隊列(Macrotask Queue)中;以Promise為代表的任務稱為微任務,放在微任務隊列(Microtask Queue)中。
主要的宏任務和微任務有:
- 宏任務(Macrotask Queue):
- script整體代碼
- setTimeout、setInterval
- setimmediate
- I/O (網(wǎng)絡請求完成、文件讀寫完畢事件)
- UI 渲染(解析DOM、計算布局、繪制)
- EventListner事件監(jiān)聽(鼠標點擊、滾動頁面、放大縮小等)
- 微任務(Microtask Queue):
- process.nextTick
- Promise
- Object.observe
- MutationObserver
宏任務
頁面進程中引入了消息隊列和事件循環(huán)機制,我們把這些消息隊列中的任務稱為宏任務。JS代碼中不能準確掌控任務要添加到隊列中的位置,控制不了任務在消息隊列中的位置,所以很難控制開始執(zhí)行任務的時間。例如:
- function func2(){
- console.log(2);
- }
- function func(){
- console.log(1);
- setTimeout(func2,0);
- }
- setTimeout(func,0);
你以為上面的代碼會一次打印1和2嗎,并不是。因為在JS事件循環(huán)機制中,當執(zhí)行setTimeout時會將事件進行掛起,執(zhí)行一些其它的系統(tǒng)任務,當其他的執(zhí)行完畢之后才會執(zhí)行,因此執(zhí)行時間間隔是不可控。
微任務
微任務是一個需要異步執(zhí)行的函數(shù),執(zhí)行時機是在主函數(shù)執(zhí)行完畢后、當前宏任務結(jié)束前。JS執(zhí)行一段腳本時,v8引擎會為其創(chuàng)建一個全局執(zhí)行上下文,同時v8引擎會在其內(nèi)部創(chuàng)建一個微任務隊列,這個微任務隊列就是用來存放微任務的。
那么微任務是如何產(chǎn)生的呢?
- 使用MutationObserver監(jiān)控某個DOM節(jié)點,或者為這個節(jié)點添加、刪除部分子節(jié)點,當DOM節(jié)點發(fā)生變化時,就會產(chǎn)生DOM變化記錄的微任務。
- 使用Promise,當調(diào)用Promise.resolve()或者Promise.reject()時,也會產(chǎn)生微任務。
通過DOM節(jié)點變化產(chǎn)生的微任務或使用Promise產(chǎn)生的微任務會被JS引擎按照順序保存到微任務隊列中。
MutationObserver是用來監(jiān)聽DOM變化的一套方法,雖然監(jiān)聽DOM需求比較頻繁,不過早期頁面并沒有提供對監(jiān)聽的支持,唯一能做的就是進行輪詢檢測。如果設置時間間隔過長,DOM變化響應不夠及時;如果時間間隔過短,又會浪費很多無用的工作量去檢查DOM。從DOM4開始,W3C推出了MutationObserver可以用于監(jiān)視DOM變化,包括屬性的變更、節(jié)點的增加、內(nèi)容的改變等。在每次DOM節(jié)點發(fā)生變化的時候,渲染引擎將變化記錄封裝成微任務,并將微任務添加到當前的微任務隊列中。
MutationObserver采用了"異步+微任務"策略,通過異步操作解決了同步操作的性能問題,通過微任務解決了實時性問題。
JS引擎在準備退出全局執(zhí)行上下文并清空調(diào)用棧的時候,JS引擎會檢查全局執(zhí)行上下文中的微任務隊列,然后按照順序執(zhí)行隊列中的微任務。在執(zhí)行微任務過程中產(chǎn)生的新的微任務,并不會推遲到下一個循環(huán)中執(zhí)行,而是在當前的循環(huán)中繼續(xù)執(zhí)行。
微任務和宏任務是綁定的,每個宏任務執(zhí)行時,會創(chuàng)建自己的微任務隊列。微任務的執(zhí)行時長會影響當前宏任務的時長。在一個宏任務中,分別創(chuàng)建一個用于回調(diào)的宏任務和微任務,無論在什么情況下,微任務都早于宏任務執(zhí)行。
瀏覽器EventLoop的原理是:
- JS引擎首先從宏任務隊列中取出第一個任務
- 執(zhí)行完畢后,再將微任務中的所有任務取出,按照順序依次全部執(zhí)行;如果在此過程中產(chǎn)生了新的微任務,也需要依次全部執(zhí)行
- 然后再從宏任務隊列中取出下一個,執(zhí)行完畢后,再將此宏任務事件中的微任務從微任務隊列中全部取出依次執(zhí)行,循環(huán)往復,知道宏任務和微任務隊列中的事件全部執(zhí)行完畢
注意:一次EventLoop循環(huán)會處理一個宏任務和所有此處循環(huán)中產(chǎn)生的微任務。
3.Node.js的EventLoop
Node.js官網(wǎng)的定義是:當 Node.js 啟動后,它會初始化事件循環(huán),處理已提供的輸入腳本(或丟入 REPL,本文不涉及到),它可能會調(diào)用一些異步的 API、調(diào)度定時器,或者調(diào)用 process.nextTick(),然后開始處理事件循環(huán)。
Node.js中的事件循環(huán)機制
上圖是Node.js的EventLoop流程圖,我們依次進行分析得到:
- Timers階段:執(zhí)行的是setTimeout和setInterval
- I/O回調(diào)階段:執(zhí)行系統(tǒng)級別的回調(diào)函數(shù),比如TCP執(zhí)行失敗的回調(diào)函數(shù)
- Idle、Prepare階段:Node內(nèi)部的閑置和預備階段
- Poll階段:檢索新的 I/O 事件;執(zhí)行與 I/O 相關的回調(diào)(幾乎所有情況下,除了關閉的回調(diào)函數(shù),那些由計時器和 setImmediate() 調(diào)度的之外),其余情況 node 將在適當?shù)臅r候在此阻塞。
- Check階段:setImmediate() 回調(diào)函數(shù)在這里執(zhí)行。
- Close回調(diào)階段:一些關閉的回調(diào)函數(shù),如:socket.on('close', ...)。
瀏覽器端任務隊列每輪事件循環(huán)僅出隊一個回調(diào)函數(shù),接著去執(zhí)行微任務隊列。而Node.js端只要輪到執(zhí)行某個宏任務隊列,就會執(zhí)行完隊列中的所有當前任務,但是每次輪詢新添加到隊尾的任務則會等待下一次輪詢才會執(zhí)行。
4.Process.nextTick()
- process.nextTick(callback,可選參數(shù)args);
Process.nextTick會將callback添加到"nextTick queue"隊列中,nextick queue會在當前Javascript stack執(zhí)行完畢后,下一次EventLoop開始執(zhí)行前按照FIFO出隊。如果遞歸調(diào)用Process.nextTick可能會導致一個無限循環(huán),需要去適當?shù)臅r機終止遞歸。
Process.nextTick其實是微任務,同時也是異步API的一部分,但是從技術而言Process.nextTick并不是事件循環(huán)(EventLoop)的一部分。如果任何時刻在給定的階段調(diào)用Process.nextick,則所有被傳入Process.nextTick的回調(diào),將會在事件循環(huán)繼續(xù)往下執(zhí)行前被執(zhí)行,這可能導致事件循環(huán)永遠無法到達輪詢階段。
為什么Process.nextTick這樣的API會被允許存在于Nodejs中呢?部分原因是因為設計理念,在nodejs中api總是異步的,即使那些不需要異步的地方。
- function apiCall(args,callback){
- if(typeof args !== "string"){
- return process.nextTick(callback,new TypeError("atgument should be string"));
- }
- }
我們可以看到上面的代碼,可以將一個錯誤傳遞給用戶,但這只允許在用戶代碼被執(zhí)行完畢后執(zhí)行。使用process.nextTick可以保證apiCall()的回調(diào)總是在用戶代碼被執(zhí)行后,且在事件循環(huán)繼續(xù)工作前被執(zhí)行。
那么Vue中nextTick又是做啥的呢?
vue異步執(zhí)行DOM的更新,當數(shù)據(jù)發(fā)生變化時,vue會開啟一個隊列,用于緩沖在同一事件循環(huán)中發(fā)生的所有數(shù)據(jù)改變的情況。如果同一個watcher被多次觸發(fā),只會被推入隊列中一次。這種在緩沖時去除重復數(shù)據(jù),對于避免不必要的計算和DOM操作上非常重要。然后在下一個事件循環(huán)tick中。例如:當你設置vm.someData = "yichuan",該組件不會立即執(zhí)行重新渲染。當刷新隊列是,組件會在事件循環(huán)隊列清空時的下一個"tick"更新。
process.nextTick的執(zhí)行順序是:每一次EventLoop執(zhí)行前,如果有多個process.nextTick,會影響下一次時間循環(huán)的執(zhí)行時間
Vue:nextick方法中每次數(shù)據(jù)更新將會在下一次作用到視圖更新
5.EventLoop對渲染的影響
requestIdlecallback和requestAnimationFrame這兩個方法不屬于JS的原生方法,而是瀏覽器宿主環(huán)境提供的方法。瀏覽器作為一個復雜的應用是多線程工作的,JS線程可以讀取并且修改DOM,而渲染線程也需要讀取DOM,這是一個典型的多線程競爭資源的問題。所以瀏覽器把這兩個線程設計為互斥的,即同時只能有一個線程進行運行。
JS線程和渲染線程本來是互斥的,但是requestAnimationFrame卻讓這對水火不相容的線程建立起了聯(lián)系,即把EventLoop和渲染建立起了聯(lián)系。通過調(diào)用requestAnimationFrame()方法,我們可以在瀏覽器下次渲染之前執(zhí)行回調(diào)函數(shù),那么下次渲染具體在什么時間節(jié)點呢?渲染和EventLoop又有著什么聯(lián)系呢?
簡而言之,就是在每次EventLoop結(jié)束前,判斷當前是否有渲染時機即重新渲染,而渲染時機是有屏幕限制的,瀏覽器的刷新幀率是60Hz,即1s內(nèi)刷新了60次。此時瀏覽器的渲染時間就沒必要小于16.6ms,因為渲染了屏幕也不會進行展示,
當然瀏覽器也不能保證每16.6ms會渲染一次。此外,瀏覽器渲染還會收到處理器的性能以及js執(zhí)行效率等因素的影響。
requestAnimationFrame保證在瀏覽器下次渲染前一定會被調(diào)用,實際上我們完全可以將其當成一個高級版的setInterval定時器。它們都是每隔一段時間執(zhí)行一次回調(diào)函數(shù),只不過requestAnimationFrame的時間間隔是瀏覽器不斷進行調(diào)整的,而setInterval的時間間隔是用戶進行指定的。因此,requestAnimationFrame更適合用于做每一幀動畫的修改效果。
requestAnimationFrame不是EventLoop中的宏任務,或者說它并不在EventLoop的生命周期中,只是瀏覽器又開發(fā)的一個在渲染前發(fā)生的新hook。此時,我們對于微任務的認知也需要進行更新,在執(zhí)行requestAnimationFrame的callback函數(shù)時,也有可能產(chǎn)生微任務會放在requestAnimationFrame處理完畢之后執(zhí)行。因此,微任務并不像之前描述的在每一次EventLoop后執(zhí)行處理,而是在JS函數(shù)調(diào)用棧清空后處理。
在EventLoop中并沒有什么任務需要處理時,瀏覽器可能處于空閑狀態(tài),在這段空閑時間可以被requestIdlecallback利用,用于執(zhí)行一些優(yōu)先不高、不必立即執(zhí)行的任務,如圖所示:
同時,為了避免瀏覽器一直處于繁忙的狀態(tài),導致requestIdlecallback函數(shù)永遠無法執(zhí)行回調(diào),瀏覽器提供了一個額外的setTimeout函數(shù),為這個任務設置截止時間,瀏覽器就可以根據(jù)這個截止時間規(guī)劃這個任務的執(zhí)行。
6.參考文章
《Javascript核心原理精講》
《深入淺出Node.js》
《Javascript高級程序設計》
7.寫在最后
本篇文章談了EventLoop在瀏覽器和Node.js中的區(qū)別,EventLoop本身不是什么比較復雜的概念,只是我們需要根據(jù)JS的不同運行平臺,理解它們之間的相同和差異。