async/await入门教程

什么是async/await

async/await是ES2017所提供的的新特性,其目的是提供简洁的如同顺序编程般丝滑的异步编程。使用async/await进行异步编程,除了在函数上加五个字母async之外,几乎没有任何其他的麻烦了。
async/await道理上是以PromiseGenerator为基础的,然而在我看来,async/await存在的目的就是不要像PromiseGenerator那么麻烦(毕竟,Promise thencatch去的就算不论是否足够优雅的问题,一旦逻辑关系复杂一点就连运行结果很多人也经常会绕蒙罢,从据说有些地方拿Promise的链式调用当做面试题也可见一斑。)
因此,本文的目标就是尽量避免不太常用的Promise和几乎用不到的Generator的相关知识,来开箱即用式的讲解async/await的使用方法。当然,作为一个成熟的程序员随着做的事情不断增多可能也无法避免需要深究一些Promise的细节,不过这就不是本文想做的事情了,相关的教程网上也已经有一大堆了。

基础:Promise

这一节将简单介绍Promise是什么,但是是以接下来讲解async/await为目的导向的,因此讲解的不会很全面,基本上是用到什么讲什么。
Promise,直译叫承诺。它表示一个“延期”的结果,即在将来的某一时刻,你所持有的Promise对象可以让你得到一个值。
打一个比方的话,Promise就像一份股票或者是理财产品。它握在你手上,此时此刻并不能当钱花;然而,它代表着一种你在将来的某一时刻可以兑现为金钱的凭证,等到合适的时机他就会为你带来一大笔钱。

Promise的状态

一个Promise有三种状态:pending(处理中)、resolved(已成功)、rejected(已失败)。刚创建的Promise绝大多数是pending状态,而允许的状态转换也只可能是由pending到resolved或由pending到reject,不可能转换回去或者二次转换。

  • pending表示的是这个值还没有真正拿到,比如发起一个网络请求,在服务器还没有返回内容的期间Promise的状态就是pending。对于pending的Promise,是不可能立刻同步的得到它内部的结果的(因为结果压根没产生)。
  • resolved表示的是这个值已经产生并存在内存中了,你可以立刻获取到它的值。比如发起一个网络请求,服务器返回内容之后,Promise的状态就会变为resolved,同时返回的内容也会存储在这个Promise的一个字段里,你可以随时获取到它。
  • rejected表示的是Promise对应的任务失败了,因此你不可能从里面拿到预期的结果了。但是相应的,你可以拿到一个异常对象用来描述发生了什么错误导致了失败。

Promise的构建

构建一个Promise对象,只需要使用new Promise(),参数中需要传入一个函数。这个传入的函数需要具有resolvereject两个参数,而这两个参数的类型也都是函数类型。

1
2
3
4
5
6
7
8
9
let a = new Promise((resolve, reject) => {
// 构建了一个promise对象,它在3s后resolve一个字符串"qwq"
setTimeout(()=>{ resolve("qwq") }, 3000)
})

console.log(a); // 立即查看a,打印出的是"Promise { <pending> }"
setTimeout(()=>{
console.log(a); // 5s后查看a,打印出的是"Promise { 'qwq' }
}, 5000)

你传入的这个函数(即上面的setTimeout(()=>{ resolve("qwq") }, 3000)这行)会被立即执行。在执行的时候,函数中的resolvereject两个参数当然要被传入实参了,这两个传入的实参是Promise为你提供的,都是仅接受一个参数的函数。作用就是只需在任意时间任意地点调用一下resolve函数,就可以让这个Promise对象变为resolved状态。
上面的例子是在3s后用字符串”qwq”调用resolve函数,所以3s后当你调用resolve函数的一瞬间,这个a对象就会立刻由pending状态变为resolved状态。当然,你也可以用闭包等方法把resolve函数给传递出去,这都无所谓。只要这个resolve函数一被调用,与之关联的Promise对象a就会立即变为resolved状态。
类似的,调用reject函数会立即使Promise变为rejected状态。

