Async 和 Await

Async 和 Await

最后更新于 2019-12-14

因为最近在复习相关内容,所以打算从头开始重新的梳理一下 asyncawait 的相关内容,主要包括它们是什么,有什么作用以及最后我们会来手动的实现一个简易版本的 async,那么我们就先从什么是 async 开始看起吧

Async

从字面意思上很好理解,async 是异步的意思,await 有等待的意思,而两者的用法上也是如此,async 用于申明一个 function 是异步的,而 await 用于等待一个异步方法执行完成,我们先来看看它是如何使用的,比如下面这个读取文件的例子

1
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
const fs = require('fs')

const readFile = function (fileName) {
return new Promise(function (resolve, reject) {
fs.readFile(fileName, function (error, data) {
if (error) return reject(error)
resolve(data)
})
})
}

// generator 函数写法
const gen = function* () {
const f1 = yield readFile('./1.txt')
const f2 = yield readFile('./2.txt')
console.log(f1.toString())
console.log(f2.toString())
}

// async 函数写法
const asyncReadFile = async function () {
const f1 = await readFile('./1.txt')
const f2 = await readFile('./2.txt')
console.log(f1.toString())
console.log(f2.toString())
}

我们特意声明了一个 generator 函数用来进行对比,对比可以发现 async 其实就是一个函数的修饰符,在异步处理上就是 generator 函数的语法糖,相比较于 generator 当中的 *yield,它的语义更为清楚一些,async 表示函数里有异步操作,await 表示紧跟在后面的表达式需要等待结果

下面我们就先来看看 async 的具体用法,async 的语法很简单,就是在函数开头加一个关键字

1
2
3
async function f() {
return 1
}

我们可以来试着直接调用一下

1
f()  // Promise { <resolved>: 1 }

可以发现,输出的是一个 Promise 对象,那么我们就可以推断出,如果在 async 函数中 return 一个直接量,async 会把这个直接量通过 resolve() 封装成 Promise 对象返回,那么针对上面的这个例子,如果在最外层不能用 await 获取其返回值的情况下,我们便可以使用针对 Promise 标准的处理方式来进行处理,即使用 then() 方法,如下

1
2
3
f().then((res) => {
console.log(res) // 1
})

通过输出的结果我们可以知道,其实 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 普通函数
function getSomething() {
return 'hello'
}

// async 函数
async function testAsync() {
return Promise.resolve('world')
}

async function test() {
const v1 = await getSomething()
const v2 = await testAsync()
console.log(v1, v2)
}

test() // hello world

那么这里就会存在一个问题,即为什么 await 关键词只能在 async 函数中使用呢?这是因为 async 函数返回的是一个 Promise 对象,必须等到内部所有 await 命令后面的 Promise 对象执行完,才会发生状态改变,而 await 操作符等的就是这样一个返回的结果,如果是同步的情况,那就直接返回了,但是在异步的情况下,await 会阻塞整一个流程,直到结果返回之后,才会继续下面的代码,如果希望多个请求可以并发执行,可以使用 Promise.all 或者 Promise.allSettled

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async function dbFuc(db) {
let docs = [{}, {}, {}]

// 这里会报错,因为 await 的上一级函数不是 async 函数
docs.forEach(function (doc) {
await db.post(doc)
})

// 针对于这种情况可以采用 for 循环或者使用数组的 reduce 方法,第一种,使用 for 循环
for (let doc of docs) {
await db.post(doc)
}

// 第二种,使用数组的 reduce 方法
await docs.reduce(async (_, doc) => {
await _
await db.post(doc)
}, undefined)
}

实战

在了解了 asyncawait 的基本概念以后,我们来通过一个示例加深一下理解,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
async function setTime(time) {
await setTimeout(() => { console.log(1) }, time)
}

async function log(val, time) {
await setTime(time)
console.log(val)
}

log(2, 3000)

// 2 ==> 立即输出
// 1 ==> 三秒后输出

通过观察可以发现,为何我们明明写了 await setTimeout,但是 log 函数却并没有等到 setTimeout 执行完毕后再打印?带着这个疑问我们先来看下面这几个例子

1
2
3
4
5
6
7
8
9
10
// 示例一
async function test() {
await setTimeout(() => console.log(1), 3000)
console.log(2)
}

test()

// 2 ==> 立即输出
// 1 ==> 三秒后输出

是不是和开头的示例类似,不急,我们接着往下看

