今天,我明白了JS事件循環機制

寫在前面

js是一門單線程的編程語言,也就是說js在處理任務的時候,所有任務只能在一個線程上排隊被執行,那如果某一個任務耗時比較長呢?總不能等到它執行結束再去執行下一個。
所以在線程之內,又被分爲了兩個隊列:

  • 同步任務隊列
  • 異步任務隊列

舉個例子來說:比如你去銀行辦理業務,都需要領號排隊。銀行櫃員一個個辦理業務,這時這個櫃員就相當於一個js線程,客戶排的隊就相當於同步任務隊列,每個人對於櫃員相當於一個個的任務。
但這個時候,你的電話突然響了,你去接電話接了半小時。這時候人家櫃員一看你這情況,直接叫了下一個,而你領的號就作廢了,只能重新零號排隊。這時候你就是被分發到了異步任務隊列。
等你前邊的人都完事了,櫃員把你叫過去辦了你的業務,這時候就是同步隊列中的任務執行完了,主線程會處理異步隊列中的任務。

同步任務和異步任務

這裏說的異步任務,它的意思是包含了獨立於主執行棧之外的宏任務和微任務

先看一個簡單的例子,對這樣的執行機制有個簡單的認識:

console.log("start")

console.log("end")

上邊的執行結果大家肯定都明白,先輸出start,再輸出end,這一段代碼會進入同步隊列,順序執行。

那麼我們加點料:

console.log("start")

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

console.log("end")

這樣的情況,函數調用棧執行到setTimeout時,setTimeout會在規定的時間點將回調函數放入異步隊列,等待同步隊列的任務被執行完,立即執行,所以結果是:start、end、setTimeout。

但需要注意的一點是,普遍認爲setTimeout定時執行的認知是片面的,因爲假設setTimeout規定2秒後執行,但同步隊列中有一個函數,執行花了很長時間,甚至花了1秒。那麼這時setTimeout中的回調也會等上至少1秒之後,同步任務都執行完了,再去執行。這時候的setTimeout回調執行的時機就會超過2秒,也就是至少3秒。

宏任務與微任務

宏任務與微任務都是獨立與主執行棧之外的另外兩個隊列,可以在概念上劃分在異步任務隊列裏。而這些隊列由js的事件循環(EventLoop)來搞定

macro-task(宏任務)與micro-task(微任務),在最新標準中,它們被分別稱爲task與jobs。

由於寫文章時沒有注意到,實際上宏任務與微任務的概念是不準確的,但由於文章中涉及多處宏任務、微任務的解讀,所以本文暫時還是用宏任務、微任務來分別代指task、jobs。但讀者要明白規範中沒有宏任務的概念,只有task與jobs

其中宏任務(task)包括:

  • script(整體代碼)
  • setTimeout, setInterval, setImmediate,
  • I/O
  • UI rendering
    ajax請求不屬於宏任務,js線程遇到ajax請求,會將請求交給對應的http線程處理,一旦請求返回結果,就會將對應的回調放入宏任務隊列,等請求完成執行。

微任務(job)包括:

  • process.nextTick
  • Promise
  • Object.observe(已廢棄)
  • MutationObserver(html5新特性)

這些我們可以理解爲它們在執行上下文中都是可執行代碼,會立即執行,只不過會將各自的回調函數放入對應的任務隊列中(宏任務微任務),也就相當於一個調度者。

我們梳理一下事件循環的執行機制:
循環首先從宏任務開始,遇到script,生成執行上下文,開始進入執行棧,可執行代碼入棧,依次執行代碼,調用完成出棧。
執行過程中遇到上邊提到的調度者,會同步執行調度者,由調度者將其負責的任務(回調函數)放到對應的任務隊列中,直到主執行棧清空,然後開始執行微任務的任務隊列。微任務也清空後,再次從宏任務開始,一直循環這一過程。

示例

上邊說了那麼多,還是用一些代碼來驗證一下是否是這樣的,先來一個簡單一點的。

console.log("start")

setTimeout(function() {
    console.log("timeout")
}, 0)

new Promise(function(resolve) {
    console.log("promise")
    resolve()
}).then(function() {
    console.log("promise resolved")
})

console.log("end")

