细读 ES6 | async/await

发布于 2023-05-13 22:28:28 字数 16413 浏览 72 评论 0

配图源自 Freepik

在前一篇文章中,最后提到 Generator 的应用,很实际场景可能需要自实现一个 Generator 函数执行器。因此,用起来还是很麻烦的。现在有另外一个替代方法,那就是 ES7 标准中引入的 async 函数,它使得异步操作变得更加方便。

Async 函数,其实就是 Generator 函数的语法糖。

一、语法

定义一个 Async 函数语法非常简单,在函数名称之前加上 async 关键字即可。

async function foo {
  // 内部的 await 语句是可选的
}

// ⚠️ Async 函数注意点:
// 1. 函数体内部的 await 语句是可选的;
// 2. 当内部含有 await 语句时,表示有异步操作;
// 3. 针对类似 let a = await 1 的语句,语法上是允许的,但这里使用 await 是无意义的;
// 4. 针对同步代码,只要类似 let a = 1 这样写即可,无需使用 await 关键字。

也可以按以下方式去定义:

// 函数声明形式
async function foo {}

// 函数表达式形式(匿名或具名均可)
const bar = async function () {}

// 箭头函数形式
const baz = async () => {}

// Class 的方法
class Foo {
  async sayHi() {}
  getName = async () => {}
  // 语法没问题,但两者的区别是:
  // sayHi 方法挂载到 Foo 原型上,而 getName 方法则是挂载到实例对象上
}

Async 函数总返回给一个 Promise 对象。

因此,调用方式也很忒简单。相比 Generator 函数,简直不要太爽了。

foo()
  .then(res => { /* res 为 foo 函数的 return 值 */ })
  .catch(err => { /* err 为 foo 函数的异常原因 */ })

async 函数内部 return 语句返回的值,会成为 then() 方法回调函数的参数,即 Promise 对象的状态变为 fulfilled。若无显式的 return 语句,相当于 return undefined,自然 then() 方法接收到的参数也就是 undefined

若内部抛出错误,会导致返回的 Promise 对象变为 rejected 状态,从而被 Promise 对象的 catch() 方法捕获到。

二、特点

Async 函数是 Generator 函数的语法糖,它对 Generator 函数的改进,体现在以下四点:

  • 内置执行器

我们都知道,Generator 函数的执行必须依赖执行器,执行器内部就是不断执行生成器的 next() 方法的过程。所以才有了 co 模块。而 async 函数则内置了执行器。调用方法也只需要跟普通函数那样,一行就行。

// Async 函数
async function foo() { /* do something... */ }
foo()

// Generator 函数
function* bar() { /* do something... */ }
const gen = bar()
gen.next() // 这才开始执行 Generator 函数体内的代码
// ...
  • 更好的语义

asyncawait,比起星号 *yield,语义更清晰。async 表示函数里有异步操作,await 表示紧跟在后面的表达式需要等待结果。

// Async 函数
async function foo() {
  let a = await 1
  let b = await 2
  return b
  // 直接调用 foo() 且 a、b 的值对应为 1、2
}
foo() // Promise { <fulfilled>: 2 }

// Generator 函数
function* bar() {
  let a = yield 1
  let b = yield 2
  return b
  // 若按如下方式调用,a、b 的值均为 undefined,而非对应为 1、2
}
const gen = bar()
gen.next() // { done: false, value: 1 }
gen.next() // { done: false, value: 2 }
gen.next() // { done: true, value: undefined }
  • 更广的适用性

co 模块约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面,可以是 Promise 对象或非 Promise 的值(原始值或引用值均可,但这时内部会自动使用 Promise.resolve() 转换为 Promise 对象)。

  • 返回值是 Promise 对象

async 函数的返回值总是 Promise 对象,这比 Generator 函数的返回值是 Iterator(迭代器)对象方便多了。你可以用 Promise.prototype.then() 方法指定下一步的操作。

三、基本用法

async 函数返回的 Promise 对象,必须等到内部所有await 命令后面的 Promise 对象执行完(即状态变为 fullfilled),且内部不发生错误的情况下,最终 async 函数返回的 Promise 对象才会变成 fulfilled 状态,从而触发 Promise 对象的 then() 方法的回调函数。

若函数内部 await 后面的某个 Promise 对象变为 rejected 状态(且函数内未使用 try...catch 捕获处理),或者函数内部出现抛出错误,将会停止执行函数体内后面的代码,并使得最终 async 函数返回的 Promise 对象会变成 rejected 状态,从而触发 catch() 的回调函数。

看着有点像 Promise.all() 方法,但两者是有区别的,这里先不展开,下文再详说。

看一个简单的例子:

async function request() {
  const response = await fetch('/user/1')
  const res = await response.json()
  return res
}

