微任務、宏任務與Event-Loop

首先,JavaScript是一個單線程的腳本語言。
因此就是說在一行代碼執行的過程當中,必然不會存在同時執行的另外一行代碼,就像使用alert()之後進行瘋狂console.log,若是沒有關閉彈框,控制檯是不會顯示出一條log信息的。
亦或者有些代碼執行了大量計算,比方說在前端暴力破解密碼之類的鬼操做,這就會致使後續代碼一直在等待,頁面處於假死狀態,由於前邊的代碼並無執行完。 javascript

因此若是所有代碼都是同步執行的,這會引起很嚴重的問題,比方說咱們要從遠端獲取一些數據,難道要一直循環代碼去判斷是否拿到了返回結果麼?_就像去飯店點餐,確定不能說點完了之後就去後廚催着人炒菜的,會被揍的。_
因而就有了異步事件的概念,註冊一個回調函數,好比說發一個網絡請求,咱們告訴主程序等到接收到數據後通知我,而後咱們就能夠去作其餘的事情了。
而後在異步完成後,會通知到咱們,可是此時可能程序正在作其餘的事情,因此即便異步完成了也須要在一旁等待,等到程序空閒下來纔有時間去看哪些異步已經完成了,能夠去執行。
好比說打了個車,若是司機先到了,可是你手頭還有點兒事情要處理,這時司機是不可能本身先開着車走的,必定要等到你處理完事情上了車才能走。 html

圖片描述

微任務與宏任務的區別

這個就像去銀行辦業務同樣,先要取號進行排號。
通常上邊都會印着相似:「您的號碼爲XX,前邊還有XX人。」之類的字樣。 前端

由於櫃員同時職能處理一個來辦理業務的客戶,這時每個來辦理業務的人就能夠認爲是銀行櫃員的一個宏任務來存在的,當櫃員處理完當前客戶的問題之後,選擇接待下一位,廣播報號,也就是下一個宏任務的開始。
因此多個宏任務合在一塊兒就能夠認爲說有一個任務隊列在這,裏邊是當前銀行中全部排號的客戶。
任務隊列中的都是已經完成的異步操做,而不是說註冊一個異步任務就會被放在這個任務隊列中,就像在銀行中排號,若是叫到你的時候你不在,那麼你當前的號牌就做廢了,櫃員會選擇直接跳過進行下一個客戶的業務處理,等你回來之後還須要從新取號java

並且一個宏任務在執行的過程當中,是能夠添加一些微任務的,就像在櫃檯辦理業務,你前邊的一位老大爺可能在存款,在存款這個業務辦理完之後,櫃員會問老大爺還有沒有其餘須要辦理的業務,這時老大爺想了一下:「最近P2P爆雷有點兒多,是否是要選擇穩一些的理財呢」,而後告訴櫃員說,要辦一些理財的業務,這時候櫃員確定不能告訴老大爺說:「您再上後邊取個號去,從新排隊」。
因此原本快輪到你來辦理業務,會由於老大爺臨時添加的「理財業務」而日後推。
也許老大爺在辦完理財之後還想 再辦一個信用卡?或者 再買點兒記念幣
不管是什麼需求,只要是櫃員可以幫她辦理的,都會在處理你的業務以前來作這些事情,這些均可以認爲是微任務。 node

這就說明:你大爺永遠是你大爺
在當前的微任務沒有執行完成時,是不會執行下一個宏任務的。 git

因此就有了那個常常在面試題、各類博客中的代碼片斷:github

