完全理解【宏任務和微任務】

很多人都不知道宏任務和微任務是什么?為什么微任務要比宏任務先執行?

先來一道常見的面試題:

console.log('start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

new Promise((resolve) => {
  console.log('promise')
  resolve()
})
  .then(() => {
    console.log('then1')
  })
  .then(() => {
    console.log('then2')
  })

console.log('end')

應該不少同學都能答出來,結果為:

start 
promise
end
then1
then2
setTimeout

這個就涉及到JavaScript事件輪詢中的宏任務和微任務。那么,你能說清楚到底宏任務和微任務是什么?是誰發起的?為什么微任務的執行要先于宏任務呢?

首先,我們需要先知道JS運行機制。

JS運行機制

  • JS是單線程執行
    ”JS是單線程的”指的是JS 引擎線程。
  • 宿主環境
    JS運行的環境。一般為瀏覽器或者Node。
  • 執行棧
    是一個存儲函數調用的棧結構,遵循先進后出的原則。
function foo() {
  throw new Error('error')
}
function bar() {
  foo()
}
bar()
image.png

當開始執行 JS 代碼時,首先會執行一個 main 函數,然后執行我們的代碼。根據先進后出的原則,后執行的函數會先彈出棧,在圖中我們也可以發現,foo 函數后執行,當執行完畢后就從棧中彈出了。

  • Event Loop
    JS到底是怎么運行的呢?
    event-loop.png

JS引擎常駐于內存中,等待宿主將JS代碼或函數傳遞給它。
也就是等待宿主環境分配宏觀任務,反復等待 - 執行即為事件循環。

Event Loop中,每一次循環稱為tick,每一次tick的任務如下:

  1. 執行棧選擇最先進入隊列的宏任務(一般都是script),執行其同步代碼直至結束;
  2. 檢查是否存在微任務,有則會執行至微任務隊列為空;
  3. 如果宿主為瀏覽器,可能會渲染頁面;
  4. 開始下一輪tick,執行宏任務中的異步代碼(setTimeout等回調)。

宏任務和微任務

ES6 規范中,microtask 稱為 jobs,macrotask 稱為 task
宏任務是由宿主發起的,而微任務由JavaScript自身發起。

在ES3以及以前的版本中,JavaScript本身沒有發起異步請求的能力,也就沒有微任務的存在。在ES5之后,JavaScript引入了Promise,這樣,不需要瀏覽器,JavaScript引擎自身也能夠發起異步任務了。

所以,總結一下,兩者區別為:

宏任務(macrotask) 微任務(microtask)
誰發起的 宿主(Node、瀏覽器) JS引擎
具體事件 1. script (可以理解為外層同步代碼)2. setTimeout/setInterval3. UI rendering/UI事件4. postMessage,MessageChannel5. setImmediate,I/O(Node.js) 1. Promise2. MutaionObserver3. Object.observe(已廢棄;Proxy 對象替代)4. process.nextTick(Node.js)
誰先運行 后運行 先運行
會觸發新一輪Tick嗎 不會

問題1:async和await是如何處理異步任務的?
簡單說,async是通過Promise包裝異步任務。
比如有如下代碼:

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

改為ES5的寫法:

new Promise((resolve, reject) => {
  // console.log('async2 end')
  async2() 
  ...
}).then(() => {
 // 執行async1()函數await之后的語句
  console.log('async1 end')
})

當調用 async1 函數時,會馬上輸出 async2 end,并且函數返回一個 Promise,接下來在遇到 await的時候會就讓出線程開始執行 async1 外的代碼(可以把await 看成是讓出線程的標志)。
然后當同步代碼全部執行完畢以后,就會去執行所有的異步代碼,那么又會回到 await 的位置,去執行 then 中的回調。

問題 2:setTimeout,setImmediate誰先執行?
setImmediate和process.nextTick為Node環境下常用的方法(IE11支持setImmediate),所以,后續的分析都基于Node宿主。

Node.js是運行在服務端的js,雖然用到也是V8引擎,但由于服務目的和環境不同,導致了它的API與原生JS有些區別,其Event Loop還要處理一些I/O,比如新的網絡連接等,所以與瀏覽器Event Loop不太一樣。

執行順序如下:

  1. timers: 執行setTimeout和setInterval的回調
  2. pending callbacks: 執行延遲到下一個循環迭代的 I/O 回調
  3. idle, prepare: 僅系統內部使用
  4. poll: 檢索新的 I/O 事件;執行與 I/O 相關的回調。事實上除了其他幾個階段處理的事情,其他幾乎所有的異步都在這個階段處理。
  5. check: setImmediate在這里執行
  6. close callbacks: 一些關閉的回調函數,如:socket.on('close', ...)

一般來說,setImmediate會在setTimeout之前執行,如下:

console.log('outer');
setTimeout(() => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);
  setImmediate(() => {
    console.log('setImmediate');
  });
}, 0);

