Node.js | 深入理解事件循環機制(瀏覽器篇)
問題:
- 單線程如何做到異步?
- 事件循環的過程是怎樣的?
macrotask和microtask是什麼,它們有何區別?
單線程和異步
提到js,就會想到單線程、異步,那麼單線程是如何做到異步的呢?
概念先行,先要了解下單線程和異步之間的關係。
js的任務分為同步和異步兩種,它們的處理方式也不同,同步任務是直接在主線程上排隊執行,異步任務則會被放到任務隊列中,若有多個任務(異步任務)則要在任務隊列中排隊等待,任務隊列類似一個緩衝區,任務下一步會被移到調用棧(call stack),然後主線程執行調用棧的任務。
單線程是指js引擎中負責解析執行js程式的線程只有一個(主線程),即每次只能做一件事,而我們知道一個ajax請求,主線程在等待它響應的同時是會去做其它事的,瀏覽器先在事件表註冊ajax的回調函數,響應回來後回調函數被添加到任務隊列中等待執行,不會造成線程阻塞,所以說js處理ajax請求的方式是異步的。
總而言之,檢查調用棧是否為空,以及確定把哪個task加入調用棧的這個過程就是事件循環,而js實現異步的核心就是事件循環。
調用棧和任務隊列
顧名思義,調用棧是一個棧結構,函數調用會形成一個棧幀,幀中包含了當前執行函數的參數和局部變量等上下文訊息,函數執行完後,它的執行上下文會從棧中彈出。
下圖就是調用棧和任務隊列的關係圖:

事件循环
關於事件循環,HTML規範的介紹。
There must be at least one event loop per user agent, and at most one event loop per unit of related similar-origin browsing contexts.
An event loop has one or more task queues.
Each task is defined as coming from a specific task source.
從規範理解,瀏覽器至少有一個事件循環,一個事件循環至少有一個任務隊列(macrotask),每個外任務都有自己的分組,瀏覽器會為不同的任務組設置優先級。
macrotask & microtask
規範有提到兩個概念:
macrotask:包含執行整體的js程式,事件回調,XHR回調,定時器(setTimeout/setInterval/setImmediate),I/O操作,UI render。microtask:更新應用程序狀態的任務,包括promise回調,MutationObserver,process.nextTick,Object.observe。
其中setImmediate和process.nextTick是Node.js的實現,在Node.js篇會詳細介紹。
事件處理過程
關於macrotask和microtask的理解,光這樣看會有些晦澀難懂,結合事件循壞的機制理解清晰很多,下面這張圖可以說是介紹得非常清楚了。

總結起來,一次事件循環的步驟包括:
- 檢查
macrotask隊列是否為空,非空則到2,為空則到3 - 執行
macrotask中的一個任務 - 繼續檢查
microtask隊列是否為空,若有則到4,否則到5 - 取出
microtask中的任務執行,執行完成返回到步驟3 - 執行視圖更新
mactotask & microtask 執行順序

範例:
1 | console.log('start') |
執行結果(瀏覽器):
1 | start |
動態說明:

首先,全局程式(main())壓入調用棧執行,印出start。
接下來setTimeout壓入macrotask隊列,promise.then回調放入microtask隊列,最後執行console.log('end'),印出出end。
至此,調用棧中的程式被執行完成,回顧macrotask的定義,我們知道全局程式屬於macrotask,macrotask執行完,那接下來就是執行microtask隊列的任務了,執行promise回調印出promise1。
promise回調函數默認返回undefined,promise狀態變為fullfill觸發接下來的then回調,繼續壓入microtask隊列,event loop會把當前的microtask隊列一直執行完,此時執行第二個promise.then回調印出出promise2。
這時microtask隊列已經為空,從上面的流程圖可以知道,接下來主線程會去做一些UI渲染工作(不一定會做),然後開始下一輪event loop,執行setTimeout的回調,印出出setTimeout。
這個過程會不斷重複,也就是所謂的事件循環。
視圖渲染的時機
回顧上面的事件循環示意圖,update rendering(視圖渲染)發生在本輪事件循環的microtask隊列被執行完之後,也就是說執行任務的耗時會影響視圖渲染的時機。通常瀏覽器以每秒60幀(60fps)的速率刷新頁面,據說這個幀率最適合人眼交互,大概16.7ms渲染一幀,所以如果要讓用戶覺得順暢,單個macrotask及它相關的所有microtask最好能在16.7ms內完成。
但也不是每輪事件循環都會執行視圖更新,瀏覽器有自己的優化策略,例如把幾次的視圖更新累積到一起重繪,重繪之前會通知requestAnimationFrame執行回調函數,也就是說requestAnimationFrame回調的執行時機是在一次或多次事件循環的UI render階段。
1 | setTimeout(function() {console.log('timer1')}, 0) |
執行結果(瀏覽器):
1 | promise 1 |
總結
1.事件循環是js實現異步的核心
2.每輪事件循環分為3個步驟:
a) 執行macrotask隊列的一個任務
b) 執行完當前microtask隊列的所有任務
c) UI render
3.瀏覽器只保證requestAnimationFrame的回調在重繪之前執行,沒有確定的時間,何時重繪由瀏覽器決定
註:以上參考了
深入理解js事件循环机制(浏览器篇)
