本文介绍ES6新标准中async函数。节选自《ESMAScript 6 入门 —— 阮一峰》)
一、含义
ES2017标准引入了async函数,使得异步操作变得更加方便。
async函数是什么?一句话,它就是Generator函数的语法糖。
前文有一个Generator函数,依次读取两个文件。
1 | const fs = require('fs'); |
写成async
函数,就是下面这样。
1 | const asyncReadFile = async function() { |
一比较就会发现,async
函数就是将Generator函数的星号(*
)替换成async
,将yield
替换成await
,仅此而已。
async
函数对Generator函数的改进,体现在以下四点。
- 1) 内置执行器
Generator函数的执行必须依靠执行器,所以才有了co模块,而async
函数自带执行器。也就是说,async
函数的执行,与普通函数一模一样,只要一行。
1 | asyncReadFile(); |
上面代码调用了asyncReadFile
函数,然后它就会自动执行,输出最后结果。这完全不像Generator函数,需要调用next
方法,或者用co
模块,才能真正执行,得到最后结果。
- 2) 更好的语义
async
和await
,比起星号和yield
,语义更加清楚。async
表示函数里有异步操作,await
表示紧跟在后面的表达式需要等待结果。
- 3) 更广的适用性
co
模块约定,yield
命令后面只能是Thunk函数或Promise对象,而async
函数的await
命令后面,可以是Promise对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)。
- 4) 返回值是Promise
async
函数的返回值是Promise对象,这笔Generator函数的返回值是Iterator对象方便多了。你可以用then
方法指定下一步的操作。
进一步说,async
函数完全可以看作多个异步操作,包装成一个Promise对象,而await
命令就是内部then
命令的语法糖。
二、基本用法
async
函数返回一个Promise对象,可以使用then
方法添加回调函数。当函数执行的时候,一旦遇到await
就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。
下面是一个例子。
1 | async function getStockPriceByName(name) { |
上面代码是一个获取股票报价的函数,函数前面的async
关键字,表明该函数内部有异步操作。调用该函数时,会立即返回一个Promise对象。
下面是另一个例子,指定多少毫秒后输出一个值。
1 | function timeout(ms) { |
上面代码指定50毫秒以后,输出hello world
。
由于async
函数返回的是Promise对象,可以作为await
命令的参数。所以,上面的例子也可以写成下面的形式。
1 | async function timeout(ms) { |
async函数有多种使用形式。
1 | // 函数声明 |
三、语法
async
函数的语法规则总体上比较简单,难点是错误处理机制。
1. 返回Promise对象
async
函数返回一个Promise对象。
async
函数内部return
语句返回的值,会成为then
方法回调函数的参数。
1 | async function f() { |
上面代码中,函数f
内部return
命令返回的值,会被then
方法回调函数接收到。
async
函数内部抛出错误,会导致返回的Promise对象变为reject
状态。抛出的错误对象会被catch
方法回调函数接收到。
1 | async function f() { |
2. Promise对象的状态变化
async
函数返回的Promise对象,必须等到内部所有await
命令后面的Promise对象执行完,才会发生状态改变,除非遇到return
语句或者抛出错误。也就是说,只有async
函数内部的异步操作执行完,才会执行then
方法指定的回调函数。
下面是一个例子。
1 | async function getTitle(url) { |
上面代码中,函数getTitle
内部有三项操作:抓取网页、取出文本、匹配页面标题。只有这三个操作全部完成,才会执行then
方法里面的console.log
。
3. await命令
正常情况下,await
命令后面是一个Promise对象。如果不是,会被转成一个立即resolve
的Promise对象。
1 | async function f() { |
上面代码中,await
命令的参数是数值123
,它被转成Promise对象,并立即resolve
。
await
命令后面的Promise对象如果变为reject
状态,则reject
的参数会被catch
方法的回调函数接收到。
1 | async function f() { |
注意,上面代码中,await
语句前面没有return
,但是reject
方法的参数依然传入了catch
方法的回调函数。这里如果在await
前面加上return
,效果是一样的。
只要一个await
语句后面的Promise变为reject
,那么整个async
函数都会中断执行。
1 | async function f() { |
上面代码中,第二个await
语句是不会执行的,因为第一个await
语句状态变成了reject
。
有时,我们希望即使前一个异步操作失败,也不要中断后面的异步操作。这时可以将第一个await
放在try...catch
结构里面,这样不管这个异步操作是否成功,第二个await
都会执行。
1 | async function f() { |
另一种方法是await
后面的Promise对象再跟一个catch
方法,处理前面可能出现的错误。
1 | async function f() { |
4. 错误处理
如果await
后面的异步操作出错,那么等同于async
函数返回的Promise对象被reject
。
1 | async function f() { |
上面代码中,async
函数f
执行后,await
后面的Promise对象会抛出一个错误对象,导致catch
方法的回调函数被调用,它的参数就是抛出的错误对象。具体的执行机制,可以参考后文的“async函数的实现原理”。
防止出错的方法,也是将其放在try...catch
代码块之中。
1 | async function f() { |
如果有多个await
命令,可以统一放在try...catch
结构中。
1 | async function main() { |
下面的例子使用try...catch
结构,实现多次重复尝试。
1 | const superagent = require('superagent'); |
上面代码中,如果await
操作成功,就会使用break
语句退出循环;如果失败,会被catch
语句捕捉,然后进入下一轮循环。
5. 使用注意点
第一点,前面已经说过,await
命令后面的Promise
对象,运行结果可能是reject
,所以最好把await
命令放在try...catch
代码块中。
1 | async function myFunction() { |
第二点,多个await
命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。
1 | let foo = await getFoo(); |
上面代码中,getFoo
和getBar
是两个独立的异步操作(即互不依赖),被写成继发关系。这样比较耗时,因为只有getFoo
完成以后,才会执行getBar
,完全可以让它们同时触发。
1 | // 写法一 |
上面两种写法,getFoo
和getBar
都是同时触发,这样就会缩短程序的执行时间。
第三点,await
命令只能用在async
函数之中,如果用在普通函数,就会报错。
1 | async function dbFuc(db) { |
上面代码会报错,因为await
用在普通函数之中了。但是,如果将forEach
方法的参数改成async
函数,也有问题。
1 | functon dbFuc(db) { // 这里不需要async |
上面代码可能不会正常工作,原因是这时三个db.post
操作将是并发执行,也就是同时执行,而不是继发执行。正确地写法是采用for
循环。
1 | async function dbFuc(db) { |
如果却是系统多个请求并发执行,可以使用Promise.all
方法。当三个请求都会resolved
时,下面两种写法效果相同。
1 | async function dbFuc(db) { |
目前,esm
模块加载器支持顶层await
,即await
命令可以不放在async
函数里面,直接使用。
1 | // async函数的写法 |
上面代码中,第二种写法的脚本必须使用esm
加载器,才会生效。
四、async函数的实现原理
async函数的实现原理,就是将Generator函数和自动执行器,包装在一个函数里。
1 | async function fn(args) { |
所有的async
函数都可以写成上面的第二种形式,其中的spawn
函数就是自动执行器。下面给出的spawn
函数的实现,基本就是前文自动执行器的翻版。
1 | function spawn(genF) { |
五、与其他异步处理方法的比较
我们通过一个例子,来看async函数与Promise、Generator函数的比较。
假定某个DOM元素上面,部署了一系列的动画,前一个动画结束,才能开始后一个。如果当中有一个动画出错,就不再往下执行,返回上一个成功执行的动画的返回值。
首先是Promise的写法。
1 | function chainAnimationsPromise(elem, animations) { |
虽然Promise的写法比回调函数的写法大大改进,但是一眼看上去,代码完全都是Promise的API(then
、catch
等等),操作本身的语义反而不容易看出来。
接着是Generator函数的写法。
1 | function chainAnimationsGenerator(elem, animations) { |
上面代码使用Generator函数遍历了每个动画,语义比Promise写法更清晰,用户定义的操作全部都出现在spawn
函数的内部。这个写法的问题在于,必须有一个任务运行器,自动执行Generator函数,上面代码的spawn
就是自动执行器,它返回一个Promise对象,而且必须保证yield
语句后面的表达式,必须返回一个Promise。
最后是async函数的写法。
1 | async function chainAnimationsAsync(elem, animations) { |
可以看到Async函数的实现最简洁,最符合语义,几乎没有语义不相关的代码。它将Generator写法中的自动执行器,改在语言层面提供,不暴露给用户,因此代码量最少。如果使用Generator写法,自动执行器需要用户自己提供。
六、实例:按顺序完成异步操作
实际开发中,经常遇到一组异步操作,需要按照顺序完成。比如,依次远程读取一组URL,然后按照读取顺序输出结果。
Promise的写法如下。
1 | function logInOrder(urls) { |
上面代码使用fetch
方法,同时远程读取一组URL。每个fetch
操作都返回一个Promise对象,放入textPromises
数组。然后,reduce
方法依次处理每个Promise对象,然后使用then
,将所有Promise对象连起来,因此就可以依次输出结果。
这种写法不太直观,可读性比较差。下面是async函数实现。
1 | async function logInOrder(urls) { |
上面代码确实大大简化,问题是所有远程操作都是继发。只有前一个URL返回结果,才会去读取下一个URL,这样做效率很差,非常浪费时间。我们需要的是并发发出远程请求。
1 | async function logInOrder(urls) { |
上面代码中,虽然map
方法的参数是async
函数,但它是并发执行的,因为只有async
函数内部是继发执行,外部不受影响。后面的for...of
循环内部使用了await
,因此实现了按顺序输出。
七、异步遍历器
《遍历器》一章说过,Iterator接口是一种数据遍历的协议,只要调用遍历器对象的next
方法,就会得到一个对象,表示当前遍历指针所在的那个位置的信息。next
方法返回的对象的结构是{value, done}
,其中value
表示当前的数据的值,done
是一个布尔值,表示遍历是否结束。
这里隐含着一个规定,next
方法必须是同步的,只要调用就必须立刻返回值。也就是说,一旦执行next
方法,就必须同步地得到value
和done
这两个属性。如果遍历指针正好指向同步操作,当然没有问题,但对于异步操作,就不太合适了。目前的解决方案是,Generator函数里面的异步操作,返回一个Thunk函数或者Promise对象,即value
属性是一个Thunk函数或者Promise对象,等待以后返回真正的值,而done
属性则还是同步产生的。
ES2018引入了“异步遍历器”(Async Iterator),为异步操作提供原生的遍历器接口,即value
和done
这两个属性都是异步产生。
1. 异步遍历的接口
异步遍历器的最大的语法特点,就是调用遍历器的next
方法,返回的是一个Promise对象。
1 | asyncIterator |
上面代码中,asyncIterator
是一个异步遍历器,调用next
方法以后,返回一个Promise对象。因此,可以使用then
方法指定,这个Promise对象的状态变为resolve
以后的回调函数。回调函数的参数,是一个具有value
和done
两个属性的对象,这个跟同步遍历器是一样的。
我们知道,一个对象的同步遍历器的接口,部署在Symbol.iterator
属性上面。同样地,对象的异步遍历器接口,部署在Symbol.asyncIterator
属性上面。不管是什么样的对象,只要它的Symbol.asyncIterator
属性有值,就表示应该对它进行异步遍历。下面是一个异步遍历器的例子。
1 | const asyncIterable = createAsyncIterable(['a', 'b']); |
上面代码中,异步遍历器其实返回了两次值。第一次调用的时候,返回一个Promise对象;等到Promise对象resolve
了,再返回一个表示当前数据成员信息的对象。这就是说,异步遍历器与同步遍历器最终行为是一致的,只是会先返回Promise对象,作为中介。
由于异步遍历器的next
方法,返回的是一个Promise对象。因此,可以把它放在await
命令后面。
1 | async function f() { |
上面代码中,next
方法用await
处理以后,就不必使用then
方法了。整个流程已经很接近同步处理了。
注意,异步遍历器的next
方法是可以连续调用的,不必等到上一步产生的Promise对象resolve
以后再调用。这种情况下,next
方法会累积起来,自动按照每一步的顺序执行下去。下面是一个例子,把所有的next
方法放在Promise.all
方法里面。
1 | const asyncGenObj = createAsyncIterable(['a', 'b']); |
另一种用法是一次性调用所有的next
方法,然后await
最后一步操作。
1 | async function runner() { |
2. for await…of
前面介绍过,for...of
循环用于遍历同步的Iterator接口。新引入的for await...of
循环,则是用于遍历异步的Iterator接口。
1 | async function f() { |
上面代码中,createAsyncIterable()
返回一个拥有异步遍历器接口的对象,for...of
循环自动调用这个对象的异步遍历器的next
方法,会得到一个Promise对象。await
用来处理这个Promise对象,一旦resolve
,就把得到的值(x
)传入for...of
的循环体。
for await...of
循环的一个用途,是部署了asyncIterable操作的异步接口,可以直接放入这个循环。
1 | let body = ''; |
上面代码中,req
是一个asyncIterable对象,用来异步读取数据。可以看到,使用for await...of
循环以后,代码会非常简洁。
如果next
方法返回的Promise对象被reject
,for await...of
就会报错,要用try...catch
捕捉。
1 | async function() { |
注意,for await...of
循环也可以用于同步遍历器。
1 | (async function() { |
Node V10支持异步遍历器,Stream就部署了这个接口。下面是读取文件的传统写法和异步遍历器写法的差异。
1 | // 传统写法 |
3. 异步Generator函数
就像Generator函数返回一个同步遍历器对象一样,异步Generator函数的作用,是返回一个异步遍历器对象。
在语法上,异步Generator函数就是async
函数与Generator函数的结合。
1 | async function* gen() { |
上面代码中,gen
是一个异步Generator函数,执行后返回一个异步Iterator对象。对该对象调用next
方法,返回一个Promise对象。
异步遍历器的设计目的之一,就是Generator函数处理同步操作和异步操作时,能够使用同一套接口。
1 | // 同步Generator函数 |
上面代码中,map
是一个Generator函数,第一个参数是可遍历对象iterable
,第二个参数是一个回调函数func
。map
的作用是将iterable
每一步返回的值,使用func
进行处理。上面有两个版本的map
,前一个处理同步遍历器,后一个处理异步遍历器,可以看到两个版本的写法基本上是一致的。
下面是另一个异步Generator函数的例子。
1 | async function* readLines(path) { |
上面代码中,异步操作前面使用await
关键字标明,即await
后面的操作,应该返回Promise对象。凡是yield
关键字的地方,就是next
方法停下来的地方,它后面的表达式的值(即await file.readLine()
的值),会作为next()
返回对象的value
属性,这一点是与Generator函数一致的。
异步Generator函数内部,能够同时使用await
和yield
命令。可以这样理解,await
命令用于将外部操作产生的值输入函数内部,yield
命令用于将函数内部的值输出。
上面代码定义的异步Generator函数的用法如下。
1 | (async function() { |
异步Generator函数可以与for await...of
循环结合起来使用。
1 | async function* prefixLines(asyncIterable) { |
异步Generator函数的返回值是一个异步Iterator,即每次调用它的next
方法,会返回一个Promise对象,也就是说,跟在yield
命令后面的,应该是一个Promise对象。如果像上面那个例子一样,yield
命令后面是一个字符串,会被自动包装成一个Promise对象。
1 | function fetchRandom() { |
上面代码中,ag
是asyncGenerator
函数返回的异步遍历器对象。调用ag.next()
以后,上面代码的执行顺序如下。
1)
ag.next()
立刻返回一个Promise对象。2)
asyncGenerator
函数开始执行,打印出Start
。3)
await
命令返回一个Promise对象,asyncGenerator
函数停在这里。4) A处变成fulfilled状态,产生的值放入
result
变量,asyncGenerator
函数继续往下执行。5) 函数在B处的
yield
暂停执行,一旦yield
命令取到值,ag.next()
返回的那个Promise对象变成fulfilled状态。6)
ag.next()
后面的then
方法指定的回调函数开始执行。该回调函数的参数是一个对象{value, done}
,其中value
的值是yield
命令后面的那个表达式的值,done
的值是false
。
A和B两行的作用类似于下面的代码。
1 | return new Promise((resolve, reject) => { |
如果异步Generator函数抛出错误,会导致Promise对象的状态变为reject
,然后抛出的错误被catch
方法捕获。
1 | async function* asyncGenerator() { |
注意,普通的async函数返回的是一个Promise对象,而异步Generator函数返回的是一个异步Iterator对象。可以这样理解,async函数和异步Generator函数,是封装异步操作的两种方法,都用来达到同一种目的。区别在于,前者自带执行器,后者通过for await...of
执行,或者字迹编写执行器。下面就是一个异步Generator函数的执行器。
1 | async function takeAsync(asyncIterable, count = Infinity) { |
上面代码中,异步Generator函数产生的异步遍历器,会通过while
循环自动执行,每当await iterator.next()
完成,就会进入下一轮循环。一旦done
属性变为true
,就会跳出循环,异步遍历器执行结束。
下面是这个自动执行器的一个使用实例。
1 | async function f() { |
异步Generator函数出现以后,JavaScript就有了四种函数形式:普通函数、async函数、Generator函数和异步Generator函数。请注意区分每种函数的不同之处。基本上,如果是一系列按照顺序执行的异步操作(比如读取文件,然后写入新内容,再存入硬盘),可以使用async函数;如果是一系列产生相同数据结构的异步操作(比如一行一行读取文件),可以使用异步Generator函数。
异步Generator函数也可以通过next
方法的参数,接收外部传入的数据。
1 | const writer = openFile('someFile.txt'); |
上面代码中,openFile
是一个异步Generator函数。next
方法的参数,向该函数内部的操作传入数据。每次next
方法都是同步执行的,最后的await
命令用于等待整个写入操作结束。
最后,同步的数据结构,也可以使用异步Generator函数。
1 | async function* createAsyncIterable(syncIterable) { |
上面代码中,由于没有异步操作,所以也就没有使用await
关键字。
4. yield* 语句
yield*
语句也可以跟一个异步遍历器。
1 | async function* gen1() { |
上面代码中,gen2
函数里面的result
变量,最后的值是2
。
与同步Generator函数一样,for await...of
循环会展开yield*
。
1 | (async function() { |