当前位置: 代码迷 >> 综合 >> 异曲神工--JavaScript中的 async 函数(ES2017)
  详细解决方案

异曲神工--JavaScript中的 async 函数(ES2017)

热度:29   发布时间:2023-10-17 03:47:57.0

前言

本文是叙述JS异步操作的最后一篇,随着时间的推移,JS中实现异步操作的方案已与日俱增,从一开始的回调函数、发布/订阅、事件监听、promise,到后来的 Generator 函数,以及本文撰述的 async 函数。
JS中异步操作的手段的百花齐放,已经使得异步操作变得越来越方便。


一、首先来大概了解一下 async 函数:

async 函数是 Generator 函数的语法糖

前文有一个 Generator 函数,依次读取两个文件,如下:

const fs = require('fs')const readFile = function (fileName) {return new Promise((resolve, reject) => {fs.readFile(fileName, (error, data) => {if (error) return reject(error)resolve(data)})})
}const gen = function* () {const f1 = yield readFile('/mc/show')const f2 = yield readFile('/mc/shells')console.log(f1.toString())console.log(f2.toString())
}

现在我们把上买代码的 Generator 函数写成 async 函数:

const asyncReadFile = async function () {const f1 = await readFile('/etc/fstab')const f2 = await readFile('/etc/shells')console.log(f1.toString())console.log(f2.toString())
}

对比二者,很容易发现,相比 Generator 函数,async 函数无非是:

1、星号( * )替换为 =>    async
2、yield     替换为 =>    await

除此之外并无差别,因此可以结合 Generator 函数对 async 函数做理解。

而 async 函数对 Generator 函数的改进,体现在以下四点:

1、内置执行器

Generator 函数的执行必须靠执行器,所以才有了 co 模块,但 async 函数是自带执行器的,async 函数的执行,与普通函数无二,只要一行。

asyncReadFile()

调用 asyncReadFile 函数,它就会自动执行,输出最后结果。相比 Generator 函数调用 next 方法、使用 co模块才能执行的冗沉,简洁度不可同日而语。

2、更好的语义

async 和 await,比起星号和 yield,语义更加清楚。

async表示函数中有异步操作,await 表示紧跟在后面的表达式需要等待结果。

3、更广的适用性

co 模块约定, yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面, 可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。

4、返回值是 Promise

async 函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便太多。

你可以用 then 方法指定下一步的操作。进一步说,async函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而 await 命令就是内部 then 命令的语法糖。

二、接下来是 async 的基本用法

async 函数返回一个 Promise 对象,可以使用 then 方法添加回调函数。当函数执行的时候,一旦遇到 await 就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。

下面是一个例子。

async function getPriceByName(name) {const symbol = await getSymbol(name)const Price = await getPrice(symbol)return Price
}getPriceByName('goog').then((result) => {console.log(result)
})

上面代码是一个获取价格的函数,函数前面的 async 关键字,表明该函数内部有异步操作。调用该函数时,会立即返回一个  Promise 对象。下面的代码是另一个实例,指定多少毫秒后输出一个值。

function timeout(ms) {return new Promise((resolve) => {setTimeout(resolve, ms)})
}async function asyncPrint(value, ms) {await timeout(ms)console.log(value)
}asyncPrint('hello world', 50)// 上面代码指定 50 毫秒以后,输出hello world

async 函数有多种使用形式:

// 1、 函数声明
async function foo() {}// 2、 函数表达式
const foo = async function () {}// 3、 对象的方法
let obj = { async foo() {} }
obj.foo().then(...)// 4、 Class 的方法
class Storage {constructor() {this.cachePromise = caches.open('avatars')}async getAvatar(name) {const cache = await this.cachePromise;return cache.match(`/avatars/${name}.jpg`)}
}const storage = new Storage()
storage.getAvatar('jake').then(…)// 5、 箭头函数
const foo = async () => {}

语法

async 函数的语法规则总体上比较简单,但可能错误处理机制那边会稍微复杂一些。

1、返回 Promise 对象

async 函数返回一个 Promise 对象,其内部 return 语句返回的值,会成为 then 方法回调函数的参数。

async function f() {return 'hello world'
}f().then(v => console.log(v))
// "hello world"

