2019年了,相信你們對 Promise 和 async/await 都再也不陌生了。javascript
前幾日,我在社區讀到了一篇關於 async/await 執行順序的文章《「前端面試題系列1」今日頭條 面試題和思路解析》。文中提到了一道「2017年「今日頭條」的前端面試題」,還有另外一篇對此題的解析文章《8張圖讓你一步步看清 async/await 和 promise 的執行順序》,兩文中都對問題進行了分析。不過在我看來,這兩篇文章都沒有把這個問題說清楚,同時在評論區中也有不少朋友留言表達了本身的疑惑。前端
其實解決這個問題最關鍵的是如下兩點:java
Promise.resolve(v)
不等於new Promise(resolve => resolve(v))
- 瀏覽器怎樣處理
new Promise(resolve => resolve(thenable))
,即在 Promise 中 resolve 一個 thenable 對象
國際慣例,先給出面試題和答案:node
注:執行順序以 Chrome71 爲準git
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(function () {
console.log('setTimeout')
}, 0)
async1();
new Promise(function (resolve) {
console.log('promise1')
resolve()
}).then(function () {
console.log('promise2')
})
console.log('script end')
複製代碼
答案:github
script start
async1 start
async2
promise1
script end
promise2
async1 end
setTimeout
複製代碼
看完答案後,我與不少人同樣不管如何也不理解 爲何 async1 end
會晚於promise2
輸出……個人第一反應是 我對 await 的理解有誤差,因此我決心要把這個問題弄明白。面試
本文主要解釋瀏覽器對 await 的處理,**並一步步將原題代碼轉換爲原生Promsie實現。segmentfault
全部執行順序以 Chrome71 爲準,不討論 Babel 和 Promise 墊片。promise
第一次發文,不免有一些不嚴謹之處,若有錯誤,還望你們在評論區批評指正!瀏覽器
在解釋答案以前,你須要先掌握:
問題主要涉及如下4點:
new Promise(resolve => resolve(thenable))
的處理下面,讓咱們一步步將原題中的代碼轉換爲更容易理解的等價代碼。
在正式開始以前,咱們先來看如下這段代碼:
new Promise((r) => {
r();
})
.then(() => console.log(1))
.then(() => console.log(2))
.then(() => console.log(3))
new Promise((r) => {
r();
})
.then(() => console.log(4))
.then(() => console.log(5))
.then(() => console.log(6))
複製代碼
答案:
1
4
2
5
3
6
複製代碼
若是你得出的答案是 1 2 3 4 5 6
那說明你尚未很好的理解 Promise.prototype.then()
。
爲何要先放出這段代碼?
由於 async/await
可視爲 Promise 的語法糖,一樣基於微任務實現;本題主要糾結的點在於 await 到底作了什麼致使 async1 end
晚於 promise2
輸出。問題的關鍵在於其執行過程當中的微任務數量,下文中咱們須要用上述代碼中的方式對微任務的執行順序進行標記,以輔助咱們理解這其中的執行過程。
then()
鏈式調用,並非連續的建立了多個微任務並推入微任務隊列,由於 then()
的返回值必然是一個 Promise,然後續的 then()
是上一步 then()
返回的 Promise 的回調resolve()
,將 Promise 的狀態改變爲 <resolved>: undefined
, 而後 then 中傳入的回調函數 console.log('1')
做爲一個微任務被推入微任務隊列then()
中傳入的回調函數 console.log('2')
此時尚未被推入微任務隊列,只有上一個 then()
中的 console.log('1')
執行完畢後,console.log('2')
纔會被推入微任務隊列Promise.prototype.then()
會隱式返回一個新 Promisethen
會在該 Promise 上註冊一個回調,當其狀態發生變化時,對應的回調將做爲一個微任務被推入微任務隊列then()
會當即建立一個微任務,將傳入的對應的回調推入微任務隊列爲了更好的解析問題,下面我對原題代碼進行一些修改,剔除和主要問題無關的代碼
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
async1();
new Promise((resolve) => {
console.log(1)
resolve()
}).then(() => {
console.log(2)
}).then(() => {
console.log(3)
}).then(() => {
console.log(4)
})
複製代碼
答案:
async1 start
async2
1
2
3
async1 end
4
複製代碼
咱們剔除了 setTimeout
和一些同步代碼,而後爲 Promise
的 then
鏈增長了一個回調,而最終結果中 async1 end 在 3 後輸出,而不是在 2 後!
await
必定是作了一些咱們不理解的「詭異操做」,令其後續代碼 console.log('async1 end')
被推遲了2個時序。
換句話說,async/await
是 Promise 的語法糖,一樣基於微任務實現,不可能有其餘超出咱們理解的東西,因此能夠判定:在 console.log('async1 end')
執行前,額外執行了2個微任務,因此致使被推遲2個時序!
若是你沒法理解上面這段話,不要緊,請繼續向下看。
下面解釋 async 關鍵字作了什麼:
下面以原題中的函數 async2
爲例,做等價轉換
function async2(){
console.log('async2');
return Promise.resolve();
}
複製代碼
這裏須要引入 TC39 規範:
規範晦澀難懂,咱們能夠看看這篇文章:《「譯」更快的 async 函數和 promises》,下面引入其中的一些描述:
簡單說,await v 初始化步驟有如下組成:
- 把 v 轉成一個 promise(跟在 await 後面的)。
- 綁定處理函數用於後期恢復。
- 暫停 async 函數並返回 implicit_promise 給調用者。
咱們一步步來看,假設 await 後是一個 promise,且最終已完成狀態的值是 42。而後,引擎會建立一個新的 promise 而且把 await 後的值做爲 resolve 的值。藉助標準裏的 PromiseResolveThenableJob 這些 promise 會被放到下個週期執行。
結合規範和這篇文章,簡單總結一下,對於 await v
:
fulfilled
的 Promise,仍是會新建一個 Promise,並在這個新 Promise 中 resolve(v)
await v
後續的代碼的執行相似於傳入 then()
中的回調如此,可進一步對原題中的 async1
做等價轉換
function async1(){
console.log('async1 start')
return new Promise(resolve => resolve(async2()))
.then(() => {
console.log('async1 end')
});
}
複製代碼
至此,咱們根據規範綜合以上全部等價轉換,將 async/await
所有轉換爲原生 Promise 實現,其執行順序在 Chrome71 上與一開始給出的 <轉換1> 徹底一致:
function async1(){
console.log('async1 start')
return new Promise(resolve => resolve(async2()))
.then(() => {
console.log('async1 end')
});
}
function async2(){
console.log('async2');
return Promise.resolve();
}
async1();
new Promise((resolve) => {
console.log(1)
resolve()
}).then(() => {
console.log(2)
}).then(() => {
console.log(3)
}).then(() => {
console.log(4)
})
複製代碼
到了這,你是否是感受整個思路變清晰了?不過,仍是不能很好的解釋 爲何 console.log('async1 end')
在3後面輸出,下面將說明其中的緣由。
new Promise(resolve => resolve(thenable))
的處理仔細觀察 <轉換4> 中的 async1
函數,不難發現 return new Promise(resolve => resolve(async2()))
中,Promise resolve 的是 async2()
,而 async2()
返回了一個狀態爲 <resolved>: undefined
的 Promsie,Promise 是一個 thenable 對象。
對於 thenable 對象,《ECMAScript 6 入門》中這樣描述:
thenable 對象指的是具備then方法的對象,好比下面這個對象
let thenable = { then: function(resolve, reject) { resolve(42); } }; 複製代碼
下面須要引入 TC39 規範中對 Promise Resolve Functions 的描述:
o
,若是 o.then
是一個 function
,那麼 o
就能夠被稱爲 thenable
對象new Promise(resolve => resolve(thenable))
,即「在 Promise 中 resolve 一個 thenable 對象」,須要先將 thenable 轉化爲 Promsie,而後當即調用 thenable 的 then 方法,而且 這個過程須要做爲一個 job 加入微任務隊列,以保證對 then 方法的解析發生在其餘上下文代碼的解析以後下面給出示例:
let thenable = {
then(resolve, reject) {
console.log('in thenable');
resolve(100);
}
};
new Promise((r) => {
console.log('in p0');
r(thenable);
})
.then(() => { console.log('thenable ok') })
new Promise((r) => {
console.log('in p1');
r();
})
.then(() => { console.log('1') })
.then(() => { console.log('2') })
.then(() => { console.log('3') })
.then(() => { console.log('4') });
複製代碼
執行順序:
in p0
in p1
in thenable
1
thenable ok
2
3
4
複製代碼
in thenable
後於 in p1
而先於 1
輸出,同時 thenable ok
在 1
後輸出console.log('1')
thenable.then()
,從而註冊了另外一個微任務:console.log('thenable ok')
thenable
的處理須要在一個微任務中完成,從而致使了第一個 Promise 的後續回調被延後了1個時序Promise.prototype.then
,而這時 Promise 若是已是 resolved 狀態 ,then 的執行會再一次建立了一個微任務最終結果就是:額外建立了兩個Job,表現上就是後續代碼被推遲了2個時序
上面圍繞規範說了那麼多,不知你有沒有理解這其中的執行過程。規範是晦澀難懂的,下面咱們結合規範繼續對代碼做「轉換」,讓這個過程變得更容易理解一些
對於代碼
new Promise((resolve) => {
resolve(thenable)
})
複製代碼
在執行順序上等價於(我只敢說「在執行順序上等價」,由於瀏覽器的內部實現沒法簡單的模擬):
new Promise((resolve) => {
Promise.resolve().then(() => {
thenable.then(resolve)
})
})
複製代碼
因此,原題中的 new Promise(resolve => resolve(async2()))
,在執行順序上等價於:
new Promise((resolve) => {
Promise.resolve().then(() => {
async2().then(resolve)
})
})
複製代碼
綜上,給出最終轉換:
function async1(){
console.log('async1 start');
const p = async2();
return new Promise((resolve) => {
Promise.resolve().then(() => {
p.then(resolve)
})
})
.then(() => {
console.log('async1 end')
});
}
function async2(){
console.log('async2');
return Promise.resolve();
}
async1();
new Promise((resolve) => {
console.log(1)
resolve()
}).then(() => {
console.log(2)
}).then(() => {
console.log(3)
}).then(() => {
console.log(4)
})
複製代碼
OK, 看到這裏,你應該理解了爲何在 Chrome71 中 async1 end 在 3 後輸出了。
不過這還沒完呢,認真的你可能已經發現,這裏給出的執行順序在 Chrome73 上不對啊。沒錯,這是由於 Await 規範更新了……
若是你在 Chrome73 中運行這道題的代碼,你會發現,執行順序與 Chrome71 中不一樣,這又是爲何?
我來簡單說說這個事情的過程:
在 Chrome71 以前的某個版本,nodejs 中有個 bug,這個 bug 的表現就是對 await 進行了激進優化,所謂激進優化,就是沒有按照 TC39 規範的要求執行。V8 團隊修復了這個 bug。不過,從這個 bug 中 V8 團隊獲得了啓發,發現這個 bug 中的激進優化居然能夠帶來性能提高,因此向 TC39 提交了改進方案,並會在下個版本中執行這個優化……
上文中提到的譯文《「譯」更快的 async 函數和 promises》,說的就是這個優化的由來。
文章中的「激進優化」,是指 await v
在語義上將等價於 Promise.resolve(v)
,而再也不是如今的 new Promise(resolve => resolve(v))
,因此在將來的 Chrome73 中,題中的代碼可作以下等價轉換:
function async1(){
console.log('async1 start');
const p = async2();
return Promise.resolve(p)
.then(() => {
console.log('async1 end')
});
}
function async2(){
console.log('async2');
return Promise.resolve();
}
async1();
new Promise((resolve) => {
console.log(1)
resolve()
}).then(() => {
console.log(2)
}).then(() => {
console.log(3)
}).then(() => {
console.log(4)
})
複製代碼
執行順序:
async1 start
async2
1
async1 end
2
3
4
複製代碼
有沒有以爲優化後的版本更容易理解了呢?
Promise.resolve(v)
不等於 new Promise(r => r(v))
,由於若是 v 是一個 Promise 對象,前者會直接返回 v,然後者須要通過一系列的處理(主要是 PromiseResolveThenableJob)setTimeout
所建立的宏任務可視爲 第二個宏任務,第一個宏任務是這段程序自己本文從一道你們都熟悉的面試題出發,綜合了 TC39 規範和《「譯」更快的 async 函數和 promises》這篇文章對瀏覽器中的 async/await 的執行過程進行了分析,並給出了基於原生 Promise 實現的等價代碼。同時,引出了即將進行的性能優化,並簡單介紹了該優化的由來。
我要感謝在 SF 社區中與我一同追尋答案的 @xianshenglu,以上所有分析過程的詳細討論在這裏:async await 和 promise微任務執行順序問題
我在偶然中看到了這個問題,因爲答案使人難以理解,因此我決定搞個明白,而後便一發不可收拾……
你可能會以爲這種在工做中根本不會遇到的代碼不必費這麼大力氣去分析,但經過以上的學習過程我仍是收穫了一些知識的,這顛覆了我以前對 async/await
的理解
不得不說,遇到這種問題,仍是得看規範才能搞明白啊……