1
2
3
4
5
6
7
8
9
// 示例二
async function test() {
await new Promise(resolve => setTimeout(() => console.log(1), 3000))
console.log(2)
}

test()

// 1 ==> 三秒后输出

这一次我们使用一个 new Promise()setTimeout 包裹了起来,但是并没有设置成功或者失败的回调,可以发现 console.log(2) 这一句并没有执行,再来简单的调整一下

1
2
3
4
5
6
7
8
9
10
// 示例三
async function test() {
await new Promise(resolve => setTimeout(() => resolve(console.log(1)), 3000))
console.log(2)
}

test()

// 1 ==> 三秒后输出
// 2 ==> 三秒后输出

这一次我们将 setTimeout 的结果放入到 resolve 回调当中,发现是可以达到我们预期的输出,首先 await 后面如果跟的是一个 Promise对象,所以它会去等该 promise resolve 后才会继续下面的执行,所以会在三秒后输出 12,以为这样就完了?我们接着往下看

1
2
3
4
5
6
7
8
9
10
// 示例四
async function test() {
await Promise.resolve(setTimeout(() => console.log(1), 3000))
console.log(2)
}

test()

// 2 ==> 立即输出
// 1 ==> 三秒后输出

我们这次没有使用 new Promise()setTimeout 包裹,而是直接使用 Promise.resolve(),发现结果又和开头部分的几个示例一样了,这是因为 await 后面是一个已经 resolvePromise,所以会直接进入到下一步

1
2
3
4
5
6
7
8
9
10
// 示例五
async function test() {
await Promise.all([setTimeout(() => console.log(1), 3000)])
console.log(2)
}

test()

// 2 ==> 立即输出
// 1 ==> 三秒后输出

我们这一次换成了 Promise.all,可以发现当 all 的数组元素不是 Promise 的时候会调用 Promise.resolve 方法进行包装,所以产生的结果与示例四是一样的,通过上面连续的几个示例我们可以发现,await 后面跟着的函数是会被立即调用的(非 Promise

1
2
3
4
5
6
7
8
9
10
11
async function test() {
await setTimeout(() => console.log(1), 1000)
await setTimeout(() => console.log(2), 3000)
await console.log(3)
}

test()

// 3 ==> 立即输出
// 1 ==> 一秒后输出
// 2 ==> 三秒后输出

但是这并不代表它们执行全是同步的,请看下列代码的输出

1
2
3
4
5
6
7
8
9
10
11
12
13
async function test() {
console.log(1)
await console.log(2)
console.log(4)
}

test()
console.log(3)

// 1
// 2
// 3
// 4

为什么会造成这样的结果呢,我们来将其稍微调整一下,就成为了下面的这个样子

1
2
3
4
5
6
7
8
async function foo() {
console.log(1)
await Promise.resolve(console.log(2))
console.log(4)
}

foo()
console.log(3)

这样写的话看起来就好理解了,先输出 1,然后发现了 await,又发现等待的不是 Promise 对象,所以就会调用 Promise.resolve 方法进行包装,然后就输出了 2,但是却会有个异步的过程,这样 3 就会被输出,最后在输出 4

Async 和 Await 的优势

严谨的说,async 是一种语法,Promise 是一个内置对象,两者并不具备可比性,更何况 async 函数实际上返回的也是一个 Promise 对象,所以下面我们就来看看几种异步处理方法之间的比较,其实在 ES6 之前,异步编程的方法,大概有下面这几种

  • 回调函数
  • 事件监听
  • 发布/订阅

不过今天我们不会介绍这些,我们今天主要来看 Promisegenerator 函数与 async 函数的比较,如果想了解其他部分可以自行查阅相关知识点,我们还是以一个示例进行比较,我们假定某个 DOM 元素上面,部署了一系列的动画,前一个动画结束,才能开始后一个,如果当中有一个动画出错,就不再往下执行,返回上一个成功执行的动画的返回值,先来看看 Promise 的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function chainAnimationsPromise(elem, animations) {
let ret = null // 变量 ret 用来保存上一个动画的返回值
let p = Promise.resolve() // 新建一个空的 promise
for (let anim of animations) { // 使用 then 方法,添加所有动画
p = p.then(function (val) {
ret = val
return anim(elem)
})
}
return p.catch(function (e) { // 返回一个部署了错误捕捉机制的 promise
/* 忽略错误,继续执行 */
}).then(function () {
return ret
})
}

一眼看上去,代码完全都是 Promise 的相关 APIthencatch 等等),操作本身的语义反而不容易看出来,下面我们再来看看 generator 函数的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
function chainAnimationsGenerator(elem, animations) {
return spawn(function* () {
let ret = null
try {
for (let anim of animations) {
ret = yield anim(elem)
}
} catch (e) {
/* 忽略错误,继续执行 */
}
return ret
})
}