上面代码中,函数 f 内部 return 命令返回的值,会被 then 方法回调函数接收到。

async 函数内部抛出错误,会导致返回的 Promise 对象变为 reject 状态。抛出的错误对象会被 catch 方法回调函数接收到。

async function f() {throw new Error('出错了')
}f().then(v => console.log(v),e => console.log(e)
)// Error: 出错了

Promise 对象的状态变化

async 函数返回的 Promise 对象,必须等到内部所有的 await 命令后面的 Promise 对象执行完,才会发生状态改变,除非遇到return 语句或者抛出错误。

也就是说,只有 async 函数内部的异步操作执行完,才会执行 then 方法指定的回调函数。下面是一个例子:

async function getTitle(url) {let response = await fetch(url)let html = await response.text()return html.match(/<title>([\s\S]+)<\/title>/i)[1]
}getTitle('https://www.csdn.net/').then(console.log)// "CSDN-专业IT技术社区"

上面代码中,函数 getTitle 内部有三个操作:抓取网页、取出文本、匹配页面标题。只有这三个操作全部完成,才会执行 then 方法里面的 console.log。

await 命令

正常情况下,await命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值。

async function f() {// 等同于// return 123return await 123
}f().then(v => console.log(v))
// 123

上面代码中,await 命令的参数是数值 123,这时等同于 return 123。

另一种情况是,await命令后面是一个 thenable 对象 (即定义 then 方法的对象),那么 await 会将其等同于 Promise 对象。

class Sleep {constructor(timeout) {this.timeout = timeout}then(resolve, reject) {const startTime = Date.now()setTimeout(() => resolve(Date.now() - startTime),this.timeout)}
}(async () => {const sleepTime = await new Sleep(1000)console.log(sleepTime)
})()
// 1000

上面代码中,await 命令后面是一个 Sleep 对象的实例。这个实例不是 Promise 对象,但是因为定义了 then 方法, await 会将其视作 Promise 处理。

这个例子还演示了如何实现休眠效果,之前JavaScript 一直没有休眠的语法,但是借助 await 命令就可以让程序停顿指定的时间。下面给出了一个简化的 sleep 实现。

function sleep(interval) {return new Promise(resolve => {setTimeout(resolve, interval)})
}// 用法
async function one2FiveInAsync() {for(let i = 1; i <= 5; i++) {console.log(i)await sleep(1000)}
}one2FiveInAsync()

await 命令后面的 Promise 对象如果变为 reject 状态,则 reject 的参数就会被 catch 方法的回调函数接收到。

async function f() {await Promise.reject('出错了')
}f()
.then(v => console.log(v))
.catch(e => console.log(e))
// 出错了

上面的代码中,await 语句前面没有 return ,但是 reject 方法的参数依然传入了 catch 方法的回调函数。这里如果在 await 前面加上 return,效果是一样的。

任何一个 await 语句后面的 Promise 对象变为 reject 状态,那么整个 async 函数都会中断执行。

async function f() {await Promise.reject('出错了')await Promise.resolve('hello world') // 不会执行
}

上面代码中,第二个 await 语句是不会执行的,因为第一个 await 语句状态变成了 reject。

但在有些场景里,要求即便前一个异步操作失败,也不要中断后面的异步操作。这时可以将第一个 await 放在 try...catch 结构里面,这样不管是这个异步操作是否成功,第二个 await 都会执行。

async function f() {try {await Promise.reject('出错了')} catch(e) {}return await Promise.resolve('hello world')
}f()
.then(v => console.log(v))
// hello world

另一种方法是,await 后面的 Promise 对象再跟一个 catch 方法,处理前面可能出现的错误。

async function f() {await Promise.reject('出错了').catch(e => console.log(e))return await Promise.resolve('hello world')
}f()
.then(v => console.log(v))
// 出错了
// hello world

最后是错误处理

如果 await 后面的异步操作出错, 那么等同于 async 函数返回的 Promise 对象被 reject。

async function f() {await new Promise(function (resolve, reject) {throw new Error('出错了')})
}f()
.then(v => console.log(v))
.catch(e => console.log(e))
// Error:出错了

上面代码中,async 函数 f 执行后,await 后面的 Promise 对象会抛出一个错误对象,导致 catch 方法的回调函数被调用,它的参数就是抛出的错误对象。