setTimeout(_ => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(_ => {
  console.log(3)
})

console.log(2)

setTimeout就是做爲宏任務來存在的,而Promise.then則是具備表明性的微任務,上述代碼的執行順序就是按照序號來輸出的。 web

全部會進入的異步都是指的事件回調中的那部分代碼
也就是說new Promise在實例化的過程當中所執行的代碼都是同步進行的,而then中註冊的回調纔是異步執行的。
在同步代碼執行完成後纔回去檢查是否有異步任務完成,並執行對應的回調,而微任務又會在宏任務以前執行。
因此就獲得了上述的輸出結論一、二、三、4面試

+部分表示同步執行的代碼api

+setTimeout(_ => {
-  console.log(4)
+})

+new Promise(resolve => {
+  resolve()
+  console.log(1)
+}).then(_ => {
-  console.log(3)
+})

+console.log(2)

原本setTimeout已經先設置了定時器(至關於取號),而後在當前進程中又添加了一些Promise的處理(臨時添加業務)。

因此進階的,即使咱們繼續在Promise中實例化Promise,其輸出依然會早於setTimeout的宏任務:

setTimeout(_ => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(_ => {
  console.log(3)
  Promise.resolve().then(_ => {
    console.log('before timeout')
  }).then(_ => {
    Promise.resolve().then(_ => {
      console.log('also before timeout')
    })
  })
})

console.log(2)

固然了,實際狀況下不多會有簡單的這麼調用Promise的,通常都會在裏邊有其餘的異步操做,好比fetchfs.readFile之類的操做。
而這些其實就至關於註冊了一個宏任務,而非是微任務。

P.S. 在Promise/A+的規範中,Promise的實現能夠是微任務,也能夠是宏任務,可是廣泛的共識表示(至少Chrome是這麼作的),Promise應該是屬於微任務陣營的

因此,明白哪些操做是宏任務、哪些是微任務就變得很關鍵,這是目前業界比較流行的說法:

宏任務

# 瀏覽器 Node
I/O
setTimeout
setInterval
setImmediate
requestAnimationFrame

有些地方會列出來UI Rendering,說這個也是宏任務,但是在讀了HTML規範文檔之後,發現這很顯然是和微任務平行的一個操做步驟
requestAnimationFrame姑且也算是宏任務吧,requestAnimationFrameMDN的定義爲,下次頁面重繪前所執行的操做,而重繪也是做爲宏任務的一個步驟來存在的,且該步驟晚於微任務的執行

微任務

# 瀏覽器 Node
process.nextTick
MutationObserver
Promise.then catch finally

Event-Loop是個啥

上邊一直在討論 宏任務、微任務,各類任務的執行。
可是回到現實,JavaScript是一個單進程的語言,同一時間不能處理多個任務,因此什麼時候執行宏任務,什麼時候執行微任務?咱們須要有這樣的一個判斷邏輯存在。

每辦理完一個業務,櫃員就會問當前的客戶,是否還有其餘須要辦理的業務。_(檢查還有沒有微任務須要處理)_
而客戶明確告知說沒有事情之後,櫃員就去查看後邊還有沒有等着辦理業務的人。_(結束本次宏任務、檢查還有沒有宏任務須要處理)_
這個檢查的過程是持續進行的,每完成一個任務都會進行一次,而這樣的操做就被稱爲Event Loop。_(這是個很是簡易的描述了,實際上會複雜不少)_

並且就如同上邊所說的,一個櫃員同一時間只能處理一件事情,即使這些事情是一個客戶所提出的,因此能夠認爲微任務也存在一個隊列,大體是這樣的一個邏輯:

const macroTaskList = [
  ['task1'],
  ['task2', 'task3'],
  ['task4'],
]

for (let macroIndex = 0; macroIndex < macroTaskList.length; macroIndex++) {
  const microTaskList = macroTaskList[macroIndex]
  
  for (let microIndex = 0; microIndex < microTaskList.length; microIndex++) {
    const microTask = microTaskList[microIndex]

    // 添加一個微任務
    if (microIndex === 1) microTaskList.push('special micro task')
    
    // 執行任務
    console.log(microTask)
  }

  // 添加一個宏任務
  if (macroIndex === 2) macroTaskList.push(['special macro task'])
}

// > task1
// > task2
// > task3
// > special micro task
// > task4
// > special macro task

之因此使用兩個for循環來表示,是由於在循環內部能夠很方便的進行push之類的操做(添加一些任務),從而使迭代的次數動態的增長。

以及還要明確的是,Event Loop只是負責告訴你該執行那些任務,或者說哪些回調被觸發了,真正的邏輯仍是在進程中執行的。

在瀏覽器中的表現

在上邊簡單的說明了兩種任務的差異,以及Event Loop的做用,那麼在真實的瀏覽器中是什麼表現呢?
首先要明確的一點是,宏任務必然是在微任務以後才執行的(由於微任務其實是宏任務的其中一個步驟)

I/O這一項感受有點兒籠統,有太多的東西均可以稱之爲I/O,點擊一次button,上傳一個文件,與程序產生交互的這些均可以稱之爲I/O

假設有這樣的一些DOM結構:

<style>
  #outer {
    padding: 20px;
    background: #616161;
  }

  #inner {
    width: 100px;
    height: 100px;
    background: #757575;
  }
