瀏覽器下的 Event Loop

以前寫了一篇 Event Loop 的文章,然而在後續的深刻學習中發現當時有些概念的理解是錯誤的,因而又從新修改了一下,更新上來。javascript

javascript 是單線程語言,使用的是異步非阻塞的運行方式,不少狀況下須要經過事件和回調函數進行驅動,那麼這些註冊的回調函數,是在何時被運行環境調用的,彼此之間又是以怎樣的順序執行的?這就繞不開一個機制——Event Loop ,也就是事件循環。前端

什麼是 JS Event Loop

JS Event Loop 即事件循環,是運行在瀏覽器環境 / Node 環境中的一種消息通訊機制,它是主線程以外的獨立線程。當主線程內須要執行某些可能致使線程阻塞的耗時操做時(好比請求發送與接收響應、文件 I/O、數據計算)主線程會註冊一個回調函數並拋給 Event Loop 線程進行監聽,本身則繼續往下執行,一旦有消息返回而且主線程空閒的狀況下,Event Loop 會及時通知主線程,執行對應的回調函數獲取信息,以此達到非阻塞的目的。java

執行棧和消息隊列

在解析 Event Loop 運行機制以前,咱們要先理解棧(stack)和隊列(queue)的概念。 棧和隊列,二者都是線性結構,可是棧遵循的是後進先出(last in first off LIFO),開口封底。而隊列遵循的是先進先出 (fisrt in first out,FIFO),兩頭通透。webpack

Event Loop得以順利執行,它所依賴的容器環境,就和這兩個概念有關。git

咱們知道,在 js 代碼執行過程當中,會生成一個當前環境的「執行上下文( 執行環境 / 做用域)」,用於存放當前環境中的變量,這個上下文環境被生成之後,就會被推入js的執行棧。一旦執行完成,那麼這個執行上下文就會被執行棧彈出,裏面相關的變量會被銷燬,在下一輪垃圾收集到來的時候,環境裏的變量佔據的內存就能得以釋放。web

這個執行棧,也能夠理解爲JavaScript的單一線程,全部代碼都跑在這個裏面,以同步的方式依次執行,或者阻塞,這就是同步場景。面試

那麼異步場景呢?顯然就須要一個獨立於「執行棧」以外的容器,專門管理這些異步的狀態,因而在「主線程」、「執行棧」以外,有了一個 Task 的隊列結構,專門用於管理異步邏輯。全部異步操做的回調,都會暫時被塞入這個隊列。Event Loop 處在二者之間,扮演一個大管家的角色,它會以一個固定的時間間隔不斷輪詢,當它發現主線程空閒,就會去到 Task 隊列裏拿一個異步回調,把它塞入執行棧中執行,一段時間後,主線程執行完成,彈出上下文環境,再次空閒,Event Loop 又會執行一樣的操做。。。依次循環,因而構成了一套完整的事件循環運行機制。express

上圖比較簡潔地描繪了整個過程,只不過其中多了 heap (堆)的概念,堆和棧,簡單來講,堆是留給開發者分配的內存空間,而棧是原生編譯器要使用的內存空間,兩者獨立。promise

microtask 和 macrotask

若是隻想應付普通點的面試,上面一節的內容就足夠了,可是想要答出下面的這條面試題,就必須再次深刻 Event Loop ,瞭解任務隊列的深層原理:microtask(微任務)和 macrotask(宏任務)。瀏覽器

// 請給出下面這段代碼執行後,log 的打印順序
console.log('script start')

async function async1() {
  await async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2 end')
}
async1()

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

new Promise(resolve => {
  console.log('Promise')
  resolve()
})
  .then(function() {
    console.log('promise1')
  })
  .then(function() {
    console.log('promise2')
  })

console.log('script end')

// log 打印順序:script start -> async2 end -> Promise -> script end -> async1 end -> promise1 -> promise2 -> setTimeout
複製代碼

若是隻有一個單一的 Task 隊列,就不存在上面的順序問題了。但事實狀況是,瀏覽器會根據任務性質的不一樣,將不一樣的任務源塞入不一樣的隊列中,任務源能夠分爲微任務(microtask) 和宏任務(macrotask),介於瀏覽器對兩種不一樣任務源隊列中回調函數的讀取機制,形成了上述代碼中的執行順序問題。

微任務包括 process.nextTickpromiseMutationObserver,其中 process.nextTick 爲 Node 獨有。

宏任務包括 scriptsetTimeoutsetIntervalsetImmediateI/OUI rendering

瀏覽器 Event Loop 的執行機制