可以发现 generator 函数的写法语义比 Promise 写法更清晰,用户定义的操作全部都出现在 spawn 函数的内部,但是问题在于必须有一个任务运行器来自动执行 generator 函数,上面代码的 spawn 函数就是自动执行器,它返回一个 Promise 对象,而且必须保证 yield 语句后面的表达式,必须返回一个 Promise,最后我们再来看看 async 函数的写法

1
2
3
4
5
6
7
8
9
10
11
async function chainAnimationsAsync(elem, animations) {
let ret = null
try {
for (let anim of animations) {
ret = await anim(elem)
}
} catch (e) {
/* 忽略错误,继续执行 */
}
return ret
}

可以发现 async 函数的实现最简洁,最符合语义,几乎没有语义不相关的代码,但是也不要为了使用 async 而去使用 async,所有的异步处理方法存在即合理,没有那个最好,只有最合适,在处理不同的实际情况时,我们选择最适合的处理方法即可

错误处理

Promise 并不是只有一种 resolve,还有一种 reject 的情况,而 await 只会等待一个结果,那么发生错误了该怎么处理呢?一般有两种方式来进行处理,第一种就是用 try-catch 来做错误捕捉,如果 await 命令后面跟的是 Promise 对象,并且运行结果可能是 rejected 的话,最好把 await 命令放在 try-catch 代码块中

1
2
3
4
5
6
7
8
9
async function test() {
try {
await Promise.reject('1')
} catch (err) {
console.log(err)
}
}

test() // 1

第二种就是用 Promisecatch 来做错误捕捉

1
2
3
4
5
6
async function test() {
await Promise.reject('1').catch((err) => {
console.log(err)
})
}
test() // 1

简单实现

我们下面来看一下如何手动的来实现一个 async,其实简单来说,async 函数的实现原理,就是将 generator 函数和自动执行器,包装在一个函数里,比如下面这样

1
2
3
4
5
6
7
8
9
10
11
async function fn(args) {
// ...
}

// 等同于 ==>

function fn(args) {
return spawn(function* () {
// ...
})
}

其实所有的 async 函数都可以写成上面的第二种形式,其中的 spawn 函数就是自动执行器,不过还是老规矩,我们先从一个示例开始看起,如下

1
2
3
4
5
6
7
8
9
10
11
12
const getData = () => new Promise(resolve => setTimeout(() => resolve('data'), 1000))

async function test() {
const data = await getData()
console.log('data: ', data)
const data2 = await getData()
console.log('data2: ', data2)
return 'success'
}

// 1 秒后打印 data,再过一秒打印 data2,最后打印 success
test().then(res => console.log(res))

我们将其改为 generator 的方式是下面这样的

1
2
3
4
5
6
7
function* testG() {
const data = yield getData()
console.log('data: ', data)
const data2 = yield getData()
console.log('data2: ', data2)
return 'success'
}

但是我们都知道,generator 函数是不会自动执行的,需要我们每一次手动的去调用它的 next 方法,才会停留在下一个 yield 的位置,所以利用这个特性,我们只要编写一个自动执行的函数,就可以让这个 generator 函数完全实现 async 函数的功能,也就是下面这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
const getData = () => new Promise(resolve => setTimeout(() => resolve('data'), 1000))

var test = spawn(
function* testG() {
const data = yield getData()
console.log('data: ', data)
const data2 = yield getData()
console.log('data2: ', data2)
return 'success'
}
)

test().then(res => console.log(res))