</style>
<div id="outer">
  <div id="inner"></div>
</div>
const $inner = document.querySelector('#inner')
const $outer = document.querySelector('#outer')

function handler () {
  console.log('click') // 直接輸出

  Promise.resolve().then(_ => console.log('promise')) // 註冊微任務

  setTimeout(_ => console.log('timeout')) // 註冊宏任務

  requestAnimationFrame(_ => console.log('animationFrame')) // 註冊宏任務

  $outer.setAttribute('data-random', Math.random()) // DOM屬性修改,觸發微任務
}

new MutationObserver(_ => {
  console.log('observer')
}).observe($outer, {
  attributes: true
})

$inner.addEventListener('click', handler)
$outer.addEventListener('click', handler)

若是點擊#inner,其執行順序必定是:click -> promise -> observer -> click -> promise -> observer -> animationFrame -> animationFrame -> timeout -> timeout

由於一次I/O建立了一個宏任務,也就是說在此次任務中會去觸發handler
按照代碼中的註釋,在同步的代碼已經執行完之後,這時就會去查看是否有微任務能夠執行,而後發現了PromiseMutationObserver兩個微任務,遂執行之。
由於click事件會冒泡,因此對應的此次I/O會觸發兩次handler函數(_一次在inner、一次在outer_),因此會優先執行冒泡的事件(_早於其餘的宏任務_),也就是說會重複上述的邏輯。
在執行完同步代碼與微任務之後,這時繼續向後查找有木有宏任務。
須要注意的一點是,由於咱們觸發了setAttribute,實際上修改了DOM的屬性,這會致使頁面的重繪,而這個set的操做是同步執行的,也就是說requestAnimationFrame的回調會早於setTimeout所執行。

一些小驚喜

使用上述的示例代碼,若是將手動點擊DOM元素的觸發方式變爲$inner.click(),那麼會獲得不同的結果。
Chrome下的輸出順序大體是這樣的:
click -> click -> promise -> observer -> promise -> animationFrame -> animationFrame -> timeout -> timeout

與咱們手動觸發click的執行順序不同的緣由是這樣的,由於並非用戶經過點擊元素實現的觸發事件,而是相似dispatchEvent這樣的方式,我我的以爲並不能算是一個有效的I/O,在執行了一次handler回調註冊了微任務、註冊了宏任務之後,實際上外邊的$inner.click()並無執行完。
因此在微任務執行以前,還要繼續冒泡執行下一次事件,也就是說觸發了第二次的handler
因此輸出了第二次click,等到這兩次handler都執行完畢後纔會去檢查有沒有微任務、有沒有宏任務。

兩點須要注意的:

  1. .click()的這種觸發事件的方式我的認爲是相似dispatchEvent,能夠理解爲同步執行的代碼
document.body.addEventListener('click', _ => console.log('click'))

document.body.click()
document.body.dispatchEvent(new Event('click'))
console.log('done')

// > click
// > click
// > done
  1. MutationObserver的監聽不會說同時觸發屢次,屢次修改只會有一次回調被觸發。
new MutationObserver(_ => {
  console.log('observer')
  // 若是在這輸出DOM的data-random屬性,必然是最後一次的值,不解釋了
}).observe(document.body, {
  attributes: true
})

document.body.setAttribute('data-random', Math.random())
document.body.setAttribute('data-random', Math.random())
document.body.setAttribute('data-random', Math.random())

// 只會輸出一次 ovserver

這就像去飯店點餐,服務員喊了三次,XX號的牛肉麪,不表明她會給你三碗牛肉麪。
上述觀點參閱自Tasks, microtasks, queues and schedules,文中有動畫版的講解

在Node中的表現

Node也是單線程,可是在處理Event Loop上與瀏覽器稍微有些不一樣,這裏是Node官方文檔的地址。

就單從API層面上來理解,Node新增了兩個方法能夠用來使用:微任務的process.nextTick以及宏任務的setImmediate

setImmediate與setTimeout的區別

