async/await 只是换了身衣服的 Promise
async/await 并不是一种新的并发模型。它是建立在 Promise 之上的语法糖,让你写出的代码看起来是顺序执行的,尽管它实际上是异步的。机制完全一样,只是写法友好得多。
下面是同一个任务的两种写法:
两个函数都返回一个 Promise,做的事情也完全一样。async 版本从上往下读,没有 .then 链,这就是它全部的魅力所在。
async 表示函数返回一个 Promise
在 function、箭头函数或方法前面加上 async,会发生两件事:
- 这个函数总是返回一个 Promise。你
return的任何值都会成为兑现的值。 - 你可以在它内部使用
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,然后继续往下执行。