最后更新于
2019-12-14
因为最近在复习相关内容,所以打算从头开始重新的梳理一下 async
和 await
的相关内容,主要包括它们是什么,有什么作用以及最后我们会来手动的实现一个简易版本的 async
,那么我们就先从什么是 async
开始看起吧
Async
从字面意思上很好理解,async
是异步的意思,await
有等待的意思,而两者的用法上也是如此,async
用于申明一个 function
是异步的,而 await
用于等待一个异步方法执行完成,我们先来看看它是如何使用的,比如下面这个读取文件的例子
1 | const fs = require('fs') |
我们特意声明了一个 generator
函数用来进行对比,对比可以发现 async
其实就是一个函数的修饰符,在异步处理上就是 generator
函数的语法糖,相比较于 generator
当中的 *
和 yield
,它的语义更为清楚一些,async
表示函数里有异步操作,await
表示紧跟在后面的表达式需要等待结果
下面我们就先来看看 async
的具体用法,async
的语法很简单,就是在函数开头加一个关键字
1 | async function f() { |
我们可以来试着直接调用一下
1 | f() // Promise { <resolved>: 1 } |
可以发现,输出的是一个 Promise
对象,那么我们就可以推断出,如果在 async
函数中 return
一个直接量,async
会把这个直接量通过 resolve()
封装成 Promise
对象返回,那么针对上面的这个例子,如果在最外层不能用 await
获取其返回值的情况下,我们便可以使用针对 Promise
标准的处理方式来进行处理,即使用 then()
方法,如下
1 | f().then((res) => { |
通过输出的结果我们可以知道,其实 async
函数内部 return
语句返回的值,会成为 then
方法回调函数的参数,但是我们思考一下,如果 async
函数没有返回值,那又会如何处理呢?其实很简单,它会直接返回 resolve(undefined)
,因为如果在没有 await
的情况下去执行 async
函数,它会立即执行返回一个 Promise
对象,并且绝不会阻塞后面的语句,这和普通返回 Promise
对象的函数并无二致,在简单了解完 async
以后,下面我们在来看看 await
这个关键字
Await
关键词 await
是等待的意思,那么它在等待什么呢?根据 MDN 可知,await
操作符用于等待一个 Promise
对象或者任何要等待的值,它会返回 Promise
对象的处理结果,如果等待的不是 Promise
对象,await
会把该值转换为已正常处理的 Promise
,然后等待其处理结果,基本语法如下
1 | [return_value] = await expression |
这里有一个需要注意的地方,那就是 await
所要等待的并不一定要是 Promise
对象,后面实际是可以接普通函数调用或者直接量的
1 | // 普通函数 |
那么这里就会存在一个问题,即为什么 await
关键词只能在 async
函数中使用呢?这是因为 async
函数返回的是一个 Promise
对象,必须等到内部所有 await
命令后面的 Promise
对象执行完,才会发生状态改变,而 await
操作符等的就是这样一个返回的结果,如果是同步的情况,那就直接返回了,但是在异步的情况下,await
会阻塞整一个流程,直到结果返回之后,才会继续下面的代码,如果希望多个请求可以并发执行,可以使用 Promise.all
或者 Promise.allSettled
1 | async function dbFuc(db) { |
实战
在了解了 async
和 await
的基本概念以后,我们来通过一个示例加深一下理解,如下
1 | async function setTime(time) { |
通过观察可以发现,为何我们明明写了 await setTimeout
,但是 log
函数却并没有等到 setTimeout
执行完毕后再打印?带着这个疑问我们先来看下面这几个例子
1 | // 示例一 |
是不是和开头的示例类似,不急,我们接着往下看
1 | // 示例二 |
这一次我们使用一个 new Promise()
将 setTimeout
包裹了起来,但是并没有设置成功或者失败的回调,可以发现 console.log(2)
这一句并没有执行,再来简单的调整一下
1 | // 示例三 |
这一次我们将 setTimeout
的结果放入到 resolve
回调当中,发现是可以达到我们预期的输出,首先 await
后面如果跟的是一个 Promise
对象,所以它会去等该 promise resolve
后才会继续下面的执行,所以会在三秒后输出 1
和 2
,以为这样就完了?我们接着往下看
1 | // 示例四 |
我们这次没有使用 new Promise()
将 setTimeout
包裹,而是直接使用 Promise.resolve()
,发现结果又和开头部分的几个示例一样了,这是因为 await
后面是一个已经 resolve
的 Promise
,所以会直接进入到下一步
1 | // 示例五 |
我们这一次换成了 Promise.all
,可以发现当 all
的数组元素不是 Promise
的时候会调用 Promise.resolve
方法进行包装,所以产生的结果与示例四是一样的,通过上面连续的几个示例我们可以发现,await
后面跟着的函数是会被立即调用的(非 Promise
)
1 | async function test() { |
但是这并不代表它们执行全是同步的,请看下列代码的输出
1 | async function test() { |
为什么会造成这样的结果呢,我们来将其稍微调整一下,就成为了下面的这个样子
1 | async function foo() { |
这样写的话看起来就好理解了,先输出 1
,然后发现了 await
,又发现等待的不是 Promise
对象,所以就会调用 Promise.resolve
方法进行包装,然后就输出了 2
,但是却会有个异步的过程,这样 3
就会被输出,最后在输出 4
Async 和 Await 的优势
严谨的说,async
是一种语法,Promise
是一个内置对象,两者并不具备可比性,更何况 async
函数实际上返回的也是一个 Promise
对象,所以下面我们就来看看几种异步处理方法之间的比较,其实在 ES6
之前,异步编程的方法,大概有下面这几种
- 回调函数
- 事件监听
- 发布/订阅
不过今天我们不会介绍这些,我们今天主要来看 Promise
、generator
函数与 async
函数的比较,如果想了解其他部分可以自行查阅相关知识点,我们还是以一个示例进行比较,我们假定某个 DOM
元素上面,部署了一系列的动画,前一个动画结束,才能开始后一个,如果当中有一个动画出错,就不再往下执行,返回上一个成功执行的动画的返回值,先来看看 Promise
的写法
1 | function chainAnimationsPromise(elem, animations) { |
一眼看上去,代码完全都是 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
函数的实现最简洁,最符合语义,几乎没有语义不相关的代码,但是也不要为了使用 async
而去使用 async
,所有的异步处理方法存在即合理,没有那个最好,只有最合适,在处理不同的实际情况时,我们选择最适合的处理方法即可
错误处理
Promise
并不是只有一种 resolve
,还有一种 reject
的情况,而 await
只会等待一个结果,那么发生错误了该怎么处理呢?一般有两种方式来进行处理,第一种就是用 try-catch
来做错误捕捉,如果 await
命令后面跟的是 Promise
对象,并且运行结果可能是 rejected
的话,最好把 await
命令放在 try-catch
代码块中
1 | async function test() { |
第二种就是用 Promise
的 catch
来做错误捕捉
1 | async function test() { |
简单实现
我们下面来看一下如何手动的来实现一个 async
,其实简单来说,async
函数的实现原理,就是将 generator
函数和自动执行器,包装在一个函数里,比如下面这样
1 | async function fn(args) { |
其实所有的 async
函数都可以写成上面的第二种形式,其中的 spawn
函数就是自动执行器,不过还是老规矩,我们先从一个示例开始看起,如下
1 | const getData = () => new Promise(resolve => setTimeout(() => resolve('data'), 1000)) |
我们将其改为 generator
的方式是下面这样的
1 | function* testG() { |
但是我们都知道,generator
函数是不会自动执行的,需要我们每一次手动的去调用它的 next
方法,才会停留在下一个 yield
的位置,所以利用这个特性,我们只要编写一个自动执行的函数,就可以让这个 generator
函数完全实现 async
函数的功能,也就是下面这样的
1 | const getData = () => new Promise(resolve => setTimeout(() => resolve('data'), 1000)) |
是不是有点思路了,spawn
方法接受一个 generator
函数,返回一个 Promise
,下面我们就来看看 spawn
函数的具体实现
1 | function spawn(genF) { |
其实也就是我们经常听闻的 Node.js
中的 co 模块 的实现方式,它的目的也是为了 generator
函数的自动执行
顶层 Await
我们在上面曾介绍到说 await
关键词只能在 async
函数中使用,否则都会报错,但是现在有一个 语法提案(目前提案处于 Status: Stage 3
),允许在模块的顶层独立使用 await
命令,这个提案的目的,是借用 await
解决模块异步加载的问题,比如下面这种情况
1 | // awaiting.js,模块的输出值 output 取决于异步操作 |
如果运行了可以发现,outputPlusValue()
的执行结果,完全取决于执行的时间,如果 awaiting.js
里面的异步操作没执行完,加载进来的 output
的值就是 undefined
,目前的解决方法是让原始模块输出一个 Promise
对象,从这个 Promise
对象判断异步操作有没有结束
1 | // usage.js |
上面代码中,将 awaiting.js
对象的输出,放在 promise.then()
里面,这样就能保证异步操作完成以后,才去读取 output
,但是这种写法比较麻烦,等于要求模块的使用者遵守一个额外的使用协议,按照特殊的方法使用这个模块,一旦你忘了要用 Promise
加载,只使用正常的加载方法,依赖这个模块的代码就可能出错,而且如果上面的 usage.js
又有对外的输出,等于这个依赖链的所有模块都要使用 Promise
加载
但是如果有了顶层 await
以后就简单了许多,顶层的 await
命令,它会保证只有异步操作完成,模块才会输出值
1 | // awaiting.js |
上面代码中,两个异步操作在输出的时候,都加上了 await
命令,只有等到异步操作完成,这个模块才会输出值,下面再来看几个顶层 await
的一些使用场景
1 | // import() 方法加载 |
另外如果加载多个包含顶层 await
命令的模块,加载命令是同步执行的
1 | // x.js |
上面代码有三个模块,最后的 z.js
加载 x.js
和 y.js
,打印结果是 X1
、Y
、X2
、Z
,这说明 z.js
并没有等待 x.js
加载完成再去加载 y.js
,顶层 await
命令有点像交出代码的执行权给其他的模块加载,等异步操作完成后,再拿回执行权,继续向下执行