在官方文檔中的定義,setImmediate爲一次Event Loop執行完畢後調用。
setTimeout則是經過計算一個延遲時間後進行執行。

可是同時還提到了若是在主進程中直接執行這兩個操做,很難保證哪一個會先觸發。
由於若是主進程中先註冊了兩個任務,而後執行的代碼耗時超過XXs,而這時定時器已經處於可執行回調的狀態了。
因此會先執行定時器,而執行完定時器之後纔是結束了一次Event Loop,這時纔會執行setImmediate

setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))

有興趣的能夠本身試驗一下,執行屢次真的會獲得不一樣的結果。
圖片描述

可是若是後續添加一些代碼之後,就能夠保證setTimeout必定會在setImmediate以前觸發了:

setTimeout(_ => console.log('setTimeout'))
setImmediate(_ => console.log('setImmediate'))

let countdown = 1e9

while(countdown--) { } // 咱們確保這個循環的執行速度會超過定時器的倒計時,致使這輪循環沒有結束時,setTimeout已經能夠執行回調了,因此會先執行`setTimeout`再結束這一輪循環,也就是說開始執行`setImmediate`

若是在另外一個宏任務中,必然是setImmediate先執行:

require('fs').readFile(__dirname, _ => {
  setTimeout(_ => console.log('timeout'))
  setImmediate(_ => console.log('immediate'))
})

// 若是使用一個設置了延遲的setTimeout也能夠實現相同的效果

process.nextTick

就像上邊說的,這個能夠認爲是一個相似於PromiseMutationObserver的微任務實現,在代碼執行的過程當中能夠隨時插入nextTick,而且會保證在下一個宏任務開始以前所執行。

在使用方面的一個最多見的例子就是一些事件綁定類的操做:

class Lib extends require('events').EventEmitter {
  constructor () {
    super()

    this.emit('init')
  }
}

const lib = new Lib()

lib.on('init', _ => {
  // 這裏將永遠不會執行
  console.log('init!')
})

由於上述的代碼在實例化Lib對象時是同步執行的,在實例化完成之後就立馬發送了init事件。
而這時在外層的主程序尚未開始執行到lib.on('init')監聽事件的這一步。
因此會致使發送事件時沒有回調,回調註冊後事件不會再次發送。

咱們能夠很輕鬆的使用process.nextTick來解決這個問題:

class Lib extends require('events').EventEmitter {
  constructor () {
    super()

    process.nextTick(_ => {
      this.emit('init')
    })

    // 同理使用其餘的微任務
    // 好比Promise.resolve().then(_ => this.emit('init'))
    // 也能夠實現相同的效果
  }
}

這樣會在主進程的代碼執行完畢後,程序空閒時觸發Event Loop流程查找有沒有微任務,而後再發送init事件。

關於有些文章中提到的,循環調用process.nextTick會致使報警,後續的代碼永遠不會被執行,這是對的,參見上邊使用的雙重循環實現的loop便可,至關於在每次for循環執行中都對數組進行了push操做,這樣循環永遠也不會結束

多提一嘴async/await函數

由於,async/await本質上仍是基於Promise的一些封裝,而Promise是屬於微任務的一種。因此在使用await關鍵字與Promise.then效果相似:

setTimeout(_ => console.log(4))

async function main() {
  console.log(1)
  await Promise.resolve()
  console.log(3)
}

main()

console.log(2)

async函數在await以前的代碼都是同步執行的,能夠理解爲await以前的代碼屬於new Promise時傳入的代碼,await以後的全部代碼都是在Promise.then中的回調

小節

JavaScript的代碼運行機制在網上有好多文章都寫,本人道行太淺,只能簡單的說一下本身對其的理解。
並無去生摳文檔,一步一步的列出來,像什麼查看當前棧、執行選中的任務隊列,各類balabala。
感受對實際寫代碼沒有太大幫助,不如簡單的入個門,掃個盲,大體瞭解一下這是個什麼東西就行了。

推薦幾篇參閱的文章:

One more things

Blued前端/Node團隊招人。。初中高都有HC
座標帝都朝陽雙井,有興趣的請聯繫我:
wechat: github_jiasm
mail: jiashunming@blued.com

歡迎砸簡歷

相關文章
相關標籤/搜索