熟悉事件循環?那談談為什麼會分為宏任務和微任務

語言: CN / TW / HK

什麼是事件循環

在瞭解事件循環前,需要一些有關 JS 特性的前置知識。

JS 引擎是單線程的,直白來説就是一個時間點下 JS 引擎只能去做一件事情,而 Java 這種多線程語言,可以同時做幾件事情。

JS 做的任務分為同步和異步兩種,所謂 "異步",簡單説就是一個任務不是連續完成的,先執行第一段,等做好了準備,再回過頭執行第二段,第二段也被叫做回調;同步則是連貫完成的。

像讀取文件、網絡請求這種任務屬於異步任務:花費時間很長,但中間的操作不需要 JS 引擎自己完成,它只用等別人準備好了,把數據給他,他再繼續執行回調部分。

如果沒有特殊處理,JS 引擎在執行異步任務時,應該是存在等待的,不去做任何其他事情。用一個圖來展示這個過程,可以看出,在執行異步任務時有大量的空閒時間被浪費。

實際上這是大多數多線程語言的處理辦法。但對於 JS 這種單線程語言來説,這種長時間的空閒等待是不可接受的:遇到其他緊急任務,Java 可以再開一個線程去處理,JS 卻只能忙等。

所以採取了以下的“異步任務回調通知”模式:

在等待異步任務準備的同時,JS 引擎去執行其他同步任務,等到異步任務準備好了,再去執行回調。這種模式的優勢顯而易見,完成相同的任務,花費的時間大大減少,這種方式也被叫做非阻塞式。

而實現這個“通知”的,正是事件循環,把異步任務的回調部分交給事件循環,等時機合適交還給 JS 線程執行。事件循環並不是 JavaScript 首創的,它是計算機的一種運行機制。

事件循環是由一個隊列組成的,異步任務的回調遵循先進先出,在 JS 引擎空閒時會一輪一輪地被取出,所以被叫做循環。

根據隊列中任務的不同,分為宏任務和微任務。

宏任務和微任務

事件循環由宏任務和在執行宏任務期間產生的所有微任務組成。完成當下的宏任務後,會立刻執行所有在此期間入隊的微任務。

這種設計是為了給緊急任務一個插隊的機會,否則新入隊的任務永遠被放在隊尾。區分了微任務和宏任務後,本輪循環中的微任務實際上就是在插隊,這樣微任務中所做的狀態修改,在下一輪事件循環中也能得到同步。

常見的宏任務有:script(整體代碼)/setTimout/setInterval/setImmediate(node 獨有)/requestAnimationFrame(瀏覽器獨有)/IO/UI render(瀏覽器獨有)

常見的微任務有:process.nextTick(node 獨有)/Promise.then()/Object.observe/MutationObserver

宏任務 setTimeout 的誤區

setTimeout 的回調不一定在指定時間後能執行。而是在指定時間後,將回調函數放入事件循環的隊列中。

如果時間到了,JS 引擎還在執行同步任務,這個回調函數需要等待;如果當前事件循環的隊列裏還有其他回調,需要等其他回調執行完。

另外,setTimeout 0ms 也不是立刻執行,它有一個默認最小時間,為 4ms。所以下面這段代碼的輸出結果不一定:

// node
setTimeout(() => {
 console.log('setTimeout')
}, 0)
setImmediate(() => {
 console.log('setImmediate')
})

因為取出第一個宏任務之前在執行全局 Script,如果這個時間大於 4ms,這時 setTimeout 的回調函數已經放入隊列,就先執行 setTimeout;如果準備時間小於 4ms,就會先執行 setImmediate。

瀏覽器的事件循環

瀏覽器的事件循環由一個宏任務隊列+多個微任務隊列組成。

首先,執行第一個宏任務:全局 Script 腳本。產生的的宏任務和微任務進入各自的隊列中。執行完 Script 後,把當前的微任務隊列清空。完成一次事件循環。

接着再取出一個宏任務,同樣把在此期間產生的回調入隊。再把當前的微任務隊列清空。以此往復。

宏任務隊列只有一個,而每一個宏任務都有一個自己的微任務隊列,每輪循環都是由一個宏任務+多個微任務組成。

下面的 Demo 展示了微任務的插隊過程:

Promise.resolve().then(()=>{
 console.log('第一個回調函數:微任務1')
 setTimeout(()=>{
   console.log('第三個回調函數:宏任務2')
 },0)
})
setTimeout(()=>{
 console.log('第二個回調函數:宏任務1')
 Promise.resolve().then(()=>{
   console.log('第四個回調函數:微任務2')
 })
},0)
// 第一個回調函數:微任務1
// 第二個回調函數:宏任務1
// 第四個回調函數:微任務2
// 第三個回調函數:宏任務2