// 如果使用 Promise 是这样的,无论在语义和写法上都不如 async 函数清晰、简洁。
// 而且这例子只有一个异步操作,若存在多个,那 then 方法真的不忍直视。
// function request() {
//   return fetch('/user/1')
//     .then(response => response.json())
//     .then(res => res)
// }

然后按照下面那样去调用即可。

request()
  .then(res => {
    console.log(res)
    // do something...
  })
  .catch(err => {
    console.warn(err)
    // 处理异常
    // 例如网络问题,导致 fetch 请求出错了,自然 response (它是一个 Promise 对象)的结果
    // 就不是一个 JSON 数据,那么调用 response.json() 解析就会出错。(这时可使用 response.text() 进行解析)
    // 使得 request 函数停止往下执行,且返回的 Promise 状态变为 rejected,
    // 自然就会被这里的 catch 捕获到。错误信息可能是:SyntaxError: Unexpected token < in JSON at position 0
  })
1. await 命令

其中 await 关键字,目前只能在 async 函数内部使用。

但未来就不一定了。现在有一个语法提案,允许在模块顶层独立使用 await 命令,目前以进入 Stage-4 阶段,相信在不久的将来就能正式纳入 ECMAScript 标准了。关于它的用法可看:Top-level await

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

async function foo() {
  return await 1
  // 相当于 return 1
  // 
  // 对于 `await 1` 函数内部的执行器,其实做的事情是 Promise.resolve(1),
  // 而 Promise.resolve() 的作用就是将某个值转化为 Promise 对象。
  // 关于 Promise.resolve() 可看文章:https://www.jianshu.com/p/1f2db76fd8d8
}

// ⚠️ 对于非 `Promise` 对象,没必要使用 `await` 关键字。

还有一种情况,就是 await 关键字后面接了一个 thenable 对象,那么 await 会将其当做是 Promise 对象。thenable 对象是指该对象上实现了 then() 方法(本身或原型上均可)。

const thenable = {
  then(resolve, reject) {
    resolve('abc')
    // 注意,then 方法中要使用 resolve 或 reject 去改变状态,
    // 否则 await thenable 的状态一直会是 pending,
    // 由于 await 一直未等到 thenable 对象的状态发生变化,
    // 因此 foo() 返回的 Promise 对象也将永远停留在 pending 状态,
    // 它只能苦苦地等待有朝一日 thenable 状态能发生改变
  }
}

async function foo() {
  return await thenable // await 会把 thenable 当作一个 Promise 对象去处理
}

foo().then(res => {
  console.log(res) // "abc"
})

一旦遇到 await 后面的 Promise 对象为 rejected 状态的情况,会终止后面代码的执行。例如:

async function foo() {
  await Promise.reject('some errors...')
  let a = 'any' // 这一行及后面的代码,并不会被执行
  return a
}

foo().catch(err => {
  console.warn(err) // "some errors..."
})

这些异常都可以使用 try...catch 去捕获,下面会讲到。

2. 错误处理

async 函数的语法规则总体上比较简单,难点是错误处理机制。

前面也提到过,一旦 async 函数内部某个 Promise 对象状态变为 rejected,或者存在语法错误,或者主动抛出错误,会停止执行函数体内部的余下代码,并使得 async 函数返回的 Promise 对象改变状态 rejected

举个例子:

async function foo() {
  await Promise.reject('error') // 表示一个异步操作
  let a = 'any'
  return a
}

// 如何使其正常调用 then 方法
foo().then(res => {
  console.log(res) // "any"
})

假设异步操作的结果成功与否,不影响函数最终的结果,使其正常执行到最后并返回结果,有几种处理方式:

// 方式一:利用 try...catch 语句
async function foo() {
  try {
    await Promise.reject('error')
  } catch (e) {
    console.warn(e) // "error"
    // 错误处理...
  }
  let a = 'any'
  return a
}
// 方式二:如果是 Promise 对象,可以用 catch 方法捕获
async function foo() {
  await Promise.reject('error').catch(err => {
    console.warn(err) // "error"
    // 错误处理...
    // 只要这里面不再抛出错误,await 拿到的 Promise 对象状态为 fulfilled
  })
  let a = 'any'
  return a
}

以上示例中,只有一个异步操作,这种情况下也可以直接采用 Promise 的写法去处理。一般情况下,使用到 async/await 的写法,表示函数体内部会存在多个异步操作,通常的错误处理方式是:利用一个 try...catch 语句将整个函数体包裹起来。

async function request() {
  try {
    await fetch('/user/1')
    await fetch('/user/2')
    await fetch('/user/3')
  } catch (e) {
    // 捕获异常,并做错误处理
  }
}

四、Async 函数应用

1. 实现重复请求

此前另一篇文章提到过,可以 async 函数结合 for 循环、try...catch 可以实现多次重复尝试的效果。

