5. 异步函数
ECMAScript 2017 的特性“异步函数”是由 Brian Terlson 提出的。
5.1 概述
5.1.1 变体
异步函数存在以下几种变体。请注意,所有变体中都使用了关键字 async
。
- 异步函数声明:
async function foo() {}
- 异步函数表达式:
const foo = async function () {};
- 异步方法定义:
let obj = { async foo() {} }
- 异步箭头函数:
const foo = async () => {};
5.1.2 异步函数始终返回 Promise
实现异步函数的 Promise
拒绝异步函数的 Promise
5.1.3 通过 await
处理异步计算的结果和错误
运算符 await
(仅允许在异步函数内部使用)会等待其操作数(一个 Promise)完成。
- 如果 Promise 被实现,则
await
的结果是实现值。
- 如果 Promise 被拒绝,则
await
会抛出拒绝值。
处理单个异步结果
顺序处理多个异步结果
并行处理多个异步结果
处理错误
5.2 理解异步函数
在解释异步函数之前,我需要解释如何结合 Promise 和生成器,通过类似同步的代码来执行异步操作。
对于异步计算其一次性结果的函数,ES6 中的 Promise 已变得流行起来。一个例子是 客户端 fetch
API,它是 XMLHttpRequest 的替代方案,用于检索文件。使用它的方式如下所示
5.2.1 使用生成器编写异步代码
co 是一个使用 Promise 和生成器来实现更像同步编码风格的库,但其工作原理与上一个示例中使用的风格相同
每次回调(一个生成器函数!)将 Promise 传递给 co 时,回调都会被挂起。一旦 Promise 完成,co 就会恢复回调:如果 Promise 被实现,则 yield
返回实现值;如果被拒绝,则 yield
抛出拒绝错误。此外,co 会将回调返回的结果 Promise 化(类似于 then()
的方式)。
5.2.2 使用异步函数编写异步代码
异步函数基本上是 co 所做工作的专用语法
在内部,异步函数的工作方式与生成器非常相似。
5.2.3 异步函数同步启动,异步完成
以下是异步函数的执行方式
- 异步函数的结果始终是一个 Promise
p
。该 Promise 在开始执行异步函数时创建。
- 执行函数体。执行可以通过
return
或 throw
永久结束。或者可以通过 await
暂时结束;在这种情况下,执行通常会在稍后继续。
- 返回 Promise
p
。
在执行异步函数体时,return x
使用 x
实现 Promise p
,而 throw err
使用 err
拒绝 p
。完成通知是异步发生的。换句话说:then()
和 catch()
的回调总是在当前代码执行完毕后执行。
以下代码演示了它是如何工作的
您可以依赖以下顺序
- 第 (A) 行:异步函数同步启动。异步函数的 Promise 通过
return
实现。
- 第 (C) 行:继续执行。
- 第 (B) 行:Promise 实现通知异步发生。
5.2.4 返回的 Promise 不会被包装
实现 Promise 是一个标准操作。return
使用它来实现异步函数的 Promise p
。这意味着
- 返回非 Promise 值将使用该值实现
p
。
- 返回 Promise 意味着
p
现在反映了该 Promise 的状态。
因此,您可以返回一个 Promise,并且该 Promise 不会被包装在另一个 Promise 中
有趣的是,返回一个被拒绝的 Promise 会导致异步函数的结果被拒绝(通常,您会使用 throw
来实现这一点)
这与 Promise 实现的工作原理一致。它使您能够转发另一个异步计算的实现和拒绝,而无需使用 await
前面的代码与以下代码大致相似(但效率更高),后者解包 anotherAsyncFunc()
的 Promise 只是为了再次包装它
5.3 使用 await
的技巧
5.3.1 不要忘记 await
在异步函数中,一个容易犯的错误是在进行异步函数调用时忘记 await
在此示例中,value
被设置为 Promise,这通常不是您在异步函数中想要的。
即使异步函数没有返回任何内容,await
也有意义。然后,它的 Promise 只是用作信号,告诉调用者它已完成。例如
第 (A) 行中的 await
确保在执行 foo()
的其余部分之前,step1()
已完全完成。
5.3.2 如果您“触发即忘记”,则不需要 await
有时,您只想触发异步计算,而并不关心它何时完成。以下代码就是一个例子
在这里,我们不关心单个写入何时完成,只关心它们是否按正确的顺序执行(API 必须保证这一点,但异步函数的执行模型鼓励这样做,正如我们所见)。
asyncFunc()
最后一行中的 await
确保仅在文件成功关闭后才实现该函数。
鉴于返回的 Promise 不会被包装,您也可以使用 return
而不是 await
writer.close()
这两种版本都有优缺点,await
版本可能更容易理解。
5.3.3 await
是顺序的,Promise.all()
是并行的
以下代码进行了两次异步函数调用,asyncFunc1()
和 asyncFunc2()
。
但是,这两个函数调用是顺序执行的。并行执行它们往往会加快速度。您可以使用 Promise.all()
来实现这一点
现在,我们不是等待两个 Promise,而是等待一个包含两个元素的数组的 Promise。
5.4 异步函数和回调
异步函数的一个限制是 await
只影响直接包围它的异步函数。因此,异步函数不能在回调中使用 await
(但是,回调本身可以是异步函数,我们将在后面看到)。这使得基于回调的实用函数和方法难以使用。例如数组方法 map()
和 forEach()
。
5.4.1 Array.prototype.map()
让我们从数组方法 map()
开始。在以下代码中,我们想下载 URL 数组指向的文件,并将它们返回到一个数组中。
这不起作用,因为 await
在普通箭头函数中是非法的语法。那么,使用异步箭头函数呢?
这段代码有两个问题
- 结果现在是一个 Promise 数组,而不是一个字符串数组。
map()
完成后,回调执行的工作还没有完成,因为 await
只会暂停包围它的箭头函数,而 httpGet()
是异步实现的。这意味着您不能使用 await
等待 downloadContent()
完成。
我们可以通过 Promise.all()
解决这两个问题,它将 Promise 数组转换为数组的 Promise(使用 Promise 实现的值)
map()
的回调对 httpGet()
的结果没有做太多处理,只是转发了它。因此,我们这里不需要异步箭头函数,普通箭头函数就可以了
我们还可以做一个小小的改进:这个异步函数效率有点低——它首先通过 await
解包 Promise.all()
的结果,然后再通过 return
包装它。鉴于 return
不会包装 Promise,我们可以直接返回 Promise.all()
的结果
5.4.2 Array.prototype.forEach()
让我们使用数组方法 forEach()
来记录 URL 指向的多个文件的内容
同样,这段代码会产生语法错误,因为您不能在普通箭头函数中使用 await
。
让我们使用异步箭头函数
这确实有效,但有一个需要注意的地方:httpGet()
返回的 Promise 是异步实现的,这意味着当 forEach()
返回时,回调还没有完成。因此,您不能等待 logContent()
的结束。
如果这不是您想要的,您可以将 forEach()
转换为 for-of
循环
现在,所有内容都在 for-of
循环结束后完成。但是,处理步骤是顺序发生的:只有在第一次调用完成后,才会第二次调用 httpGet()
。如果您希望处理步骤并行发生,则必须使用 Promise.all()
map()
用于创建 Promise 数组。我们对它们实现的结果不感兴趣,我们只 await
直到它们都实现为止。这意味着我们在该异步函数结束时就完全完成了。我们也可以返回 Promise.all()
,但这样函数的结果将是一个所有元素都为 undefined
的数组。
5.5 使用异步函数的技巧
5.5.1 了解您的 Promise
异步函数的基础是 Promise。这就是为什么理解后者对于理解前者至关重要。特别是当将未基于 Promise 的旧代码与异步函数连接时,您通常别无选择,只能直接使用 Promise。
例如,这是 XMLHttpRequest
的“Promise 化”版本
XMLHttpRequest
的 API 基于回调。通过异步函数对其进行 Promise 化意味着您必须从回调内部实现或拒绝函数返回的 Promise。这是不可能的,因为您只能通过 return
和 throw
来实现。而且您不能从回调内部 return
函数的结果。throw
也有类似的限制。
因此,异步函数的常见编码风格是
- 直接使用 Promise 来构建异步原语。
- 通过异步函数使用这些原语。
延伸阅读:“Exploring ES6”中的“用于异步编程的 Promise”一章。
有时,如果能够在模块或脚本的顶层使用 await
会很方便。可惜的是,它只能在异步函数内部使用。因此,您有几种选择。您可以创建一个异步函数 main()
并在之后立即调用它
或者,您可以使用立即调用的异步函数表达式
另一种选择是立即调用的异步箭头函数
5.5.3 使用异步函数进行单元测试
以下代码使用 测试框架 mocha 对异步函数 asyncFunc1()
和 asyncFunc2()
进行单元测试
但是,此测试始终成功,因为 mocha 不会等到第 (B) 行和第 (C) 行中的断言执行完毕。
您可以通过返回 Promise 链的结果来解决此问题,因为 mocha 会识别测试是否返回 Promise,然后等待该 Promise 完成(除非超时)。
方便的是,异步函数始终返回 Promise,这使得它们非常适合这种单元测试
因此,在 mocha 中使用异步函数进行异步单元测试有两个优点:代码更简洁,并且也处理了返回 Promise 的问题。
5.5.4 无需担心未处理的拒绝
JavaScript 引擎在警告未处理的拒绝方面越来越出色。例如,以下代码在过去通常会静默失败,但现在大多数现代 JavaScript 引擎都会报告未处理的拒绝
5.6 延伸阅读