根據上邊的結論,分析一下執行過程:

  • 建立執行上下文,進入執行棧開始執行代碼,打印start
  • 往下執行,遇到setTimeout,將回調函數放入宏任務隊列,等待執行
  • 繼續往下,有個new Promise,其回調函數並不會被放入其他任務隊列,因此會同步地執行,打印promise,但是當resolve後,.then會把其內部的回調函數放入微任務隊列
  • 執行到了最底部的代碼,打印出end。這時,主執行棧清空了,開始尋找微任務隊列裏有沒有可執行代碼
  • 發現了微任務隊列中有之前放進去的代碼,執行打印出promise resolved,第一次循環結束
  • 再開始第二次循環,從宏任務開始,檢查宏任務隊列是否有可執行代碼,發現有一個,打印timeout

所以,打印順序是:start–>promise–>end–>promise resolved–>timeout

上邊是一個簡單示例,比較好理解。那麼接下來看一個稍微複雜一點的(這裏直接用漢字直觀地表明瞭打印時機的意思,避免大家蒙圈):

console.log("第一次循環主執行棧開始")

setTimeout(function() {
    console.log("第二次循環開始,宏任務隊列的第一個宏任務執行中")
    new Promise(function(resolve) {
        console.log("宏任務隊列的第一個宏任務的微任務繼續執行")
        resolve()
    }).then(function() {
        console.log("第二次循環的微任務隊列的微任務執行")
    })
}, 0)

new Promise(function(resolve) {
    console.log("第一次循環主執行棧進行中...")
    resolve()
}).then(function() {
    console.log("第一次循環微任務,第一次循環結束")
    setTimeout(function() {
        console.log("第二次循環的宏任務隊列的第二個宏任務執行")
    })
})

console.log("第一次循環主執行棧完成")

同樣我們分析一下執行過程:

  • 第一次循環

    1. 進入執行棧執行代碼,打印第一次循環主執行棧開始
    2. 遇到setTimeout,將回調放入宏任務隊列等待執行
    3. promise聲明過程是同步的,打印第一次循環主執行棧進行中...,resolve後遇到.then,將回調放入微任務隊列
    4. 打印第一次循環主執行棧完成
    5. 檢查微任務隊列是否有可執行代碼,有一個第三步放入的任務,打印第一次循環微任務,第一次循環結束,第一次循環結束,同時遇到setTimeout,將回調放入宏任務隊列
  • 第二次循環

    1. 從宏任務入手,檢查宏任務隊列,發現有兩個宏任務,分別是第一次循環第二步和第一次循環第五步被放入的任務,先執行第一個宏任務,打印第二次循環開始,宏任務隊列的第一個宏任務執行中
    2. 遇到promise聲明語句,打印宏任務隊列的第一個宏任務繼續執行,這時候又被resolve了,又會將.then中的回調放入微任務隊列,這是這個宏任務隊列中的第一個任務還沒執行完
    3. 第一個宏任務中的同步代碼執行完畢,檢查微任務隊列,發現有一段第二步放進去的代碼,執行打印第二次循環的微任務隊列的微任務執行,此時第一個宏任務執行完畢
    4. 開始執行第二個宏任務,打印第二次循環的宏任務隊列的第二個宏任務執行,所有任務隊列全部清空,執行完畢

所以打印順序爲:

  • 第一次循環主執行棧開始
  • 第一次循環主執行棧進行中…
  • 第一次循環主執行棧完成
  • 第一次循環微任務,第一次循環結束
  • 第二次循環開始,宏任務隊列的第一個宏任務執行中
  • 第二次循環的宏任務隊列的第一個宏任務的微任務繼續執行
  • 第二次循環的微任務隊列的微任務執行
  • 第二次循環的宏任務隊列的第二個宏任務執行

看一下gif,事件循環以肉眼可見的形式呈現出來(兩次循環之間有微小的時間間隔)

總結

js的執行機制是面試中常考的點,也是非常繞的。但相信完全瞭解事件循環機制,仔細分析的話,面試遇到這樣的題完全不是問題。我在寫這篇文章的時候,發現自己之前理解的很大一部分是錯的。如果大家覺得哪裏有錯誤,還請幫忙指點出來。


歡迎關注我的公衆號: 一口一個前端,不定期分享我所理解的前端知識

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章