其執行順序為:

  1. 外層是一個setTimeout,所以執行它的回調的時候已經在timers階段了
  2. 處理里面的setTimeout,因為本次循環的timers正在執行,所以其回調其實加到了下個timers階段
  3. 處理里面的setImmediate,將它的回調加入check階段的隊列
  4. 外層timers階段執行完,進入pending callbacks,idle, prepare,poll,這幾個隊列都是空的,所以繼續往下
  5. 到了check階段,發現了setImmediate的回調,拿出來執行
  6. 然后是close callbacks,隊列是空的,跳過
  7. 又是timers階段,執行console.log('setTimeout')
  8. 但是,如果當前執行環境不是timers階段,就不一定了。。。。順便科普一下Node里面對setTimeout的特殊處理:setTimeout(fn, 0)會被強制改為setTimeout(fn, 1)。

看看下面的例子:

setTimeout(() => {
  console.log('setTimeout');
}, 0);

setImmediate(() => {
  console.log('setImmediate');
});

其執行順序為:

1 .遇到setTimeout,雖然設置的是0毫秒觸發,但是被node.js強制改為1毫秒,塞入times階段

  1. 遇到setImmediate塞入check階段
  2. 同步代碼執行完畢,進入Event Loop
  3. 先進入times階段,檢查當前時間過去了1毫秒沒有,如果過了1毫秒,滿足setTimeout條件,執行回調,如果沒過1毫秒,跳過
  4. 跳過空的階段,進入check階段,執行setImmediate回調

可見,1毫秒是個關鍵點,所以在上面的例子中,setImmediate不一定在setTimeout之前執行了。

問題 3:Promise,process.nextTick誰先執行?
因為process.nextTick為Node環境下的方法,所以后續的分析依舊基于Node。

process.nextTick() 是一個特殊的異步API,其不屬于任何的Event Loop階段。事實上Node在遇到這個API時,Event Loop根本就不會繼續進行,會馬上停下來執行process.nextTick(),這個執行完后才會繼續Event Loop。

所以,nextTick和Promise同時出現時,肯定是nextTick先執行,原因是nextTick的隊列比Promise隊列優先級更高。

問題 4:應用場景 - Vue中的vm.nextTick
vm.$nextTick 接受一個回調函數作為參數,用于將回調延遲到下次DOM更新周期之后執行。

這個API就是基于事件循環實現的。
“下次DOM更新周期”的意思就是下次微任務執行時更新DOM,而vm.$nextTick就是將回調函數添加到微任務中(在特殊情況下會降級為宏任務)。

因為微任務優先級太高,Vue 2.4版本之后,提供了強制使用宏任務的方法。

  1. 優先檢測是否支持原生 setImmediate(這是一個高版本 IE 和 Edge 才支持的特性)
  2. 如果不支持,再去檢測是否支持原生的MessageChannel
  3. 如果也不支持的話就會降級為 setTimeout。

vm.$nextTick優先使用Promise,創建微任務。
如果不支持Promise或者強制開啟宏任務,那么,會按照如下順序發起宏任務:

下面是道加強版的考題,大家可以試一試。

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')

經過上面的分析,相信大家很容易理解此題。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容