天天看點

事件循環機制(EventLoop)你真的玩明白了麼

作者:我的代碼果然有問題

前言

事件循環機制是浏覽器按照我們的期望有條不紊地執行任務的重要保障,也是我們前端面試過程中幾乎必考的一道題目,但很多時候,我們提到事件循環,想到的隻是Promise,setTimeout,哪些屬于宏任務,哪些屬于微任務,還有一大堆讓你講出輸出順序的題目,很少涉及背後的原由,為什麼它要這麼設計?以及什麼情況下,事件循環機制會給我們意料之外的結果?本節,我就和大家一起,聊一聊我對Javascript事件循環機制的了解

熱身問題

這裡抛出第一個問題,同樣是經典的考察輸出順序,看看有多少同學能夠答對

const button = document.createElement('button')
button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('micro 1'))
  console.log('event 1');
})
button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('micro 2'))
  console.log('event 2');
})
button.innerText = 'test'
document.body.appendChild(button)

button.click()           

看完上面的代碼,請回答以下兩個問題

  • 執行代碼控制台會輸出什麼?
  • 如果我用滑鼠點選按鈕觸發事件,輸出結果一樣嗎?為什麼?

關于第一問,我想大部分同學都能輕松的回答上來,不就是先執行同步的任務,遇到微任務把任務推到微任務隊列中,同步任務處理完了,再回來處理微任務,很簡單嘛,是以答案是

event 1
event 2
micro 1
micro 2           

沒錯這就是第一問的答案,這時看到第二問,可能有的同學就會有點疑問了,這不一樣麼?難道還有差別?我們先把代碼放到浏覽器中執行一下看看

事件循環機制(EventLoop)你真的玩明白了麼

可以看到,答案是

event 1
micro 1
event 2
micro 2           

是以為什麼會不一樣呢?這就是我們今天想要探究的事件循環機制背後藏了多少東西

關于含糊不清的宏任務

其實對上面問題的了解出現偏差,很大程度是因為對宏任務沒有了解好(實際上看了很多資料,官方似乎并沒有哪個文檔提及到宏任務這個詞——macroTask)

個人了解宏任務是為了了解友善所造的一個詞,大部分資料将setTimeout,setInterval等視為産生宏任務的方法,但是關鍵點卻很少提及

宏任務産生後,并不會那馬上進入任務隊列中,而是交給了相關的線程(這裡是容易忽視的一個點)

很多案例在分析時,喜歡用setTimeout(fn,0)舉例,産生宏任務後就直接丢到宏任務隊列中,等待執行,這是有問題的,實際上并沒有什麼宏任務隊列,宏任務産生的回調方法本身也是一個普通的任務

一個簡單的例子

setTimeout(() => {
    console.log(1);
}, 3000)

setTimeout(() => {
    console.log(2);
}, 2000)

setTimeout(() => {
    console.log(3);
}, 1000)           

輸出的結果是3,2,1,如果宏任務産生後就直接将回調方法丢到任務隊列中等待執行,那麼結果就亂了!

為什麼它能正确的進行回調處理,是因為有另外的線程在正确的時機将回調任務推到任務隊列中進行執行!

關于微任務的誕生

提到微任務,很多人會将它和Promise聯系起來,但這不是微任務産生的初衷

微任務的出現,最早是浏覽器想提供給開發者一種監控DOM變化的方法(例如插入元素到頁面中)

如果沒有微任務,像這種代碼代碼,浏覽器将産生200個事件(插入一個,修改一個)

for (let i = 0; i <100; i++) {
  const span = document.createElement('span')
  span.textContent = 'Hello'
}           

我們的期望是類似上面的操作,隻産生一個事件而不是200次,解決方案是使用DOM變化事件的觀察者,于是浏覽器建立了一個新的隊列就叫做微任務隊列,存在于當次的執行環境中

ui 渲染會等待所有微任務執行完成之後才能執行,之後才是宏任務(這也是後面我為什麼把浏覽器渲染作為一個事件循環的起點的原因)

下面兩個案例,有興趣的同學可以自己浏覽器試試,有助于了解任務執行的機制

  • 不會阻塞頁面
function loop() {
  setTimeout(() => {
    loop()
  }, 0)
}
loop()           
  • 阻塞了頁面
function loop() {
  Promise.resolve().then(loop)
}
loop()           

微任務如果不為空會一直執行,并且阻塞後續的所有任務(UI渲染)

這也就是為什麼我們遇到的微任務,常常都是發生在一項任務之後

并且任何javascript運作的時候都可能執行微任務

浏覽器是怎麼工作的

浏覽器是一個多程序,多線程的狀态在進行工作的

上面說到,異步的宏任務JS引擎會移交給浏覽器的其它線程處理,那麼浏覽器都有哪些線程,以及哪些和異步任務相關呢?

具體到一個标簽tab頁面,就是一個程序,裡面有五個主要的線程在一起工作

事件循環機制(EventLoop)你真的玩明白了麼

簡單說下它們主要負責做什麼

