1. 程式人生 > 其它 >熟悉事件迴圈?那談談為什麼會分為巨集任務和微任務

熟悉事件迴圈?那談談為什麼會分為巨集任務和微任務

原文連結:熟悉事件迴圈?那談談為什麼會分為巨集任務和微任務

什麼是事件迴圈

在瞭解事件迴圈前,需要一些有關 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。所以下面這段程式碼的輸出結果不一定:

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 和瀏覽器的規律趨同。