打印的結果不是從 1 到 4,而是先執行第四個回調函數,再執行第三個,因為它是一個微任務,比第三個回調函數有更高優先級。

Node 的事件循環

node 的事件循環比瀏覽器複雜很多。由 6 個宏任務隊列+6 個微任務隊列組成。

宏任務按照優先級從高到低依次是:

其執行規律是:在一個宏任務隊列全部執行完畢後,去清空一次微任務隊列,然後到下一個等級的宏任務隊列,以此往復。

一個宏任務隊列搭配一個微任務隊列。六個等級的宏任務全部執行完成,才是一輪循環。

其中需要關注的是:Timers、Poll、Check 階段,因為我們所寫的代碼大多屬於這三個階段。

  1. Timers:定時器 setTimeout/setInterval;
  2. Poll :獲取新的 I/O 事件, 例如操作讀取文件等;
  3. Check:setImmediate 回調函數在這裏執行;

除此之外,node 端微任務也有優先級先後:

  1. process.nextTick;
  2. promise.then 等;

清空微任務隊列時,會先執行 process.nextTick,然後才是微任務隊列中的其他。下面這段代碼可以佐證瀏覽器和 node 的差異:

console.log('Script開始')
setTimeout(() => {
 console.log('第一個回調函數,宏任務1')
 Promise.resolve().then(function() {
   console.log('第四個回調函數,微任務2')
 })
}, 0)
setTimeout(() => {
 console.log('第二個回調函數,宏任務2')
 Promise.resolve().then(function() {
   console.log('第五個回調函數,微任務3')
 })
}, 0)
Promise.resolve().then(function() {
 console.log('第三個回調函數,微任務1')
})
console.log('Script結束')
node端:
Script開始
Script結束
第三個回調函數,微任務1
第一個回調函數,宏任務1
第二個回調函數,宏任務2
第四個回調函數,微任務2
第五個回調函數,微任務3
瀏覽器
Script開始
Script結束
第三個回調函數,微任務1
第一個回調函數,宏任務1
第四個回調函數,微任務2
第二個回調函數,宏任務2
第五個回調函數,微任務3

可以看出,在 node 端要等當前等級的所有宏任務完成,才能輪到微任務:第四個回調函數,微任務2在兩個 setTimeout 完成後才打印。

因為瀏覽器執行時是一個宏任務+一個微任務隊列,而 node 是一整個宏任務隊列+一個微任務隊列。

node11.x 前後版本差異

node11.x 之前,其事件循環的規則就如上文所述:先取出完一整個宏任務隊列中全部任務,然後執行一個微任務隊列。

但在 11.x 之後,node 端的事件循環變得和瀏覽器類似:先執行一個宏任務,然後是一個微任務隊列。但依然保留了宏任務隊列和微任務隊列的優先級。可以用下面的 Demo 佐證:

console.log('Script開始')
setTimeout(() => {
 console.log('宏任務1(setTimeout)')
 Promise.resolve().then(() => {
   console.log('微任務promise2')
 })
}, 0)
setImmediate(() => {
 console.log('宏任務2')
})
setTimeout(() => {
 console.log('宏任務3(setTimeout)')
}, 0)
console.log('Script結束')
Promise.resolve().then(() => {
 console.log('微任務promise1')
})
process.nextTick(() => {
 console.log('微任務nextTick')
})

在 node11.x 之前運行:

Script開始
Script結束
微任務nextTick
微任務promise1
宏任務1(setTimeout)
宏任務3(setTimeout)
微任務promise2
宏任務2(setImmediate)

在 node11.x 之後運行:

Script開始
Script結束
微任務nextTick
微任務promise1
宏任務1(setTimeout)
微任務promise2
宏任務3(setTimeout)
宏任務2(setImmediate)

可以發現,在不同的 node 環境下:

  1. 微任務隊列中 process.nextTick 都有更高優先級,即使它後進入微任務隊列,也會先打印微任務nextTick再微任務promise1;
  2. 宏任務 setTimeout 比 setImmediate 優先級更高,宏任務2(setImmediate)是三個宏任務中最後打印的;
  3. 在 node11.x 之前,微任務隊列要等當前優先級的所有宏任務先執行完,在兩個 setTimeout 之後才打印微任務promise2;在 node11.x 之後,微任務隊列只用等當前這一個宏任務先執行完。

結語

事件循環中的任務被分為宏任務和微任務,是為了給高優先級任務一個插隊的機會:微任務比宏任務有更高優先級。

node 端的事件循環比瀏覽器更復雜,它的宏任務分為六個優先級,微任務分為兩個優先級。node 端的執行規律是一個宏任務隊列搭配一個微任務隊列,而瀏覽器是一個單獨的宏任務搭配一個微任務隊列。但是在 node11 之後,node 和瀏覽器的規律趨同。