有關JavaScript事件循環的若干疑問探究

起因

即使我完全沒有系統學習過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)