很多人都不知道宏任務和微任務是什么?為什么微任務要比宏任務先執行?
先來一道常見的面試題:
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()
當開始執行 JS 代碼時,首先會執行一個 main 函數,然后執行我們的代碼。根據先進后出的原則,后執行的函數會先彈出棧,在圖中我們也可以發現,foo 函數后執行,當執行完畢后就從棧中彈出了。
-
Event Loop
JS到底是怎么運行的呢?
event-loop.png
JS引擎常駐于內存中,等待宿主將JS代碼或函數傳遞給它。
也就是等待宿主環境分配宏觀任務,反復等待 - 執行即為事件循環。
Event Loop中,每一次循環稱為tick,每一次tick的任務如下:
- 執行棧選擇最先進入隊列的宏任務(一般都是script),執行其同步代碼直至結束;
- 檢查是否存在微任務,有則會執行至微任務隊列為空;
- 如果宿主為瀏覽器,可能會渲染頁面;
- 開始下一輪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不太一樣。
執行順序如下:
- timers: 執行setTimeout和setInterval的回調
- pending callbacks: 執行延遲到下一個循環迭代的 I/O 回調
- idle, prepare: 僅系統內部使用
- poll: 檢索新的 I/O 事件;執行與 I/O 相關的回調。事實上除了其他幾個階段處理的事情,其他幾乎所有的異步都在這個階段處理。
- check: setImmediate在這里執行
- close callbacks: 一些關閉的回調函數,如:socket.on('close', ...)
一般來說,setImmediate會在setTimeout之前執行,如下:
console.log('outer');
setTimeout(() => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
}, 0);
其執行順序為:
- 外層是一個setTimeout,所以執行它的回調的時候已經在timers階段了
- 處理里面的setTimeout,因為本次循環的timers正在執行,所以其回調其實加到了下個timers階段
- 處理里面的setImmediate,將它的回調加入check階段的隊列
- 外層timers階段執行完,進入pending callbacks,idle, prepare,poll,這幾個隊列都是空的,所以繼續往下
- 到了check階段,發現了setImmediate的回調,拿出來執行
- 然后是close callbacks,隊列是空的,跳過
- 又是timers階段,執行console.log('setTimeout')
- 但是,如果當前執行環境不是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階段
- 遇到setImmediate塞入check階段
- 同步代碼執行完畢,進入Event Loop
- 先進入times階段,檢查當前時間過去了1毫秒沒有,如果過了1毫秒,滿足setTimeout條件,執行回調,如果沒過1毫秒,跳過
- 跳過空的階段,進入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版本之后,提供了強制使用宏任務的方法。
- 優先檢測是否支持原生 setImmediate(這是一個高版本 IE 和 Edge 才支持的特性)
- 如果不支持,再去檢測是否支持原生的MessageChannel
- 如果也不支持的話就會降級為 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')
經過上面的分析,相信大家很容易理解此題。