本文转载自掘金的文章
- TC39关于
await
和PromiseResolveThenableJob
的原文规范,我手动去除了,太晦涩难懂了 - 我贴上了另一道很有意思的面试题
- 原文地址:https://juejin.im/post/5c3cc981f265da616a47e028
涉及到的点
- await操作符都做了什么
- 宏任务和微任务的概念
- eventloop
- Promise.then 到底做了什么
——————–分割线,以下为文章原文—————-
起源
2019年了,相信大家对 Promise 和 async/await 都不再陌生了。
前几日,我在社区读到了一篇关于 async/await 执行顺序的文章《「前端面试题系列1」今日头条 面试题和思路解析》。文中提到了一道“2017年「今日头条」的前端面试题”,还有另一篇对此题的解析文章《8张图让你一步步看清 async/await 和 promise 的执行顺序》,两文中都对问题进行了分析。不过在我看来,这两篇文章都没有把这个问题说清楚,同时在评论区中也有很多朋友留言表达了自己的疑惑。
其实解决这个问题最关键的是以下两点:
Promise.resolve(v)
不等于new Promise(resolve => resolve(v))
- 浏览器怎样处理
new Promise(resolve => resolve(thenable))
,即在 Promise 中 resolve 一个thenable
对象
面试题
国际惯例,先给出面试题和答案:
注:执行顺序以 Chrome71 为准
1 | async function async1() { |
答案:1
2
3
4
5
6
7
8script start
async1 start
async2
promise1
script end
promise2
async1 end
setTimeout
看完答案后,我与很多人一样无论如何也不理解 为什么 async1 end 会晚于promise2 输出……我的第一反应是 我对 await 的理解有偏差,所以我决心要把这个问题弄明白。
本文主要解释浏览器对 await 的处理,**并一步步将原题代码转换为原生Promsie实现。
所有执行顺序以 Chrome71 为准,不讨论 Babel 和 Promise 垫片。
基础
在解释答案之前,你需要先掌握:
- Promise 基础
- Promise 执行器中的代码会被同步调用
- Promise 回调是基于微任务的
- 浏览器 eventloop
- 宏任务与微任务的优先级
- 宏任务的优先级高于微任务
- 每一个宏任务执行完毕都必须将当前的微任务队列清空
- 第一个 script 标签的代码是第一个宏任务
主要内容
问题主要涉及以下4点:
- Promise 的链式 then() 是怎样执行的
- async 函数的返回值
- await 做了什么
- PromiseResolveThenableJob:浏览器对
new Promise(resolve => resolve(thenable))
的处理
下面,让我们一步步将原题中的代码转换为更容易理解的等价代码。
Promise 的链式 then() 是怎样执行的
在正式开始之前,我们先来看以下这段代码:1
2
3
4
5
6
7
8
9
10
11
12
13new 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
2
3
4
5
61
4
2
5
3
6
如果你得出的答案是 1 2 3 4 5 6 那说明你还没有很好的理解Promise.prototype.then()
。
为什么要先放出这段代码?
因为 async/await
可视为 Promise
的语法糖,同样基于微任务实现;本题主要纠结的点在于 await
到底做了什么导致 async1 end
晚于 promise2
输出。问题的关键在于其执行过程中的微任务数量,下文中我们需要用上述代码中的方式对微任务的执行顺序进行标记,以辅助我们理解这其中的执行过程。
分析
Promise
多个then()
链式调用,并不是连续的创建了多个微任务并推入微任务队列,因为then()
的返回值必然是一个Promise,而后续的
then()是上一步
then()返回的
Promise` 的回调- 传入
Promise
构造器的执行器函数内部的同步代码执行到resolve()
,将Promise
的状态改变为<resolved>: undefined
, 然后then
中传入的回调函数console.log('1')
作为一个微任务被推入微任务队列 - 第二个
then()
中传入的回调函数console.log('2')
此时还没有被推入微任务队列,只有上一个then()
中的console.log('1')
执行完毕后,console.log('2')
才会被推入微任务队列
总结
Promise.prototype.then()
会隐式返回一个新Promise
- 如果
Promise
的状态是pending
,那么then
会在该Promise
上注册一个回调,当其状态发生变化时,对应的回调将作为一个微任务被推入微任务队列 - 如果
Promise
的状态已经是fulfilled
或rejected
,那么then()
会立即创建一个微任务,将传入的对应的回调推入微任务队列
为了更好的解析问题,下面我对原题代码进行一些修改,剔除和主要问题无关的代码<转换1>:
1 | async function async1() { |
答案:1
2
3
4
5
6
7async1 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 函数的返回值
下面解释 async 关键字做了什么:
- 被
async
操作符修饰的函数必然返回一个Promise
- 当
async
函数返回一个值时,Promise
的resolve
方法负责传递这个值 - 当
async
函数抛出异常时,Promise
的reject
方法会传递这个异常值
下面以原题中的函数async2
为例,作等价转换<转换2>:
1
2
3
4function async2(){
console.log('async2');
return Promise.resolve();
}
await 操作符做了什么
await
后的值v
会被转换为Promise
- 即使
v
是一个已经fulfilled
的Promise
,还是会新建一个Promise
,并在这个新Promise
中resolve(v)
await v
后续的代码的执行类似于传入then()
中的回调
如此,可进一步对原题中的async1
作等价转换<转换3>:
1
2
3
4
5
6
7function async1(){
console.log('async1 start')
return new Promise(resolve => resolve(async2()))
.then(() => {
console.log('async1 end')
});
}
至此,我们根据规范综合以上所有等价转换,将 async/await
全部转换为原生 Promise
实现,其执行顺序在 Chrome71 上与一开始给出的<转换1>
完全一致:
<转换4>:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25function 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后面输出,下面将说明其中的原因。
PromiseResolveThenableJob:浏览器对 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 入门》中这样描述:1
2
3
4
5let thenable = {
then: function(resolve, reject) {
resolve(42);
}
};
总结:
- 对于一个对象
o
,如果o.then
是一个function
,那么o
就可以被称为thenable
对象 - 对于
new Promise(resolve => resolve(thenable))
,即“在Promise
中resolve
一个thenable
对象”,需要先将thenable
转化为Promsie
,然后立即调用thenable
的then
方法,并且 这个过程需要作为一个job
加入微任务队列,以保证对then
方法的解析发生在其他上下文代码的解析之后
下面给出示例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21let 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') });
执行顺序:1
2
3
4
5
6
7
8in p0
in p1
in thenable
1
thenable ok
2
3
4
解析
in thenable
后于in p1
而先于1
输出,同时thenable ok
在1
后输出- 在执行完同步任务后,微任务队列中只有
2
个微任务:第一个是 转换thenable
为Promise
的过程,即PromiseResolveThenableJob
,第二个是console.log('1')
- 在
PromiseResolveThenableJob
执行中会执行thenable.then()
,从而注册了另一个微任务:console.log('thenable ok')
- 正是由于规范中对
thenable
的处理需要在一个微任务中完成,从而导致了第一个Promise
的后续回调被延后了1个时序
如果在 Promise 中 resolve 一个 Promise 实例呢?
- 由于
Promise
实例是一个对象,其原型上有then
方法,所以这也是一个thenable
对象。 - 同样的,浏览器会创建一个
PromiseResolveThenableJob
去处理这个Promise
实例,这是一个微任务。 - 在
PromiseResolveThenableJob
执行中,执行了Promise.prototype.then
,而这时Promise
如果已经是resolved
状态,then
的执行会再一次创建了一个微任务
最终结果就是:额外创建了两个Job,表现上就是后续代码被推迟了 2 个时序
最终转换
上面围绕规范说了那么多,不知你有没有理解这其中的执行过程。规范是晦涩难懂的,下面我们结合规范继续对代码作“转换”,让这个过程变得更容易理解一些
对于代码1
2
3new Promise((resolve) => {
resolve(thenable)
})
在执行顺序上等价于 (我只敢说“在执行顺序上等价”,因为浏览器的内部实现无法简单的模拟):1
2
3
4
5new Promise((resolve) => {
Promise.resolve().then(() => {
thenable.then(resolve)
})
})
所以,原题中的 new Promise(resolve => resolve(async2()))
,在执行顺序上等价于:1
2
3
4
5new Promise((resolve) => {
Promise.resolve().then(() => {
async2().then(resolve)
})
})
综上,给出最终转换:<转换-END>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30function 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
规范更新了……
Await 规范的更新 激进优化
文章中的 激进优化 ,是指 await v
在语义上将等价于 Promise.resolve(v)
,而不再是现在的new Promise(resolve => resolve(v))
,所以在未来的 Chrome73 中,题中的代码可做如下等价转换:<转换-优化版本>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26function 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)
})
执行顺序:1
2
3
4
5
6
7async1 start
async2
1
async1 end
2
3
4
有没有觉得优化后的版本更容易理解了呢?
最后贴上另一道面试题
1 | console.log(1); |
答案:1
2
3
4
5
6
7
8
9
10
111
4
6
7
8
2
5
10
3
undefined
9