防止出错的方法,也是将其放在 try...catch代码块之中。

async function f() {try {await new Promise(function (resolve, reject) {throw new Error('出错了')})} catch(e) {}return await('hello world')
}

如果有多个 await 命令,可以统一放在 try...catch 结构中。

async function main() {try {const val1 = await firstStep()const val2 = await secondStep(val1)const val3 = await thirdStep(val1, val2)console.log('Final: ', val3)}catch (err) {console.error(err)}
}

下面的例子使用 try...catch 结构,实现多次重复尝试。

const superagent = require('superagent')
const NUM_RETRIES = 3async function test() {let ifor (i = 0; i < NUM_RETRIES; ++i) {try {await superagent.get('http://google.com/this-throws-an-error')break} catch(err) {}}console.log(i) // 3
}test()

上面代码中,如果 await 操作成功,就会使用 break 语句退出循环;如果失败,会被 catch 语句捕获,然后进入下一轮循环。

使用async 函数时需要注意:

1、前面已经说过,await 命令后面的 Promise 对象,运行结果可能是 rejected,所以最好把 await 命令放在 try...catch 代码块中

async function myFunction() {try {await somethingThatReturnsAPromise()} catch (err) {console.log(err)}
}// 另一种写法async function myFunction() {await somethingThatReturnsAPromise().catch(function (err) {console.log(err)})
}

2、多个 await 命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。

let foo = await getFoo()
let bar = await getBar()

上面代码中,getFoo 和 getBar 是两个独立的异步操作(互相不依赖),被写成继发关系。这样比较耗时,因为只有 getFoo 完成以后,才会执行 getBar,完全可以让它们同时触发。

let [foo, bar] = await Promise.all([getFoo(), getBar()])// 让 getFoo 和 getBar 同时触发,缩短了程序的执行时间

3、和 Generator 函数的 yield 类似,async 的 await 命令只能用在 async 函数之中,如果用在普通函数,就会报错。

async function dbFuc(db) {let docs = [{}, {}, {}]// 报错docs.forEach(function (doc) {await db.post(doc)})
}

上面代码会报错,因为await用在普通函数之中了。但是,如果将forEach方法的参数改成async函数,也有问题。

function dbFuc(db) {//这里不需要 asynclet docs = [{}, {}, {}]// 可能得到错误结果docs.forEach(async function (doc) {await db.post(doc)})}

上面的代码不会正常运行,因为这时三个 db.post 操作将是并发执行,也就是同时执行,而不是继发执行。正确的写法是采用 for 循环:

async function dbFuc(db) {let docs = [{}, {}, {}]for (let doc of docs) {await db.post(doc)}
}

如果确实希望多个请求并发执行,可以使用 Promise.all方法。当三个请求都会 resolved 时,下面两种写法效果相同。

async function dbFuc(db) {let docs = [{}, {}, {}];let promises = docs.map((doc) => db.post(doc));let results = await Promise.all(promises);console.log(results);
}// 或者使用下面的写法async function dbFuc(db) {let docs = [{}, {}, {}];let promises = docs.map((doc) => db.post(doc));let results = [];for (let promise of promises) {results.push(await promise);}console.log(results);
}

4、async 函数可以保留运行堆栈。

const a = () => {b().then(() => c())
}

上面代码中,函数a内部运行了一个异步任务b()。当b()运行的时候,函数a()不会中断,而是继续执行。等到b()运行结束,可能a()早就运行结束了,b()所在的上下文环境已经消失了。如果b()c()报错,错误堆栈将不包括a()

现在将这个例子改成async函数。

const a = async () => {await b()c()
}

上面代码中,b()运行的时候,a()是暂停执行,上下文环境都保存着。一旦b()c()报错,错误堆栈将包括a()

async 函数的实现原理

简单的说,就是将 Generator 函数和自动执行器,包装在一个函数里。

async function fn(args) {// ...
}// 等同于function fn(args) {return spawn(function* () {// ...})
}

所有的 async 函数都可以写成上面的第二种形式,其中的 spawn 函数就是自动执行器。

下面给出 spawn 函数的实现,基本就是前文自动执行器的翻版。