没错,关于Promise,我们需要讲的就这么多。是不是比外面的教程少多了?

async函数

async函数是一个函数上带了async关键字的函数。例如:

1
2
3
4
5
async function qwq(){
return 1
}
// 或者也可以用在lambda表达式上
async ()=>"abc"

关于async函数,请记住这两句话:

  • async函数的返回类型总是Promise!
  • 只有async函数内部才能使用await关键字!

async函数的返回类型总是Promise!

是的。如同上面的qwq函数,我们在里面写了return 1。然而,我们试一下

1
console.log(qwq()) // 打印出的是"Promise { 1 }"

一个async函数的真正返回类型总是Promise,这个Promise所resolve的结果就是你在async函数体内return出来的结果。相当于把return的结果封装在了一个Promise里面!
特殊的,如果你在async函数内return的已经是一个Promise类型的对象了,那么真正返回的结果当然还是一个Promise,但它所封装的结果不是直接是return的Promise对象,而是那个Promise对象所进一步resolve的结果。参见下面的小实验:

1
2
3
4
5
6
7
8
9
10
11
let m = Promise.resolve(2) // 构造了一个Promise,它在构造时就立刻resolve出结果2。
async function y() {
return m
}
let r = y()

setTimeout(()=>{
console.log(r === m) // 输出"false",说明y()返回的Promise(r)和写在return后面的Promise对象(m)不是同一个对象
console.log(m) // 输出"Promise { 2 }"
console.log(r) // 输出"Promise { 2 }"。这表明了r resolve的值和m resolve的值是一致的
}, 10) // 延迟个10ms再执行,以让r得以resolve

对应于这个async函数调用的Promise,是在函数里面return值的一刻才会resolve的。也就是说如果你的函数有使用到await进行等待(下文会讲),
则一开始的时候这个Promise是pending状态,直到return语句被执行的一瞬间才立即变为resolve你所return的值。
而如果你的async函数是以抛出异常的方式返回的,那么对应的Promise将会reject,且reject的值就是你抛出的异常对象。

事实上,如果你使用TypeScript,你会看到Promise类实际上是带有泛型的Promise<T>T代表着这个Promise可以resolve出的结果的类型。在TypeScript下你看到的qwq函数的返回类型像这样:

看起来async函数和普通的函数相比没什么特别的,倒是变得更加不自由了,因为返回的类型只能是Promise。那我们为什么要用它呢?
这正是因为,只有async函数内部才能使用await关键字!

await关键字

await关键字用于一个值之前。例如,await a

  • 如果a是一个Promise对象(事实上只要是Promise Like的对象都可以,本文中不对这一概念展开说明),那么await返回的是它resolve的结果!
    • 如果a对象最终是reject的,则await会抛出异常,抛出的异常对象就是a所reject的值。
  • 否则如果a不是Promise对象,那么await直接返回a本身。

await,顾名思义,就是 “等”。如果被await已经是一个真正的结果,或者是一个已经resolved的Promise对象,那么直接返回值就好了;
而如果是一个pending中的Promise对象,await就相当于会使得async函数在此处“暂停”,等到被await的Promise得到结果以后,拿着结果再继续。
(如果拿到的是resolved结果就以该结果作为await表达式的返回值,如果拿到的是rejected结果则直接在此处抛出异常。这个特性同时意味着,如果你在async函数中抛出异常,那么await这个函数也同样会得到这个异常,就像普通的函数调用的异常沿调用栈向上传播一样特性。)

await有什么用?

考虑下面一个场景,你要做一个爬虫。你需要连续的请求5个不同的url,并且下一个请求的url总是在上一个请求的响应中给出的。
假设你有一个回调函数形式的请求函数称为request

1
2
3
4
5
6
7
function request(url, callback) {
let response
// 向指定的url发送请求的逻辑,可能会很复杂。在这里我们用response = "xxx"一笔带过,
// 实际上可能代表一大段很复杂的代码。总之假设"xxx"就是服务器返回的结果
response= "xxxxx"
callback(undefined, response) // 调用回调函数,按惯例第一个参数表示错误,第二个参数表示返回内容
}

