Menu

JavaScript async/await:像同步代码一样写异步

JavaScript 中 async/await 的真正工作方式:async 函数、等待 Promise、用 try/catch 处理错误,以及并行运行任务。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

async/await 只是换了身衣服的 Promise

async/await 并不是一种新的并发模型。它是建立在 Promise 之上的语法糖,让你写出的代码看起来是顺序执行的,尽管它实际上是异步的。机制完全一样,只是写法友好得多。

下面是同一个任务的两种写法:

两个函数都返回一个 Promise,做的事情也完全一样。async 版本从上往下读,没有 .then 链,这就是它全部的魅力所在。

async 表示函数返回一个 Promise

在 function、箭头函数或方法前面加上 async,会发生两件事:

  1. 这个函数总是返回一个 Promise。你 return 的任何值都会成为兑现的值。
  2. 你可以在它内部使用 await。

注意,result 不是那个字符串,而是一个兑现为该字符串的 Promise。即使 greet 里没有任何 await,也没有任何异步操作,async 关键字依然会把返回值包装成一个 Promise。如果函数抛出异常,这个 Promise 就会被拒绝。

await 暂停执行,直到 Promise 敲定

在 async 函数内部,await somePromise 会暂停这个函数,直到 Promise 兑现,然后把兑现的值交给你。如果 Promise 被拒绝,await 就会抛出异常。

注意看输出的顺序。"started countdown" 在 "2" 之前打印,因为 await 只会暂停这个 async 函数,而不是程序的其余部分。事件循环一直在运行;countdown 只是在每个 wait Promise 兑现之后才继续执行。

你可以 await 任何类似 Promise 的东西。await 42 也是合法的:非 Promise 的值会被包装成 Promise.resolve(42),并立即兑现。

用 try/catch 处理错误

使用普通的 Promise 时,你要链式调用 .catch()。使用 async/await 时,被拒绝的 Promise 会变成一个抛出的异常,你可以用常规方式捕获它:

一个 try/catch 就能覆盖其中的每一个 await。网络故障、JSON 解析错误和你自己的 throw 语句,都会落到同一个 catch 里。相比层层嵌套的 .then/.catch 链,这是实实在在的进步。

有一点要注意:fetch 只会在网络错误时拒绝,HTTP 4xx/5xx 不会导致拒绝。你需要自己检查 res.ok 并抛出错误,在真实代码中你会经常看到这种写法。

不必要时,别在循环里 await

这是 async/await 最常见的陷阱。在循环中依次 await,意味着每次迭代都要等上一次完成:

sequential 大约需要 900ms,parallel 大约需要 300ms。经验法则是:如果任务之间不依赖彼此的结果,就先把它们全部启动,再 await Promise.all。只有当下一次调用确实需要上一次的结果时,才逐个 await。

处理集合时,惯用写法是 Promise.all(items.map(async (x) => ...))。在普通的 for...of 里使用 await 会按顺序执行:有时你确实需要这样(限流、保证顺序),但通常不需要。

混用 async/await 和普通 Promise

你不必只选一边。async 函数返回 Promise,而 await 适用于任何 Promise,所以你可以自由地混用它们:

两种风格可以互换。当代码从上往下读更清晰时就用 await;当你只想快速写个一次性的处理,或者处在异步上下文之外时,就用 .then。

顶层 await(在 ES 模块中)

过去,你必须把 await 包在 async 函数里,因为脚本的顶层不允许使用 await。现在情况变了:在 ES 模块(.mjs 文件或 <script type="module">)中,你可以直接在顶层使用 await:

// in an ES module
const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
const user = await res.json();
console.log(user.name);

顶层 await 会推迟模块的完成,直到被等待的 Promise 敲定,导入这个模块的代码也要跟着等待。它在加载配置和动态导入时很方便,但要谨慎使用:一个缓慢的顶层 await 会阻塞所有导入该模块的代码。

在 CommonJS 文件或普通的内联脚本中,这样写仍然会报 SyntaxError。经典的解决办法是使用一个立即调用的异步函数:

容易让人栽跟头的地方

快速过一遍常见的坑:

  • 忘了写 async。 在普通函数中使用 await 是语法错误。解决办法是加上 async,或者用 .then 调用这个异步辅助函数。
  • 忘了 await 结果。 const data = getJSON(url); 得到的是一个 Promise,而不是数据。如果你把它当成值来用,输出里就会出现 [object Promise]。
  • 未处理的拒绝。 一个调用后就不管的异步函数(doWork();)会悄悄吞掉错误,除非你加上 .catch,或者在 try/catch 中 await 它。
  • 在 forEach 中使用异步回调。 array.forEach(async (x) => await something(x)) 不会等待任何东西,因为 forEach 会忽略返回的 Promise。改用带 await 的 for...of,或者 Promise.all(array.map(...))。

运行一下:"finished?" 会在任何 "done" 之前打印,因为 broken 没有等待就返回了。fixed 会等所有任务完成,最后才打印 "finished!"。

什么时候该用 async/await

只要代码要按顺序执行不止一个异步步骤,或者需要 try/catch 式的错误处理,就默认使用 async/await。对于简单的单行代码、本身不需要 await 任何东西就直接返回 Promise 的库代码,或者确实需要在链中使用 Promise.race 或 .finally() 这类组合方法的情况,就继续用原始的 Promise。

用得好的话,async/await 能让异步代码读起来像一份菜谱:先做这个,再做这个,然后做这个。事件循环依然照常工作,只是你不用再以回调的方式思考了。

下一页:fetch API

这里的大多数例子都用 fetch 代表“某个异步操作”。它值得好好讲一讲:请求和响应如何工作、如何处理 JSON、如何设置请求头,以及为什么 fetch 不会因为 HTTP 错误而拒绝。这就是下一页的内容。

常见问题

JavaScript 中的 async/await 是做什么的?

async/await 是处理 Promise 的一种语法,让你可以像写同步代码一样写异步代码。async 表示一个函数返回 Promise,await 会在该函数内部暂停,直到给定的 Promise 敲定,然后把兑现的值交给你。它的底层仍然是 Promise,只是读起来更容易。

可以在 async 函数之外使用 await 吗?

在 ES 模块的顶层可以,这叫顶层 await。在普通函数或 CommonJS 脚本中不行:在 async 函数之外使用 await 是语法错误。通常的解决办法是把代码包进一个 async 函数再调用它,或者把文件改成 ES 模块。

async/await 中如何处理错误?

把被 await 的调用包进 try/catch。你 await 的任何被拒绝的 Promise 都会变成一个抛出的异常,由 catch 代码块处理。对于不 await 的后台任务,要在返回的 Promise 上加 .catch(),避免出现未处理的拒绝。

await 会阻塞整个程序吗?

不会。await 只会暂停当前的 async 函数。事件循环会继续运行:定时器照常触发,其他异步任务继续推进,界面也保持响应。调用方会立即拿到一个待定状态的 Promise,然后继续往下执行。

Coddy 编程语言插图

和 Coddy 一起学编程

开始