線程 作用
GUI渲染線程 頁面繪制
js線程 執行 js 腳本(與GUI互斥,如果 js 執行時間過長會造成頁面卡頓)
定時觸發器線程 定時器setInterval與setTimeout所線上程
事件觸發線程 用來控制事件輪詢,異步事件,JS引擎自己忙不過來,需要浏覽器另開線程協助
異步http請求線程 XMLHttpRequest在連接配接後是通過浏覽器新開一個線程請求, 将檢測到狀态變更時,如果設定有回調函數,異步線程就産生狀态變更事件放到 JavaScript引擎的處理隊列中等待處理

看到這裡就比較清晰,setTimeout 這個API會産生一個宏任務丢給浏覽器的定時觸發器線程,當時間到的時候,定時觸發器線程會把回調任務推進JS任務隊列中等待執行

上面的那個點選輸出順序問題其實也與這相關,對于事件的處理,浏覽器有相應的事件觸發線程去做處理,你注冊一個事件,點選時就會觸發一個事件,把回調方法推到任務隊列中執行,并且每一個事件都是互相獨立的任務,彼此之間不會互相影響(也就是說它們其實不是在同一個事件循環周期中發生的)

而如果你是btn.click()實際上是由JS引擎直接觸發,不經過事件觸發線程,也就沒有了所謂的宏任務,隻有在同一事件循環中的微任務被先後觸發

了解完這個點,我們就來一起完整的回顧下

為什麼要有事件循環機制

老生常談,Javascript是一門單線程的語言,同一時間隻能做一件事,但是現實場景中,不管是使用者互動,還是接口請求,或者是定時器,都是常見的異步場景,如果不引入其它機制,讓js按順序執行任務,那麼頁面就會失去響應,js線程被阻塞,于是,為了讓這些異步的任務不阻塞JS線程的執行,浏覽器提供了事件循環的機制,來實作異步事件的回調

事件循環機制在做什麼

浏覽器在js執行棧工作的過程中,還同時存在着Task Queue和microtask Queue這兩個任務隊列,裡面就存放着我們待執行的任務與微任務,所謂循環機制,就是浏覽器會在合适的時候,不斷地檢視這兩個任務隊列,有沒有需要執行的任務,如果有,就把任務拿到執行棧中執行,執行完了之後後開始新一輪的詢問檢視,如此反複

各種任務執行的時機是什麼

在分析執行時間前我們先來了解下各種任務的概念

同步任務與異步任務

我們使用同步的代碼或是異步的代碼會生成不同的任務,對此浏覽器會有不同的執行政策

  • 同步代碼

任務立即放入JS線程引擎(執行棧),并原地等待結果

  • 異步代碼

先放入宿主環境中(相應的微任務隊列或者線程),不等待結果,不阻塞主線程繼續往下執行,等待js引擎執行棧中任務都完成時,會去任務隊列中取出任務來執行,也就是異步結果在将來執行

宏任務與微任務

宏任務與微任務都屬于異步任務,它們通過調用不同的api進人任務隊列

  • 宏任務

宏任務可通過setTimeout、setInterval、setImmediate、event等方式觸發,并由相應的線程控制進入任務隊列(着重關注setTimeout和event,很多題目場景會涉及)

  • 微任務

微任務通過process.nextTick、Promises、MutationObserver等方式進入微任務隊列(着重關注Promise,nextTick,很多題目場景會涉及)

這裡需要注意,異步代碼會産生異步任務,但是宏任務産生的回調不一定會進入任務隊列,浏覽器會有相應的線程去處理這些異步任務,并在合适的時候,把任務回調推入任務隊列中

時機

接下來就是關鍵了,不同的異步任務的觸發時機究竟是什麼

事件循環機制(EventLoop)你真的玩明白了麼

定義循環的開始和結束

首先,每一個 eventLoop 都是一個單獨的循環隻有當目前事件循環完成時,才會進入下一個循環

這裡我們把浏覽器完成渲染的時候作為一個新的事件循環的開始

  • 浏覽器完成渲染(開始)

當界面渲染完成時,我們開啟一個新的事件循環

  • 開始執行任務隊列

這裡的任務不是指宏任務,而是正常的執行代碼,JS遇到所謂的宏任務(setTimeout,event事件等)會将任務的回調交給相應的線程處理,線程處理完成後會将相關的回調事件推入任務隊列,等待執行

舉個例子:

JS 遇到 setTimeout(fnA, 1000)這個異步代碼

會産生一個異步任務,給到浏覽器對應的線程(這裡是定時觸發器線程)

等待 1 秒後,定時器觸發線程會把相應的回調任務推進任務隊列中

JS通過事件循環機制,讀取并執行任務隊列中的任務

  • 清空任務隊列中的任務
  • 開始執行微任務隊列中的任務
  • 清空微任務隊列中的任務

注意如果産生新的微任務,也屬于同一循環周期,需要全部微任務完成才能進入下個階段

  • 浏覽器渲染(結束)

到這裡,一個事件循環結束,進入下一個循環

總結

個人關于宏任務與微任務的一點了解是,宏任是交由浏覽器或是目前宿主環境(node)進行處理的,它們會在合适的時機将宏任務的回調方法交給js引擎執行,而微任務則直接存在于js引擎的執行環境中,執行時機是在主函數執行結束之後,并且除非清空微任務,否則不會進入下一個事件循環中

繼續閱讀