那么,为了完成需求,你可能得这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
let firstUrl = "https://xxx"
let finalResult
request(firstUrl, (err, res1)=>{
request(res1, (err, res2)=>{
request(res2, (err, res3)=>{
request(res3, (err, res4)=>{
request(res4, (err, res5)=>{
finalResult = res5
})
})
})
})
})

这就是所谓的回调地狱。一点也不够优雅,不是吗?
但是,假设你有了一个能返回Promise的可以用于发送请求的函数request_promise

1
2
3
4
5
6
7
async function request_promise(url) {
let response
// 向指定的url发送请求的逻辑,可能会很复杂。在这里我们用response = "xxx"一笔带过,
// 实际上可能代表一大段很复杂的代码。总之假设"xxx"就是服务器返回的结果
response = "xxxxx"
return response // 实际返回的是Promise { response }
}

那么,你只需要这样就可以了:

1
2
3
4
5
6
7
8
9
let firstUrl = "https://xxx";
let finalResult
(async function() {
let res1 = await request_promise(firstUrl)
let res2 = await request_promise(res1)
let res3 = await request_promise(res2)
let res4 = await request_promise(res3)
finalResult = await request_promise(res4)
})()

是不是原来用回调函数方式完成的异步操作,变得像写普通同步代码一样丝滑?这就是await的意义!

理解await和async函数的实质

上面我们强调了async函数的两个特性:一定返回Promise,和await只能在async函数中使用。为了更加深入的理解,我想我们有必要问一下为什么是这样的?

为什么需要区分异步操作与同步操作?(事件循环)

总有一些操作是耗时的,比如请求网络。在Java之类的语言里我们是通过多线程来完成的,但与此而来的数据冲突也是很麻烦的问题。而JS则使用了单线程加事件循环的思路,从根本上杜绝了程序员管理同步锁的麻烦。
但是,耗时操作怎么办?比如一个网络请求需要1s才能返回,那总不能让JS所有执行内容都在这1s内暂停吧?为此,JS的思路是这样的:所有的JS代码都只在一个线程内执行,当我们调用系统库发起网络请求时,它把我们调用的API函数立即返回,同时登记我们传入的请求参数和回调函数。之后,JS引擎异步的开启多线程来请求网络资源,在网络资源被返回后,再以返回的内容为参数调用我们的回调函数。
这告诉我们一个道理:JS代码的执行是不能卡死的。在正常的函数中如果写了一个东西,那么执行第一行结束就一定会马上执行下一行,不会在某一行上卡住。
实际上,JS中有一个“事件循环”的概念。我们写的各种回调函数,其本质都是往某个事件上注册了一个监听器;当我们需要的事件真的发生,我们之前注册过的函数就会因此被触发调用。
借用操作系统中内核态和用户态的概念,我也想把JS引擎分为“系统态”和“用户态”。JS调用栈的最底层肯定都是系统函数;而当系统调用了我们注册过的一个回调函数时,随着这个函数入栈,就是由系统态转移到了用户态,此时的控制权在我们手里,我们可以任意的执行各种代码、调用各种函数;而当我们最初被系统调用的那个函数返回的时候,就是我们把控制权还给了系统,又从用户态过渡到了系统态。由于单线程的特性,一旦处于用户态,系统就不能够打断我们的工作去做他自己的事情(比如调用某个回调函数)的;只有在我们把控制权交还给系统后,其他的回调函数才有可能被调用。

将回调函数封装为返回Promise形式

例如,我们可以构造一个函数,它返回一个在一定毫秒数之后resolve的Promise。我们可以用此来代替setTimeout:

1
2
3
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}

之后,只要这样就好:

1
2
3
4
5
async function yyy(){
console.log("start");
await delay(5000);
console.log("after 5s");
}

现在,来稍微想一下为什么