async function request(url) {
  let res
  let err
  const MAX_NUM_RETRIES = 3

  for (let i = 0; i < MAX_NUM_RETRIES; i++) {
    try {
      res = await fetch(url).then(response => response.json())
      break
    } catch (e) {
      err = e
      // Do nothing and make it continue.
    }
  }

  if (res) return res
  throw err
}

request('/user/1')
  .then(res => {
    console.log('success')
  })
  .catch(err => {
    console.log('fail')
  })
2. 不要在 forEach 中使用 async/await

它可能得不到预期结果,在另一篇文章也详细地分析过了。

如果使用 promiseasync 函数作为 forEach() 等类似方法的 callback 参数,最好对造成的执行顺序影响多加考虑,否则容易出现错误。

举个例子,打印 sum 并不会得到预期的结果 6,而是 3

// PS:实际场景肯定不会这样去求和,这里只是为了举例而举例
function foo() {
  let sum = 0
  const arr = [1, 2, 3]
  const sumFn = (a, b) => a + b

  arr.forEach(async item => {
    sum = await sumFn(sum, item)
  })

  setTimeout(() => {
    console.log(sum) // 3
    // 注意,这里打印不能去掉 setTimeout,否则打印结果永远是 0。
  })
}

foo()

由于 await anything 表达式(这里 anything 表示任意值)相当于使用了 Promise.resolve()anything 包裹起来,其中 Promise 属于异步任务(微任务),它会在同步任务执行完之后才会去执行。

当执行第一次循环,先执行 forEach 的回调函数,遇到 await sumFn(sum, 1),会执行 sumFn 函数计算结果,所以变成了 sum = await 1。由于是异步,会暂时 Hold 将其放入微任务队列,因此 sum 暂时不会被重新赋值,它仍是 0;接着执行下一次循环,同理变成 sum = await 2,又被 Hold 住;再下一次循环同理变成 sum = await 3。至此 forEach 的三次回调函数执行完毕,接着继续往下走,遇到 setTimeout(属于异步任务中的宏任务),由于延迟时间为 0 会直接放入任务队列,它将会在下一次宏任务中执行。

至此,同步任务已执行完毕,紧接着,会依次执行刚刚被 Hold 住的三个微任务(分别是 sum = 1sum = 2sum = 3),因此 sum 变成了 3。微任务执行完毕之后,(由于本示例中没有 UI Render)立刻会执行下一次宏任务,即 console.log(sum),因此打印结果为 3

其实理解原理之后,分析这道题就很简单了。那么替代方案就是使用 for...of 语句:

async function foo() {
  let sum = 0
  const arr = [1, 2, 3]
  const sumFn = (a, b) => a + b

  for (let item of arr) {
    sum = await sumFn(sum, item)
  }

  setTimeout(() => {
    console.log(sum) // 6
    // 改成 for...of 之后,这里可以去掉 setTimeout 了,
    // 直接将 console 语句放到外面,也可以按顺序执行了
  })
}

foo()

那为什么 for...of 就可以,因为它本质上就是一个 while 循环。

async function foo() {
  let sum = 0
  const arr = [1, 2, 3]
  const sumFn = (a, b) => a + b

  // for (let item of arr) {
  //   sum = await sumFn(sum, item)
  // }

  // 相当于
  const iterator = arr[Symbol.iterator]()
  let iteratorResult = iterator.next()
  while (!iteratorResult.done) {
    sum = await sumFn(sum, iteratorResult.value)
    iteratorResult = iterator.next()
  }

  setTimeout(() => {
    console.log(sum) // 6
  })
}

foo()
3. 继发关系

文章前面部分提到过 Async 函数中使用 await 去控制异步操作,看起来像 Promise.all(),但又有区别。

如下示例:

async function request() {
  // 假设 fetchUser1、fetchUser2、fetchUser3 表示异步请求
  let user1 = await fetchUser1()
  let user2 = await fetchUser2()
  let user3 = await fetchUser3()
  return 'abc'
}

上面示例中,request() 函数的返回值不依赖于 fetchUser 的结果,而且三个 fetchUser 请求是相互独立的。如果按上面的写法,直接影响程序的执行时间。

因为目前继发式写法,fetchUser2 请求只有在 fetchUser1 请求完成并返回数据后才会被发出(fetchUser3 同理)。但根据代码逻辑来看,它们是没有关联关系的,为什么不一次性连续发出三个请求以减少整个程序的执行时间呢?

因此,我们可以优化一下。

// 写法一(推荐)
async function request() {
  // 这样 fetchUser1、fetchUser2、fetchUser3 将会同时发出请求
  // 这也是文中所说 await 与 Promise.all() 的不同点。
  let [user1, user2, user3] = await Promise.all([
    fetchUser1(),
    fetchUser2(),
    fetchUser3()
  ])
  return 'abc'
}