是不是有点思路了,spawn 方法接受一个 generator 函数,返回一个 Promise,下面我们就来看看 spawn 函数的具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function spawn(genF) {
return new Promise(function (resolve, reject) { // 返回的是一个 promise
const gen = genF() // 直接调用来生成迭代器
function step(nextF) {
let next
try { // 包裹在 try-catch 中,如果报错了就把 promise 给 reject 掉,外部就可以通过 .catch 获取到错误
next = nextF() // next 的结果是一个 { value, done } 的结构
} catch (e) {
return reject(e)
}
if (next.done) { // 如果已经完成,就直接 resolve 这个 promise
return resolve(next.value)
}
Promise.resolve(next.value).then(function (v) { // 除了最后结束的时候外,每次调用 .next(),其实是返回的都是 { value: Promise, done: false } 的结构
step(function () { return gen.next(v) }) // 只要 done 不是 true 的时候,就会递归的往下解开 promise
}, function (e) {
step(function () { return gen.throw(e) })
})
}
step(function () { return gen.next(undefined) }) // 开启
})
}

其实也就是我们经常听闻的 Node.js 中的 co 模块 的实现方式,它的目的也是为了 generator 函数的自动执行

顶层 Await

我们在上面曾介绍到说 await 关键词只能在 async 函数中使用,否则都会报错,但是现在有一个 语法提案(目前提案处于 Status: Stage 3),允许在模块的顶层独立使用 await 命令,这个提案的目的,是借用 await 解决模块异步加载的问题,比如下面这种情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// awaiting.js,模块的输出值 output 取决于异步操作
let output
(async function main() {
const dynamic = await import(someMission)
const data = await fetch(url)
output = someProcess(dynamic.default, data)
})()
export { output }


// usage.js,加载 awaiting.js 的模块
import { output } from './awaiting.js'

function outputPlusValue(value) { return output + value }
console.log(outputPlusValue(100))
setTimeout(() => console.log(outputPlusValue(100), 1000)

如果运行了可以发现,outputPlusValue() 的执行结果,完全取决于执行的时间,如果 awaiting.js 里面的异步操作没执行完,加载进来的 output 的值就是 undefined,目前的解决方法是让原始模块输出一个 Promise 对象,从这个 Promise 对象判断异步操作有没有结束

1
2
3
4
5
6
7
8
// usage.js
import promise, { output } from './awaiting.js'

function outputPlusValue(value) { return output + value }
promise.then(() => {
console.log(outputPlusValue(100))
setTimeout(() => console.log(outputPlusValue(100)), 1000)
})

上面代码中,将 awaiting.js 对象的输出,放在 promise.then() 里面,这样就能保证异步操作完成以后,才去读取 output,但是这种写法比较麻烦,等于要求模块的使用者遵守一个额外的使用协议,按照特殊的方法使用这个模块,一旦你忘了要用 Promise 加载,只使用正常的加载方法,依赖这个模块的代码就可能出错,而且如果上面的 usage.js 又有对外的输出,等于这个依赖链的所有模块都要使用 Promise 加载

但是如果有了顶层 await 以后就简单了许多,顶层的 await 命令,它会保证只有异步操作完成,模块才会输出值

1
2
3
4
5
6
7
8
9
10
11
12
// awaiting.js
const dynamic = import(someMission)
const data = fetch(url)
export const output = someProcess((await dynamic).default, await data)


// usage.js
import { output } from './awaiting.js'
function outputPlusValue(value) { return output + value }

console.log(outputPlusValue(100))
setTimeout(() => console.log(outputPlusValue(100)), 1000)

上面代码中,两个异步操作在输出的时候,都加上了 await 命令,只有等到异步操作完成,这个模块才会输出值,下面再来看几个顶层 await 的一些使用场景

1
2
3
4
5
6
7
8
9
10
11
12
13
// import() 方法加载
const strings = await import(`/i18n/${navigator.language}`)

// 数据库操作
const connection = await dbConnector()

// 依赖回滚
let jQuery
try {
jQuery = await import('https://cdn-a.com/jQuery')
} catch {
jQuery = await import('https://cdn-b.com/jQuery')
}

另外如果加载多个包含顶层 await 命令的模块,加载命令是同步执行的

1
2
3
4
5
6
7
8
9
10
11
12
// x.js
console.log('X1')
await new Promise(r => setTimeout(r, 1000))
console.log('X2')

// y.js
console.log('Y')

// z.js
import './x.js'
import './y.js'
console.log('Z')

上面代码有三个模块,最后的 z.js 加载 x.jsy.js,打印结果是 X1YX2Z,这说明 z.js 并没有等待 x.js 加载完成再去加载 y.js,顶层 await 命令有点像交出代码的执行权给其他的模块加载,等异步操作完成后,再拿回执行权,继续向下执行

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×