我们认为,async函数是一个语法糖,最终async函数还是用像同步函数一样的方法一行行执行的。那么,async在执行时实际的“同步函数”长什么样呢?
幸运的是,借助TypeScript的编译工具在ES5模式下编译,有助于我们看清上面的函数到底是什么情况。上面的yyy函数编译得到的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function yyy() {
return __awaiter(this, void 0, void 0, function () {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
console.log("start");
return [4 /*yield*/, delay(5000)];
case 1:
_a.sent();
console.log("after 5s");
return [2 /*return*/];
}
});
});
}

我们今天并不打算探讨__awaiter__generator具体是什么东西,只是用来帮助我们理解。
原本函数中的第一行console.log("start"),对应上面case 0的情况,很显然一上来执行的就是这个。随后是delay(5000),然而在这之后,我们直接从yyy函数中返回出去了!
返回的时候携带的是delay(5000)返回的Promise对象,还有一个数字4(是用来标示下次函数恢复时候应该从哪里起继续执行的。)
这也就是说,每当我们执行await的一瞬间,在同步函数意义下函数就已经被返回了! 控制权在此时被我们交还给了系统态,所以系统可以继续愉快的去做他的工作,不会因为我们的耗时操作而被卡死。
随后当5s钟过去、Promise被resolve的时候,我们的yyy函数会被系统再一次调用, 只是这次,携带的_a.label的值从0变成了1。于是case 1对应的代码console.log("after 5s");就被执行了。
这同时也解答了为什么只能在async函数中await的问题:普通的函数是不可能被编译成上面那种复杂的带case的形式的,而只有async函数,会根据里面有多少个await,编译出多少个case出来;
每次await都立即使函数在同步意义下返回、等到await的对象产生结果时再一次调用函数进入后面的case。

至于第二个问题:为什么async函数只能返回Promise,可以观察到,上面yyy函数一开始就是return __awaiter(xxx),这个__awaiter()函数返回的就是一个Promise对象。这里面的原理相对有点复杂(比如需要先讲generator的概念),我们这里在实现原理上就不做深究了。
只从道理简单的思考一点:不返回Promise是不可能的,因为async函数内需要await等待其他Promise的结果,所以在你调用函数的一刹那是几乎不可能产生确定的结果的(除非这个函数的函数体里面没有调用过任何await,那此时它就和普通函数没有任何区别了)。既然没有确定结果,那就只能返回Promise这种“将来的结果”了。

async/await怎么用

Promise模型,是一个在ES6就已经引入了的东西。如今,已有大量的npm包都提供了返回Promise的API,还有很多把其他的包封装成promise形式的包。比如我们上面的delay函数,其实npm上就有一个包叫delay,功能和我们上面写的一模一样;我现在写一些JS代码,建好项目后首先做的事之一就是npm install delay --save
只要你看到了返回Promise的API,就可以直接把需要调用这个API的函数改为async函数,然后在里面await调用它即可。
如今,很多人(至少包括我)都已经拒绝写回调函数形式的代码了,因为async/await和回调函数的形式相比真的简洁优雅又丝滑。但是对于早期已经以回调函数形式提供的API怎么办呢?我们只要把它封装为返回Promise的形式就好了。NodeJS原本的回调函数规范是(err, res),即回调函数的第一个参数永远是用来装错误的,第二个参数是结果;并且回调函数参数callback永远是作为API调用时的最后一个参数传进去的。
如果API符合这种形式,那我们可以直接用一个NodeJS提供的系统工具函数util.promisify来完成把它到Promise形式的转换,这个过程称为promisify

1
2
3
4
5
const util = require("util");
const fs = require("fs");
(async function () {
console.log(await util.promisify(fs.readdir)("./")); // 打印出当前文件所在目录的所有文件的文件名
})()

而如果不是这种形式,那就需要人来封装一下了。可以先去npm上看看有没有别人已经做好的包(比如delay那种的);
如果没有或者觉得封装一下太简单懒得去npm上找的话,那就自己手写封装吧。相信如果看懂了上面的delay函数的话,这件事并不太难。