// 写法二
async function request() {
  let p1 = fetchUser1()
  let p2 = fetchUser2()
  let p3 = fetchUser3()
  let user1 = await p1
  let user2 = await p2
  let user3 = await p3
  return 'abc'
}

五、Async 函数的实现原理

Async 函数的实现原理,就是将 Generator 函数和自动执行器,包装在一个函数里面。

如果对 Generator 函数不熟悉的话,建议先看下这篇文章:细读 ES6 之 Generator 生成器,再回来看本节内容。

举个例子,下面 requestasync 函数。

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

async function request() {
  let response = await fetch('http://192.168.1.117:7701/config')
  let result = await response.json()
  await delay(1000)
  await delay(2000)
  await delay(3000)
  return result
}

既然 Async 函数是 Generator 函数和自动执行器的结合,那么相当于将上面 Async 函数中的 asyncawait 关键字,就替换成 Generator 函数的 *yield,外加一个自动执行器。

于是变成了这样:

function* request() {
  let response = yield fetch('http://192.168.1.117:7701/config')
  let result = yield response.json()
  yield delay(1000)
  yield delay(2000)
  yield delay(3000)
  return result
}

那么怎样实现执行器,才能达到 Async 函数的语义呢?

其实没那么难,只要弄清楚 Generator.prototype.next()Generator.prototype.throw() 两个方法就没太大问题了。当然前提还是要知道生成器的执行过程。

实现如下:

function executor(genFn, ...args) {
  return new Promise((resolve, reject) => {
    if (Object.prototype.toString.call(genFn) !== '[object GeneratorFunction]') {
      return reject(new Error('Must be a generator function!'))
    }

    const gen = genFn(...args)

    const step = iteratorResult => {
      console.count('次数')
      const { done, value } = iteratorResult

      if (done) {
        return resolve(value)
      }

      Promise
        .resolve(value)
        .then(res => {
          // 关于捕获异常,怕有人不懂,这里解释一下:
          // 假设调用 next 方法后,Generator 内部报错,且内部未进行捕获错误时,
          // 内部的错将会被 Generator 外部的 try...catch 捕获到(即这里的 then 回调函数),
          // 但是这里不进行捕获的原因是:使其传递出去,让后面的 Promise.prototype.catch() 方法捕获,
          // 进而在 catch() 的回调函数内调用 Generator.prototype.throw() 主动使得生成器结束,并 reject 错误。
          step(gen.next(res))
        })
        .catch(err => {
          try {
            gen.throw(err)
          } catch (e) {
            reject(e)
          }
        })
    }

    step(gen.next())
  })
}

调用方式如下:

function* request() {
  let response = yield fetch('http://192.168.1.117:7701/config')
  let result = yield response.json()
  yield delay(1000)
  yield delay(2000)
  yield delay(3000)
  return result
}

// 语法:executor(generatorFunction[, param[, ... param]])
// 其中第一个参数必须是生成器函数,若生成器函数需要传递参数,往后面添加即可
executor(request)
  .then(res => {
    console.log(res)
  })
  .catch(err => {
    console.warn(err)
  })

大致实现如上。如果还是不太懂的,应该是 Generator 函数这块知识点还不够熟悉。

如果这样,还是先要看下这篇文章熟悉相关知识,而且文章末尾很相似的自动执行器的实现示例。

六、总结

前面写过关于 IteratorPromiseGenerator 相关内容的文章,再到本文的 Async 函数,其实都是环环相扣的,因此应按顺序来学习,才能事半功倍。

其中 Promise 虽然解决了“横向”回调地狱(Callback Hell)的问题,但是又出现了“纵向”不断 thencatch 的处理。

而 Generator 函数提出了一种全新的异步控制的方案,调用 Generator 函数不会自动执行其函数体内部的代码,仅返回一个生成器对象,需要我们手动去调用生成器的 next() 方法以执行内部的代码。尽管生成器对象实现了 Iterator 接口,因而可供 for...of 等使用,但是往往结合网络请求等异步操作时,用 for...of 等语句几乎是不能满足我们需求的。这种前提下,需要我们自实现一个执行器来自动调用 Generator 函数实例。但要我们自个实现???这就是最大的问题了(手动狗头)。

基于这种状况下,著名的 co 模块就实现了 Generator 执行器,以供我们使用。但要另外引入第三方库,这本身也是个问题,哈哈...

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

过度放纵

暂无简介

0 文章
0 评论
22 人气
更多

推荐作者

yili302

文章 0 评论 0

晚霞

文章 0 评论 0

LLFFCC

文章 0 评论 0

陌路黄昏

文章 0 评论 0

xiaohuihui

文章 0 评论 0

你与昨日

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文