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