瞭解了微任務宏任務的概念後,咱們就能夠完整地分析一邊 Event Loop 的執行機制了。

  • 初始狀態:執行棧爲空,micro-task 爲空,macro-task裏有且只有一個 script 腳本(總體代碼)
  • script 腳本執行:全局上下文(script 任務)被推入執行棧,代碼以同步的方式以此執行。在執行的過程當中,可能會產生新的 macro-taskmicro-task,它們會分別被推入各自的任務隊列裏
  • script 腳本出隊:同步代碼執行完了,script 腳本會被移出 macro-task,這個過程本質上是宏任務隊列的執行和出隊的過程。
  • 微任務隊列執行:上一步已經將 script 宏任務執行並出隊了,這時候執行棧爲空,Event Loop 會去 micro-task 中將微任務推入主線程執行,這裏的微任務的執行方式和宏任務的執行方式有個很重要的區別,就是:宏任務是一個一個執行,而微任務是一隊一隊執行的。也就是說,執行一個宏任務,要執行一隊的微任務。(注意:在執行微任務的過程當中,仍有可能有新的微任務插入 micro-task 那麼這種狀況下,Event Loop 仍然須要將本次 Tick (循環) 下的微任務拿到主線程中執行完畢)

  • 瀏覽器執行渲染操做,更新界面
  • 檢查是否存在 Web worker 任務,若是有,則對其進行處理 。
  • 上述過程循環往復,直到兩個隊列都清空

瀏覽器渲染時機

在上面瀏覽器 Event Loop 的執行機制中,有很重要的一塊內容,就是瀏覽器的渲染時機,瀏覽器會等到當前的 micro-task 爲空的時候,進行一次從新渲染。因此若是你須要在異步的操做後從新渲染 DOM 最好的方法是將它包裝成 micro 任務,這樣 DOM 渲染將會在本次 Tick 內就完成。

面試題解析

看到這裏,相信你已經明白上面的那條面試題是怎麼一回事了,咱們能夠用對 Event Loop 的理解來分析一下這道題目的執行:

// 請給出下面這段代碼執行後,log 的打印順序
console.log('script start')

// 這邊的 await 可能不太好理解,我換成了另外一種寫法
function async1() {
  async2().then(res => {
    console.log('async1 end')
  })
}
function async2() {
  console.log('async2 end')
  return Promise.resolve(undefined);
}
async1()

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

new Promise(resolve => {
  console.log('Promise')
  resolve()
})
  .then(function() {
    console.log('promise1')
  })
  .then(function() {
    console.log('promise2')
  })

console.log('script end')

// log 打印順序:script start -> async2 end -> Promise -> script end -> async1 end -> promise1 -> promise2 -> setTimeout
複製代碼
  • script 腳本開頭碰到了 console.log 因而打印 script start
  • 解析至 async1() ,async1 執行環境被推入執行棧,解析引擎進入 async1 內部
  • 發現 async1 內部調用了 async2,因而繼續進入 async 2,並將 async 2 執行環境推入執行棧
  • 碰到 console.log,因而打印 async2 end
  • async2 函數執行完成,彈出執行棧,並返回了一個 Promise.resolve(undefined),此時,因爲 Promise 已經變成 resolve 狀態,因而async1 then 註冊的回調被推入 microtask
  • 解析至 setTimeout,等待 0ms 後將其回調推入 macrotask
  • 繼續執行,直到碰到了 Promise,new Promise 的內部註冊的回調是當即執行的,解析進入注入函數的內部,碰到 console.log,因而打印 'Promise',再往下,碰到了 resolve,將第一個 then 中的回調函數推入 micro-task ,而後碰到了第二個 then ,繼續將其中的回調函數推入 micro-task。
  • 執行到最後一段代碼,打印 script end
  • 自此,第一輪 Tick 中的一個宏任務執行完成,開始執行微任務隊列,經過前面的分析能夠得知,目前 micro-task 中有三個任務,依次爲:console.log('async 1')、console.log('promise1')、console.log('promise2')因而 Event Loop 會將這三個回調依次取到主線程執行,控制檯打印:async一、promise一、promise2
  • 自此,micro-task 爲空,瀏覽器開始從新渲染(若是有 DOM 操做的話)
  • Event Loop 再次啓動一個新的 Tick ,從宏任務隊列中拿出一個(惟一的一個)宏任務執行,打印出:setTimeout

本篇文章已收錄入 前端面試指南專欄

相關參考

  1. 前端性能優化原理與實踐
  2. 前端面試之道

往期內容推薦

  1. 完全弄懂節流和防抖
  2. 【基礎】HTTP、TCP/IP 協議的原理及應用
  3. 【實戰】webpack4 + ejs + express 帶你擼一個多頁應用項目架構
相關文章
相關標籤/搜索