function spawn(genF) {return new Promise((resolve, reject) => {const gen = genF()function step(nextF) {let nexttry {next = nextF()} catch(e) {return reject(e)}if(next.done) {return resolve(next.value)}Promise.resolve(next.value).then((v) => {step(function() { return gen.next(v) })}, function(e) {step(function() { return gen.throw(e) })})}step(function() { return gen.next(undefined) })})
}

与其他异步处理方法的比较

接下来看以下 async 函数与 Promise、Generator 函数的比较:

有这样一个场景:

假定某个 DOM 元素上面,部署了一系列的动画,前一个动画结束,才能开始后一个。

如果当中有一个动画出错,就不再往下执行,返回上一个成功执行的动画的返回值。

先是 Promise :

function chainAnimationsPromise(elem, animations) {// 变量ret用来保存上一个动画的返回值let ret = null// 新建一个空的Promiselet p = Promise.resolve()// 使用then方法,添加所有动画for(let anim of animations) {p = p.then(function(val) {ret = valreturn anim(elem)})}// 返回一个部署了错误捕捉机制的Promisereturn p.catch(function(e) {/* 忽略错误,继续执行 */}).then(function() {return ret})}可以看到虽然 Promise 的写法,比回调函数的写法有极大的改进,但是一眼看上去,代码完全都是 Promise 的 API(then、catch等等)。
操作本身的语义反而不容易看出来。

接着是 Generator 函数的写法:

function chainAnimationsGenerator(elem, animations) {return spawn(function*() {let ret = nulltry {for(let anim of animations) {ret = yield anim(elem)}} catch(e) {/* 忽略错误,继续执行 */}return ret})}

上面代码使用 Generator 函数遍历了每个动画,语义比 Promise 写法更清晰,用户定义的操作全部都出现在 spawn 函数的内部。

但是这个写法的问题在于:

必须有一个任务运行期,自动执行 Generator 函数,上面代码的 spawn 函数就是自动执行器,他返回一个 Promise 对象,而且必须保证 yield 语句后面的表达式,返回的必须是一个 Promise。

最后是 async 函数的写法:

async function chainAnimationsAsync(elem, animations) {let ret = nulltry {for(let anim of animations) {ret = await anim(elem)}} catch(e) {/* 忽略错误,继续执行 */}return ret
}

可以看到 Async 函数的实现最简洁,最符合语义,几乎没有语义不相关的代码

它将 Generator 写法中的自动执行器,改在语言层面提供,不暴露给用户,因此代码量最少。如果使用 Generator 写法,自动执行器需要用户自己提供。

实例:按顺序完成异步操作

实际开发中,经常遇到一组异步操作,需要按照顺序完成。比如,依次远程读取一组 URL,然后按照读取的顺序输出结果。

Promise 的实现:

function logInOrder(urls) {// 远程读取所有URLconst textPromises = urls.map(url => {return fetch(url).then(response => response.text())})// 按次序输出textPromises.reduce((chain, textPromise) => {return chain.then(() => textPromise).then(text => console.log(text))}, Promise.resolve())
}

上面代码使用 fetch 方法,同时远程读取一组 URL。每个 fetch 操作都返回一个 Promise 对象,放入 testPromise 数组。然后,reduce 方法依次处理每个 Promise 对象,然后使用then,将所有 Promise 对象连起来,因此就可以依次输出结果。

这种写法不太直观,可读性比较差。

下面是 async 函数实现:

async function logInOrder(urls) {for (const url of urls) {const response = await fetch(url)console.log(await response.text())}
}

上面代码确实大大简化,问题是所有远程操作都是继发。只有前一个 URL 返回结果,才会去读取下一个 URL,这样做效率很差,非常浪费时间。我们需要的是并发发出远程请求。

async function logInOrder(urls) {// 并发读取远程URLconst textPromises = urls.map(async url => {const response = await fetch(url)return response.text()})// 按次序输出for (const textPromise of textPromises) {console.log(await textPromise)}}

上面代码中,虽然 map 方法的参数是 async 函数,但它是并发执行的,因为只有 async 函数内部是继发执行,外部不受影响。后面的 for...of 循环内部使用了 await,因此实现了按顺序输出。

  相关解决方案