有關JavaScript事件迴圈的若干疑問探究

夜盡丶發表於2022-04-13

起因

即使我完全沒有系統學習過JavaScript的事件迴圈機制,在經過一定時間的經驗積累後,也聽過一些諸如巨集任務和微任務、JavaScript是單執行緒的、Ajax和Promise是一種非同步操作、setTimeout會在最後執行等這類的碎片資訊,結合實際的程式碼也可以保證絕大多數情況下程式碼是按照我希望的順序執行,但是當我被實際問到這個問題時,發現自己並不能切實地理解這其中的原理,相關的資料有很多,但還是要用自己的理解來表述一遍。

為什麼要有事件迴圈?

首先是個簡單的問題,換句話說就是事件迴圈有什麼作用,我為什麼要學習這個知識?就像第一段裡提到的,眾所周知JavaScript是單執行緒語言,但這並不代表JavaScript不需要非同步操作,反向思考一下,如果你所寫的所有Ajax操作都是同步的會有什麼後果:我們每次向服務端傳送請求,整個頁面都會因此停滯,直到請求返回,無論響應時間是1毫秒、1秒還是1分鐘。對於使用者體驗來說,這無疑是災難,所以JavaScript提供了各種非同步程式設計的方式:事件迴圈、Promise、Generator、Worker等,這裡我們還是把目光先聚焦到事件迴圈上,隨著問題的深入,我們會知道事件迴圈為我們解決了什麼問題。

事件迴圈是怎樣運作的?

要理解這個問題,推薦先看下這個視訊:到底什麼是Event Loop呢?,然後是視訊中提到的網站:loupe,結合視訊我們可以很形象地看到事件是如何在迴圈中運作的,網站則是根據輸入的程式碼來用動畫演示這個過程。

順著視訊的思路我們把JavaScript的執行分成幾部分:呼叫棧(Call stack)、事件迴圈(Event loop)、回撥佇列(Callback queue)、其他API(Other apis)。

呼叫棧

因為JavaScript是單執行緒的,所以只能一句一句地執行我們的程式碼,編譯器每讀到一個函式就把它壓入棧中,棧頂的函式返回結果時就彈棧,在這個過程中只有同步函式函式會進入呼叫棧走正常的執行流程,而setTimeoutPromise這種非同步函式則會進入回撥佇列,形成事件迴圈的第一步。

Web API

視訊中最令我感到意外的是很多我們熟悉的函式並不是JavaScript提供的,而是來自於Web APIs,比如Ajax、DOM、setTimeout等,這些方法的實現並沒有出現在V8的原始碼中,因為它們是由瀏覽器提供的,更準確地說,應該是執行環境提供的,因為JavaScript的執行環境並不是統一的,不同的瀏覽器核心就不說了,我們就分成瀏覽器和Node就可以,看似與我們討論的事件迴圈無關,但其中還是存在區別,這個問題我們放在後面說明。

任務佇列

非同步方法經過Web API的處理後會進入任務佇列,以setTimeout為例就是瀏覽器提供了一個定時器,當處理這個方法時就在後臺啟動定時器,達到設定的時間時就將這個方法新增進任務佇列,當這一批的同步任務處理完後,JavaScript就會從佇列取出方法放入呼叫棧執行,所以,實際上我們設定的時間是指這個方法最早什麼時候可以執行,而不是延遲多久執行。我們來看一個例子,可以先腦內執行模擬一下結果:

console.log('1')

setTimeout(function setFirstTimeout() {
  console.log('2')

  new Promise(function (resolve) {
    console.log('3')
    resolve()
  }).then(function () {
    console.log('4')
  })
},0)

new Promise(function (resolve) {
  console.log('5')
  resolve()
}).then(function () {
  console.log('6')
})

console.log('7')

實際執行一下我們可以得到1、5、7、6、2、3、4這樣一個結果,把這段程式碼放到上文提到的網站裡可以很清晰地看到過程,我們定義的setFirstTimeout這一方法經由Web API的處理後進入了Callback Queue,等待主執行緒的程式碼執行完,再通過事件迴圈這一機制進入呼叫棧。

這樣就都說得通了:setTimeout為什麼總是在最後執行,但事實真是如此嗎?我們看下一個問題。

setTimeout一定是在所有程式碼最後執行嗎——巨集任務與微任務

即使沒有仔細研究過這個問題,根據經驗也知道肯定不是這樣,雖然setTimeout會相對延遲執行,但並不總是會在所有程式碼最後執行,這裡就涉及一個更大的問題——巨集任務與微任務。我們在上文的程式碼中新增一個DOM操作。

console.log('1')

$.on('button','click',function onClick(){
    console.log('Clicked');
})

setTimeout(function setFirstTimeout() {
  console.log('2')

  new Promise(function (resolve) {
    console.log('3')
    resolve()
  }).then(function () {
    console.log('4')
  })
},0)

new Promise(function (resolve) {
  console.log('5')
  resolve()
}).then(function () {
  console.log('6')
})

console.log('7')

