异步函数
异步函数,也称为“async/await”(语法关键字),是ES6期约模式在ECMAScript函数中的应用。async/await
是ES8规范新增的。这个特性从行为和语法上都增强了JavaScript,让以同步方式写的代码能够异步执行。下面来看一个最简单的例子,这个期约在超时之后会解决为一个值:
let p = new Promise((resolve,reject) => setTimeout(resolve,1000,3))
这个期约在1000毫秒之后解决为数值3.如果程序中的其他代码要在这个值可用时访问它,则需要写一个解决处理程序:
let p = new Promise((resolve,reject) => setTimeout(resolve, 1000, 3))
p.then((x) => console.log(x)) //3
2
这其实是很不方便的,因为其他代码都必须塞到期约处理程序中。不过可以把处理程序定义为一个函数:
function handler(x){
console.log(x)
}
let p = new Promise((resolve,reject) => setTimeout(resolve, 1000, 3))
p.then(handler) //3
2
3
4
5
6
这个改进其实也不大。这是因为任何需要访问这个期约所产生值的代码,都需要以处理程序的形式来接收这个值。也就是说,代码照样还是要放到处理程序里。ES8为此提供了async/await
关键字。
# 异步函数
# 1.async
async
关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上:
async function foo(){
let bar = async function()
let baz = async () = {}
class Qux {
async qux() {}
}
}
2
3
4
5
6
7
8
9
使用async
关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。而在参数或闭包方面,异步函数仍然具有普通JavaScript函数的正常行为。如下面例子所示,foo()函数仍然会在后面的指令之前被求值:
async function foo() {
console.log(1)
}
foo()
console.log(2)
//1
//2
2
3
4
5
6
7
8
9
10
不过,异步函数如果使用return
关键字返回了值(如果没有return则会返回undefined),这个值会被Promise.resolve()
包装成一个期约对象。异步函数始终返回期约对象。在函数外部调用这个函数可以得到它返回的期约:
async function foo() {
console.log(1)
return 3
}
//给返回的期约添加一个解决处理程序
foo().then(console.log)
console.log(2)
//1
//2
//3
2
3
4
5
6
7
8
9
10
11
12
当然,直接返回一个期约对象也是一样的:
async function foo() {
console.log(1)
return Promise.resolve(3)
}
//给返回的期约添加一个解决处理程序
foo().then(console.log)
console.log(2)
//1
//2
//3
2
3
4
5
6
7
8
9
10
11
12
异步函数的返回值期待(但实际上并不要求)一个实现thenable
接口的对象,但是常规的值也可以。如果返回的是实现thenable
接口的对象,则这个对象可以由提供给then()
的处理程序“解包”。如果不是,则返回值就被当做已经解决的期约。下面的代码演示了这些情况:
//返回一个原始值
async function foo(){
return 'foo'
}
foo().then(console.log) //foo
//返回一个没有实现thenable接口的对象
async function bar(){
return ['bar']
}
bar().then(console.log) //['bar']
//返回一个实现了thenable接口的非期约对象
async function baz() {
const thenable = {
then(callback) {callback('baz')}
}
return thenable
}
baz().then(console.log) //baz
//返回一个期约
async function qux(){
return Promise.resolve('qux')
}
qux().then(console.log) // qux
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
与在期约处理程序中医院,在异步函数中抛出错误会返回拒绝的期约:
async function foo() {
console.log(1)
throw 3
}
//给返回的期约添加一个拒绝处理程序
foo().catch(console.log)
console.log(2)
//1
//2
//3
2
3
4
5
6
7
8
9
10
11
12
不过拒绝期约的错误不会被异步函数捕获:
async function foo() {
console.log(1)
Promise.reject(3)
}
//Attach a reject handler to the returned promise
foo().catch(console.log)
console.log(2)
//1
//2
//Uncaught (in promise):3
2
3
4
5
6
7
8
9
10
11
12
# 2.await
因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复的执行能力。使用await
关键字可以暂停异步代码的执行,等待期约解决。来看下之前出现过的例子:
let p = new Promise((resolve,reject) => setTimeout(resolve, 1000, 3))
p.then((x) => console.log(x)) //3
2
3
使用async/await
可以写成这样:
async function foo() {
let p = new Promise((resolve,reject) => setTimeout(resolve,1000,3))
console.log(await p)
}
foo()
//3
2
3
4
5
6
7
注意,await
关键字会暂停执行异步函数后面的代码,让出Javascript运行时执行线程。这个行为与生成器函数中的yield
关键字是一样的。await
关键字同样是尝试“解包”对象的值,然后将这个值传给表达式,再异步恢复函数的执行。
await
关键字的用法与JavaScript的一元操作一样。它可以单独使用,也可以在表达式中使用,如下面的例子所示:
//异步打印“foo”
async function foo(){
console.log(await Promise.resolve('foo'))
}
foo()
//foo
//异步打印“bar”
async function bar() {
return await Promise.resolve('bar')
}
bar().then(console.log)
//bar
//1000毫秒后异步打印“baz”
async function baz(){
await new Promise((resolve,reject) => setTimeout(resolve,1000))
console.log('baz')
}
baz()
//baz(1000毫秒后)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
await
关键字期待(但实际上并不要求)一个实现thenable
接口的对象,但常规的值也可以。如果是实现thenable
接口的对象,则这个对象可以由await
来“解包”。如果不是,则这个值就被当做一个已经解决的期约。下面代码演示了这些情况:
//等待一个原始值
async function foo(){
console.log(await 'foo')
}
foo() //foo
//等待一个没有实现thenable接口的对象
async function bar(){
console.log(await ['bar'])
}
bar() //['bar']
//等待一个实现了thenable接口的非期约对象
async function baz(){
const thenable = {
then(callback){callback('baz')}
}
console.log(await thenable)
}
baz() //baz
//等待一个期约
async function qux() {
console.log(await Promise.resolve('qux'))
}
qux() //qux
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
等待会抛出错误的同步操作,会返回拒绝的期约:
async function foo(){
console.log(1)
await (() => {throw 3})()
}
//给返回的期约添加一个拒绝处理程序
foo().catch(console.log)
console.log(2)
//1
//2
//3
2
3
4
5
6
7
8
9
10
11
12
如前面的例子所示,单独的Promise.reject()
不会被异步函数捕获,而会抛出未捕获错误。不过,对拒绝的期约使用await
则会释放(unwrap)错误值(将拒绝期约返回):
async function foo(){
console.log(1)
await Promise.reject(3)
console.log(4) //这行代码不会执行
}
//给返回的期约添加一个拒绝处理程序
foo().catch(console.log)
console.log(2)
//1
//2
//3
2
3
4
5
6
7
8
9
10
11
12
13
# 3.await的限制
await
关键字必须在异步函数中使用,不能在顶级上下文如<script>标签或模块中使用。不过,定义并立即调用异步函数是没问题的。下面两段代码实际是相同的:
async function foo(){
console.log(await Promise.resolve(3))
}
foo() //3
//立即调用的异步函数表达式
(async function() {
console.log(await Promise.resolve(3))
})() //3
2
3
4
5
6
7
8
9
此外,异步函数的特质不会扩展到嵌套函数。因此,await
关键字也只能直接出现在异步函数的定义中。在同步函数内部使用await
会抛出SyntaxError
。
下面展示了一些会出错的例子:
//不允许:await出现在了箭头函数中,只允许出现在异步函数定义中,而且只能在代码块顶层
function foo() {
const syncFn = () => {
return await Promise.resolve('foo')
}
console.log(syncFn())
}
//Uncaught SyntaxError: await is only valid in async functions and the top level bodies of modules
//不允许:await出现在了同步函数声明中
function bar(){
function syncFn() {
return await Promise.resolve('bar')
}
console.log(syncFn())
}
//Uncaught SyntaxError: await is only valid in async functions and the top level bodies of modules
//不允许:await 出现在了同步函数表达式中
function baz() {
const syncFn = function() {
return await Promise.resolve('baz')
}
console.log(syncFn())
}
//ncaught SyntaxError: await is only valid in async functions and the top level bodies of modules
//不允许:IIFE使用同步函数表达式或箭头函数
function qux() {
(
function(){
console.log(await Promise.resolve('qux'))
}
)()
(
() => console.log(await Promise.resolve('qux'))
)()
}
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
30
31
32
33
34
35
36
37
38
# 停止和恢复执行
使用await
关键字之后的区别其实比看上去的还要微妙一些。比如,下面的例子中按顺序调用了3个函数,但它们的输出结果顺序是相反的:
async function foo() {
console.log(await Promise.resolve('foo'))
}
async function bar() {
console.log(await 'bar')
}
async function baz() {
console.log('baz')
}
foo()
bar()
baz()
//baz
//bar
//foo
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async/await
中真正起作用的是await
。async
关键字,无论从哪方面来看,都不过是一个标识符。毕竟,异步函数如果不包含await
关键字,其执行基本上跟普通函数没什么区别:
async function foo() {
console.log(2)
}
console.log(1)
foo()
console.log(3)
//1
//2
//3
2
3
4
5
6
7
8
9
10
要完全理解await
关键字,必须知道它并非只是等待一个值可用那么简单。JavaScript运行时在碰到await
关键字时,会记录在哪里暂停执行。等到await
右边的值可以用了,JavaScript运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。
因此,即使await
后面跟着一个立即可用的值,函数的其余部分也会被异步求职。下面的例子演示了这一点:
async function foo() {
console.log(2)
await null
console.log(4)
}
console.log(1)
foo()
console.log(3)
//1
//2
//3
//4
2
3
4
5
6
7
8
9
10
11
12
13
控制台中输出结果的顺序很好地解释了运行时的工作过程:
- 打印1
- 调用异步函数foo()
- (在foo()中)打印2
- (在foo()中)await关键字暂停执行,为立即可用的值null向消息队列中添加一个任务
- foo()退出
- 打印3
- 同步线程的代码执行完毕
- JavaScript运行时从消息队列中取出任务,恢复异步函数执行
- (在foo()中)恢复执行,await取得null值(这里并没有使用)
- (在foo()中) 打印4
- foo()返回
如果await
后面是一个期约,则问题会稍微复杂些。此时,为了执行异步函数,实际上会有两个任务被添加到消息队列并被异步求值。下面的例子虽然看起来很反直觉,但它演示了真正的执行顺序:
async function foo() {
console.log(2)
console.log(await Promise.resolve(8))
console.log(9)
}
async function bar(){
console.log(4)
console.log(await 6)
console.log(7)
}
console.log(1)
foo()
console.log(3)
bar()
console.log(5)
//1
//2
//3
//4
//5
//6
//7
//8
//9
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
运行时会像这样执行上面的例子:
- 打印1
- 调用异步函数foo()
- (在foo()中)打印2
- (在foo()中) await关键字暂停执行,向消息队列中添加一个期约在落定之后执行的任务
- 期约立即落定,把给await提供值的任务添加到消息队列
- foo()退出
- 打印3
- 调用异步函数bar()
- (在bar()中)打印4
- (在bar()中)await关键字暂停执行,为立即可用的值6向消息队列中添加一个任务
- bar()退出
- 打印5
- 顶级线程执行完毕
- JavaScript运行时从消息队列中取出解决await期约的处理程序,并将解决的值8提供给它
- JavaScript运行时向消息队列中添加一个恢复执行foo()函数的任务
- JavaScript运行时从消息队列中取出恢复执行bar()的任务及值 6
- (在bar()中)恢复执行,await 取得值6
- (在bar()中)打印6
- (在bar()中)打印7
- bar()返回
- 异步任务完成,JavaScript从消息队列中取出恢复执行foo()的任务及值8
- (在foo()中)打印8
- (在foo()中)打印9
- foo()返回
注意
TC39对await后面是期约的情况如果处理做过一次修改。修改后,本例中的Promise.resolve(8)只会生成一个异步任务。因此在新版浏览器中,这个示例的输出结果为123458967
async function foo() {
console.log(2)
console.log(
await new Promise((resolve) => resolve(Promise.resolve(8)))
)
console.log(9)
}
async function bar(){
console.log(4)
console.log(await 6)
console.log(7)
}
console.log(1)
foo()
console.log(3)
bar()
console.log(5)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
这样才是真的123456789
# 异步函数策略
因为简单实用,所以异步函数很快成为JavaScript项目最广泛的特性之一。不过,在使用异步函数时,还是有些问题需要注意。
# 1. 实现sleep()
很多人在刚开始学习JavaScript时,想找到一个类似Java
中Thread.sleep()
之类的函数,好在程序中加入非阻塞性的暂停。以前,这个需求基本上都通过setTimeout()
利用JavaScript运行时的行为来实现的。
有了异步函数之后,就不一样了。一个简单的箭头函数就可以实现sleep()
:
async function sleep(delay) {
return new Promise((resolve) => setTimeout(resolve,delay))
}
async function foo() {
const t0 = Date.now()
await sleep(1500)
console.log(Date.now() - t0)
}
foo()
//1500
2
3
4
5
6
7
8
9
10
11
# 2.利用平行执行
如果使用await
时不留心,则很可能错过平行加速的机会。来看下面的例子,其中顺序等待了5个随机的超时:
async function randomDelay(id){
//延迟0-1000毫秒
const delay = Math.random() * 1000
return new Promise((resolve) => setTimeout(() => {
console.log(`${id} finished`)
resolve()
},delay))
}
async function foo() {
const t0 = Date.now()
await randomDelay(0)
await randomDelay(1)
await randomDelay(2)
await randomDelay(3)
await randomDelay(4)
console.log(`${Date.now() - t0}ms elapsed`)
}
foo()
//0 finished
//1 finished
//2 finished
//3 finished
//4 finished
//2372ms elapsed
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
用一个for
循环重写,就是:
async function randomDelay(id){
//延迟0-1000毫秒
const delay = Math.random() * 1000
return new Promise((resolve) => setTimeout(() => {
console.log(`${id} finished`)
resolve()
},delay))
}
async function foo() {
const t0 = Date.now()
for(let i = 0;i<5;++i){
await randomDelay(i)
}
console.log(`${Date.now() - t0}ms elapsed`)
}
foo()
//0 finished
//1 finished
//2 finished
//3 finished
//4 finished
//2372ms elapsed
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
就算这些期约之间没有依赖,异步函数也会一次暂停,等待每个超时完成。这样可以保证执行顺序,但总执行时间会边长。
如果顺序不是必须保证的,那么可以先一次性初始化所有期约,然后再分别等待他们的结果。比如:
async function randomDelay(id){
//延迟0-1000毫秒
const delay = Math.random() * 1000
return new Promise((resolve) => setTimeout(() => {
console.log(`${id} finished`)
resolve()
},delay))
}
async function foo() {
const t0 = Date.now()
const p0 = randomDelay(0)
const p1 = randomDelay(1)
const p2 = randomDelay(2)
const p3 = randomDelay(3)
const p4 = randomDelay(4)
await p0
await p1
await p2
await p3
await p4
setTimeout(console.log,0,`${Date.now() - t0}ms elapsed`)
}
foo()
//2 finished
// 3 finished
// 4 finished
// 1 finished
// 0 finished
// 369ms elapsed
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
30
31
32
33
34
用数组和for循环再包装一下:
async function randomDelay(id){
//延迟0-1000毫秒
const delay = Math.random() * 1000
return new Promise((resolve) => setTimeout(() => {
console.log(`${id} finished`)
resolve()
},delay))
}
async function foo() {
const t0 = Date.now()
const promises = Array(5).fill(null).map((_,i) => randomDelay(i))
for(const p of promises){
await p
}
console.log(`${Date.now() - t0}ms elapred`)
}
foo()
// 2 finished
// 3 finished
// 4 finished
// 1 finished
// 0 finished
// 369ms elapsed
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
注意,虽然期约没有按照顺序执行,但await
按顺序接收到了每个期约的值:
async function randomDelay(id){
//延迟0-1000毫秒
const delay = Math.random() * 1000
return new Promise((resolve) => setTimeout(() => {
console.log(`${id} finished`)
resolve(id)
},delay))
}
async function foo(){
const t0 = Date.now()
const promises = Array(5).fill(null).map((_,i) => randomDelay(i))
for(const p of promises) {
console.log(`awaited ${await p}`)
}
console.log(`${Date.now() - t0}ms elapsed`)
}
foo()
//1 finishid
//2 finishid
//3 finishid
//4 finishid
//0 finishid
//awaited 0
//awaited 1
//awaited 2
//awaited 3
//awaited 4
//654ms elapsed
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
30
31
32
33
# 3.串行执行期约
使用async/await
,期约连锁会变得很简单:
function addTwo(x) {return x + 2}
function addThree(x) {return x + 3}
function addFive(x) {return x + 5}
async function addTen(x) {
for(const fn of [addTwo,addThree,addFive]){
x = await fn(x)
}
return x
}
addTen(9).then(console.log)
2
3
4
5
6
7
8
9
10
11
这里,await
直接传递了每个函数的返回值,结果通过迭代产生。当然,这个例子没有使用期约,如果要使用期约,则可以把所有函数都改成异步函数。这样他们就都返回期约了:
async function addTwo(x) {return x + 2}
async function addThree(x) {return x + 3}
async function addFive(x) {return x + 5}
async function addTen(x) {
for(const fn of [addTwo,addThree,addFive]){
x = await fn(x)
}
return x
}
addTen(9).then(console.log)
2
3
4
5
6
7
8
9
10
11
# 4.栈追踪与内存管理
期约与异步函数的功能有相当程度的重叠,但他们在内存中的表示则差别很大。看看下面的例子,它展示了拒绝期约的栈追踪信息:
function fooPromiseExecutor(resolve,reject) {
setTimeout(reject,1000,'bar')
}
function foo() {
new Promise(fooPromiseExecutor)
}
foo()
//Uncaught (in promise) bar
//setTimeout
//setTimeout (async)
//fooPromiseExecutor
//foo
2
3
4
5
6
7
8
9
10
11
12
13
14
15
栈追踪信息应该相当直接地表现JavaScript引擎当前栈内存中函数调用之间的嵌套关系。在超时处理程序执行和拒绝期约时,我们看到的错误信息包含嵌套函数的标识符,那是被调用以创建最初期约实例的函数,可是,我们知道这些函数已经返回了,因此栈追踪信息中不应该看到他们。
答案很简单,这是因为JavaScript引擎会在创建期约时尽可能保留完整的调用栈。在抛出错误时,调用栈可以由运行时的错误处理逻辑获取,因而就会出现在栈追踪信息中。当然,这意味着栈追踪信息会占用内存,从而带来一些计算和存储成本。
如果在前面的例子中使用的是异步函数,那又会怎样呢?比如:
function fooPromiseExecutor(resolve,reject) {
setTimeout(reject,1000,'bar')
}
async function foo() {
await new Promise(fooPromiseExecutor)
}
foo()
//Uncaught (in promise) bar
//foo
//async function(async)
//foo
2
3
4
5
6
7
8
9
10
11
12
13
14
这样一改,栈追踪信息就准确地反应了当前的调用栈。fooPromiseExecutor()已经返回,所以它不在错误信息中。但foo()此时被挂起了,并没有退出。JavaScript运行时可以简单地在嵌套函数中存储指向包含函数的指针,就跟对待同步函数调用栈一样。这个指针实际上存储在内存中,可用于在出错时生成栈追踪信息。这样就不会像之前的例子那样带来额外的消耗,因此在重视性能的应用中是可以优先考虑的。