# 什么是 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 两个参数,而这两个参数的类型也都是函数类型。

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 关键字的函数。例如:

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

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

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

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

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

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

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

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

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

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

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

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

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

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:

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

之后,只要这样就好:

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

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

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

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

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

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

更新于