Like Share Discussion Bookmark Smile

J.J. Huang   2020-01-20   Node.js   瀏覽次數:

Node.js | 深入理解事件循環機制(瀏覽器篇)

問題:

  • 單線程如何做到異步?
  • 事件循環的過程是怎樣的?
  • macrotaskmicrotask是什麼,它們有何區別?

單線程和異步

提到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回調,MutationObserverprocess.nextTickObject.observe

其中setImmediateprocess.nextTickNode.js的實現,在Node.js篇會詳細介紹。

事件處理過程

關於macrotaskmicrotask的理解,光這樣看會有些晦澀難懂,結合事件循壞的機制理解清晰很多,下面這張圖可以說是介紹得非常清楚了。

總結起來,一次事件循環的步驟包括:

  1. 檢查macrotask隊列是否為空,非空則到2,為空則到3
  2. 執行macrotask中的一個任務
  3. 繼續檢查microtask隊列是否為空,若有則到4,否則到5
  4. 取出microtask中的任務執行,執行完成返回到步驟3
  5. 執行視圖更新

mactotask & microtask 執行順序

範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('start')

setTimeout(function() {
console.log('setTimeout')
}, 0)

Promise.resolve().then(function() {
console.log('promise1')
}).then(function() {
console.log('promise2')
})

console.log('end')

執行結果(瀏覽器):

1
2
3
4
5
start
end
promise1
promise2
setTimeout

動態說明:

首先,全局程式(main())壓入調用棧執行,印出start

接下來setTimeout壓入macrotask隊列,promise.then回調放入microtask隊列,最後執行console.log('end'),印出出end

至此,調用棧中的程式被執行完成,回顧macrotask的定義,我們知道全局程式屬於macrotaskmacrotask執行完,那接下來就是執行microtask隊列的任務了,執行promise回調印出promise1

promise回調函數默認返回undefinedpromise狀態變為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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
setTimeout(function() {console.log('timer1')}, 0)

requestAnimationFrame(function(){
console.log('requestAnimationFrame')
})

setTimeout(function() {console.log('timer2')}, 0)

new Promise(function executor(resolve) {
console.log('promise 1')
resolve()
console.log('promise 2')
}).then(function() {
console.log('promise then')
})

console.log('end')

執行結果(瀏覽器):

1
2
3
4
5
6
7
promise 1
promise 2
end
promise then
requestAnimationFrame
timer1
timer2

總結

1.事件循環是js實現異步的核心
2.每輪事件循環分為3個步驟:

a) 執行macrotask隊列的一個任務
b) 執行完當前microtask隊列的所有任務
c) UI render

3.瀏覽器只保證requestAnimationFrame的回調在重繪之前執行,沒有確定的時間,何時重繪由瀏覽器決定


註:以上參考了
深入理解js事件循环机制(浏览器篇)