直接看結果,當setTimeout的回撥方法進入事件佇列後,我點選了繫結了事件的按鈕,因此點選的回撥方法也進入了事件佇列,當同步任務處理完之後,根據佇列先入先出的之一原則,setTimeout的回撥方法就會先被處理,之後才是點選事件的回撥方法。

不算巧妙的一個例子,但是DOM操作確實與setTimeout同屬巨集任務這一類別,相對於巨集任務的則是微任務,常見分類如下:

巨集任務

  • script(整體程式碼)
  • setTimeout
  • setInterval
  • I/O
  • UI互動事件
  • postMessage
  • MessageChannel
  • setImmediate(Node.js 環境)

微任務

  • Promise.then
  • Object.observe
  • MutationObserver
  • process.nextTick(Node.js 環境)

其實從上面例子中,應該已經有人發現Promise的執行順序也不太正常。then中的回撥函式既沒有跟著Promise執行也沒有進入回撥佇列,這裡顯然不是程式有Bug,正是因為巨集任務與微任務有區別。

簡單地說,巨集任務和微任務各自有著自己的任務佇列,執行一個巨集任務時,遇到微任務會把它們移到微任務佇列中,執行完當前巨集任務後再依次執行微任務,讓我們把之前的例子再豐富一下:

console.log("1");

setTimeout(function s1() {
  console.log("2");
  process.nextTick(function p2() {
    console.log("3");
  });
  new Promise(function (resolve) {
    console.log("4");
    resolve();
  }).then(function t2() {
    console.log("5");
  });
});
process.nextTick(function p1() {
  console.log("6");
});
new Promise(function (resolve) {
  console.log("7");
  resolve();
}).then(function t1() {
  console.log("8");
});

console.log("9");

setTimeout(function s2() {
  console.log("10");
  process.nextTick(function () {
    console.log("11");
  });
  new Promise(function (resolve) {
    console.log("12");
    resolve();
  }).then(function () {
    console.log("13");
  });
});

以v16版本的node環境執行結果是:1、7、9、6、8、2、4、3、5、10、12、11、13,其他環境會有差異,我們放在後面說,先看眼前的問題,以process.nextTick是微任務為前提來分析。

  1. 執行console.log(1)
  2. 遇到巨集任務setTimeouts1,將其新增進Callback Queue
  3. 遇到微任務process.nextTickp1,將其新增進Task Queue
  4. 執行new Promise中的console.log(7)
  5. 將微任務thent1新增進Task Queue
  6. 執行console.log(9)
  7. 遇到巨集任務setTimeouts2,將其新增進Callback Queue

全域性的巨集任務執行完我們可以得到這樣兩個佇列,和1、7、9的輸出,按規則接下來執行這個巨集任務中的微任務p1和t1,得到6和8。

Callback Queue Task Queue
s1 p1
s2 t1

繼續下一個巨集任務s1:

  1. 執行console.log(2)
  2. 遇到微任務process.nextTickp2,將其新增進Task Queue
  3. 執行new Promise中的console.log(4)
  4. 將微任務thent2新增進Task Queue
Task Queue
p2
t2

因此,接下來的輸出是:2、4、3、5,以此類推,後面的都是差不多的規則,不一一贅述。

Node與瀏覽器的EventLoop有什麼差異?

上一個問題應該算是解決了,但也引出了一個新問題,之前我提到是以v16版本的node環境來執行,那麼如果不是v16版本的node甚至不用node來執行會有什麼結果呢?在這一次,徹底弄懂 JavaScript 執行機制這篇文章的評論區我看到了一些討論,v10之前的node在事件迴圈的處理上與瀏覽器不同,所以得到了另外的結果,我切換到v10的版本後,得到的還是1、7、9、6、8、2、4、3、5、10、12、11、13這樣的結果,個人覺得這裡以最新版本為準就好了,不打算深究,有興趣的可以看下那篇文章的評論區。

然後是另一種情況,最開始我是在Vue中驗證這段程式碼的,得到的結果是1、7、9、8、2、4、5、6、10、12、13、3、11,如果是在process.nextTick是巨集任務的前提下,這個結果就是正確的,但是這裡我不太清楚為什麼。另外我想到了Vue中也有一個nextTick方法,查了一下發現又是一個不同的課題,限於篇幅打算另開一篇來學習,具體的內容也可以看下這篇部落格Vue的nextTick具體是微任務還是巨集任務?

還有什麼問題?

寫這一篇部落格本來是想弄懂事件迴圈這一機制的,沒想到裡面的內容那麼多,在我剛上班的時候,遇到過一個問題JavaScript定時器越走越快的問題,當時我是以為把這個問題搞清楚了,從今天這篇文章的角度回頭來看那時候僅僅看到了冰山一角,這篇文章也同樣只是寫到了事件迴圈的冰山一角,好在現在我知道這件事了,除了Vue的nextTick這一問題外,還有一個渲染的問題與事件迴圈相關,之後也會將這部分內容整理成文章,這裡先推薦一篇部落格和一個視訊:

深入解析你不知道的 EventLoop 和瀏覽器渲染、幀動畫、空閒回撥(動圖演示)

深入事件環(In The Loop)

相關文章