先來看這樣一道面試題:
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(() => {
console.log('setTimeout')
}, 0);
async1()
new Promise(resolve => {
console.log('promise1')
resolve()
})
.then(() => {
console.log('promise2')
})
console.log('script end')

這道題主要考察的是事件循環中函數執行順序的問題,其中包括async ,await,setTimeout,Promise函數。下面來說一下本題中涉及到的知識點。
任務隊列
首先我們需要明白以下幾件事情:
·JS分為同步任務和異步任務
·同步任務都在主線程上執行,形成一個執行棧
·主線程之外,事件觸發線程管理着一個任務隊列,只要異步任務有了運行結果,就在任務隊列之中放置一個事件。
·一旦執行棧中的所有同步任務執行完畢(此時JS引擎空閑),系統就會讀取任務隊列,將可運行的異步任務添加到可執行棧中,開始執行。
根據規范,事件循環是通過任務隊列的機制來進行協調的。一個 Event Loop 中,可以有一個或者多個任務隊列(task queue),一個任務隊列便是一系列有序任務(task)的集合;每個任務都有一個任務源(task source),源自同一個任務源的 task 必須放到同一個任務隊列,從不同源來的則被添加到不同隊列。setTimeout/Promise 等API便是任務源,而進入任務隊列的是他們指定的具體執行任務。

宏任務
(macro)task(又稱之為宏任務),可以理解是每次執行棧執行的代碼就是一個宏任務(包括每次從事件隊列中獲取一個事件回調並放到執行棧中執行)。
瀏覽器為了能夠使得JS內部(macro)task與DOM任務能夠有序的執行,會在一個(macro)task執行結束后,在下一個(macro)task 執行開始前,對頁面進行重新渲染,流程如下:
(macro)task->渲染->(macro)task->...
(macro)task主要包含:
1.script(整體代碼)
2.setTimeout
3.setInterval
4.I/O
5.UI交互事件
6.postMessage
7.MessageChannel
8.setImmediate(Node.js 環境)
微任務
microtask(又稱為微任務),可以理解是在當前 task 執行結束后立即執行的任務。也就是說,在當前task任務后,下一個task之前,在渲染之前。
所以它的響應速度相比setTimeout(setTimeout是task)會更快,因為無需等渲染。也就是說,在某一個macrotask執行完后,就會將在它執行期間產生的所有microtask 都 執行完畢(在渲染前)。
microtask主要包含:
1. Promise.then
2. MutaionObserver
3. process.nextTick(Node.js 環境)
運行機制
在事件循環中,每進行一次循環操作稱為 tick,每一次 tick 的任務處理模型是比較復雜的,但關鍵步驟如下:
·執行一個宏任務(棧中沒有就從事件隊列中獲取)
·執行過程中如果遇到微任務,就將它添加到微任務的任務隊列中
·宏任務執行完畢后,立即執行當前微任務隊列中的**所有微任務**(依次執行)
·當前宏任務執行完畢,開始檢查渲染,然后GUI線程接管渲染
·渲染完畢后,JS線程繼續接管,開始下一個宏任務(從事件隊列中獲取)
流程圖如下:

Promise和async中的立即執行
我們知道Promise中的異步體現在then和catch中,所以寫在Promise中的代碼是被當做同步任務立即執行的。而在async/await中,在出現await出現之前,其中的代碼也是立即執行的。那么出現了await時候發生了什么呢?
await做了什么
從字面意思上看await就是等待,await 等待的是一個表達式,這個表達式的返回值可以是一個promise對象也可以是其他值。
很多人以為await會一直等待之后的表達式執行完之后才會繼續執行后面的代碼,實際上await是一個讓出線程的標志。await后面的表達式會先執行一遍,將await后面的代碼加入到microtask中,然后就會跳出整個async函數來執行后面的代碼。
由於因為async await 本身就是promise+generator的語法糖。所以await后面的代碼是microtask。所以對於本題中的
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
和
async function async1() {
console.log('async1 start')
Promise.resolve(async2()).then(() => {
console.log('async1 end')
})
}
以上就本道題涉及到的所有相關知識點了,下面我們再回到這道題來一步一步看看怎么回事兒。
-
首先,事件循環從宏任務(macrotask)隊列開始,這個時候,宏任務隊列中,只有一個script(整體代碼)任務;當遇到任務源(task source)時,則會先分發任務到對應的任務隊列中去。所以,上面例子的第一步執行如下圖所示:

-
然后我們看到首先定義了兩個async函數,接着往下看,然后遇到了 console 語句,直接輸出 script start。輸出之后,script 任務繼續往下執行,遇到 setTimeout,其作為一個宏任務源,則會先將其任務分發到對應的隊列中:

-
script 任務繼續往下執行,執行了async1()函數,前面講過async函數中在await之前的代碼是立即執行的,所以會立即輸出async1 start。
遇到了await時,會將await后面的表達式執行一遍,所以就緊接着輸出async2,然后將await后面的代碼也就是console.log('async1 end')加入到microtask中的Promise隊列中,接着跳出async1函數來執行后面的代碼。

-
script任務繼續往下執行,遇到Promise實例。由於Promise中的函數是立即執行的,而后續的 .then 則會被分發到 microtask 的 Promise 隊列中去。所以會先輸出 promise1,然后執行 resolve,將 promise2 分配到對應隊列。

-
script任務繼續往下執行,最后只有一句輸出了 script end,至此,全局任務就執行完畢了。
根據上述,每次執行完一個宏任務之后,會去檢查是否存在 Microtasks;如果有,則執行 Microtasks 直至清空 Microtask Queue。
因而在script任務執行完畢之后,開始查找清空微任務隊列。此時,微任務中, Promise 隊列有的兩個任務async1 end和promise2,因此按先后順序輸出 async1 end,promise2。當所有的 Microtasks 執行完畢之后,表示第一輪的循環就結束了。
-
第二輪循環依舊從宏任務隊列開始。此時宏任務中只有一個 setTimeout,取出直接輸出即可,至此整個流程結束。
變式一
在第一個變式中我將async2中的函數也變成了Promise函數,代碼如下:
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
new Promise(resolve => {
console.log('promise1')
resolve()
})
.then(() => {
console.log('promise2')
})
}
console.log('script start')
setTimeout(() => {
console.log('setTimeout')
}, 0);
async1()
new Promise(resolve => {
console.log('promise3')
resolve()
})
.then(() => {
console.log('promise4')
})
console.log('script end')
運行結果入下:

在第一次macrotask執行完之后,也就是輸出script end之后,會去清理所有microtask。所以會相繼輸出promise2, async1 end ,promise4,其余不再多說。
變式二
在第二個變式中,我將async1中await后面的代碼和async2的代碼都改為異步的,代碼如下:
async function async1() {
console.log('async1 start')
await async2()
setTimeout(() => {
console.log('setTimeout1')
}, 0)
}
async function async2() {
setTimeout(() => {
console.log('setTimeout2')
}, 0)
}
console.log('script start')
setTimeout(() => {
console.log('setTimeout3')
}, 0);
async1()
new Promise(resolve => {
console.log('promise1')
resolve()
})
.then(() => {
console.log('promise2')
})
console.log('script end')
執行結果如下:

在輸出為promise2之后,接下來會按照加入setTimeout隊列的順序來依次輸出,通過代碼我們可以看到加入順序為3 2 1,所以會按3,2,1的順序來輸出。
只要前面的原理看懂了,任何的變式題都不會有問題。
