Node.js async await
上篇 Node.js Promise 异步编程 用 then 链解决了嵌套,但链式代码里到处还是方法调用,读起来不够直接。async/await 让异步代码长得和同步代码一样,是 Node 20 里写异步的首选写法。这篇讲透它的机制、错误处理方式和并发优化,并发优化部分是面试高频点。
async 函数的本质
async 修饰的函数,返回值一定会被包成 Promise。返回普通值等价于 Promise.resolve,内部 throw 等价于 reject
async function getData() {
return 42;
}
// 等价于 function getData() { return Promise.resolve(42); }
async function fail() {
throw new Error("出错了");
}
// fail() 返回 rejected 状态的 Promise,错误内容为 "出错了"
所以 async 函数可以直接接 then 和 catch
getData().then((n) => console.log(n));
fail().catch((err) => console.error(err.message));
如果 return 的是一个 Promise,不会包成双层,而是直接返回那个 Promise,await 它的地方拿到的是解包后的值。
await 做了什么
await 只能在 async 函数里用。它暂停当前函数的执行,等后面的 Promise 落定,恢复后整个表达式的值就是 resolve 出来的值。如果 Promise 被 reject,await 直接抛出那个错误
async function demo() {
const a = await Promise.resolve(1); // a 是 1
const b = await 2; // 普通值直接返回,等于没用 await
const c = await Promise.reject(new Error("炸了")); // 这里抛出,函数中断
console.log(a, b, c); // 这行不会执行
}
注意暂停的是整个函数,不是线程。等待期间事件循环照常跑别的任务,这一点在下一篇 Node.js 事件循环 会详细讲。
错误处理
await 会抛出 reject 的错误,所以用 try/catch 就能捕获异步错误,写法和同步代码完全一样
const fsp = require("node:fs/promises");
async function readAll() {
try {
const data = await fsp.readFile("a.txt", "utf8");
console.log(data);
} catch (err) {
console.error("读取失败", err.message);
} finally {
console.log("清理动作,无论成败都执行");
}
}
readAll();
finally 用来兜底清理,比如关掉打开的文件句柄、清除定时器。catch 里如果不处理,错误会被继续抛出,async 函数外面没人接就变成 unhandled rejection。
并发优化
这是重点。两个互不依赖的异步操作,不要写两个 await 串行等待,应该用 Promise.all 并行。先看反例
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function serial() {
console.time("串行");
await sleep(200);
await sleep(200);
console.timeEnd("串行"); // 约 400ms
}
async function parallel() {
console.time("并行");
await Promise.all([sleep(200), sleep(200)]);
console.timeEnd("并行"); // 约 200ms
}
两个 sleep 互不依赖,串行写法白白浪费一倍时间。判断标准很简单,后面的操作用不到前面的结果,就该并行。
并行拿结果时,可以用解构保持可读性
const [dataA, dataB] = await Promise.all([
fsp.readFile("a.txt", "utf8"),
fsp.readFile("b.txt", "utf8"),
]);
console.log(dataA, dataB);
有依赖关系才串行,比如先读配置再按配置请求接口,这种用 await 依次等待没问题。
顶层 await
模块顶层也能直接用 await,不用包 async 函数。但有个限制,只有 ES 模块支持,CommonJS 不行,相关背景见 Node.js 模块系统。ES 模块的文件后缀是 .mjs,或 package.json 里 type 设为 module
// top-await.mjs
const resp = await fetch("https://api.github.com/zen");
const text = await resp.text();
console.log(text);
运行方式
node top-await.mjs
顶层 await 会让模块加载等这个操作完成,适合启动时读配置、连数据库这类初始化场景。CommonJS 文件里用会直接报语法错误,只能包一层 async 函数绕过去。
实践
创建 a.txt 和 b.txt,用 async/await 实现先读 a 再读 b,最后合并输出,并加上 try/catch 和 finally
const fsp = require("node:fs/promises");
async function merge() {
try {
const dataA = await fsp.readFile("a.txt", "utf8");
const dataB = await fsp.readFile("b.txt", "utf8");
console.log(dataA + dataB);
} catch (err) {
console.error("读取失败", err.message);
} finally {
console.log("读取流程结束");
}
}
merge();
运行确认输出后,把它改成并行版本,删掉 b.txt 再运行,观察 catch 是否只触发一次。
然后对比三种写法在同一场景下的代码量
| 写法 | 结构 | 错误处理 | 典型代码行数 |
|---|---|---|---|
| 回调 | 层层嵌套 | 每层各写一遍 | 30 行上下 |
| Promise 链 | 平铺链式 | 一个 catch | 20 行上下 |
| async/await | 同步式平铺 | 一个 try/catch | 15 行上下 |
行数只是表象,真正的差距在可读性,async/await 版本读起来就是”先做这个,再做那个”的顺序,思维负担最小。
常见坑
第一个坑,在非 async 函数里用 await,直接语法错误。错误信息会提示 await is only valid in async functions,检查调用链,回调里写 await 也会踩这个。
第二个坑,忘了 try/catch。async 函数里的错误不会崩进程,但会变成 unhandled rejection,程序悄悄挂掉,日志还难找,入口函数一定要接住错误。
第三个坑,在 for 循环里写 await 当并行。循环里每个 await 都是串行的,循环 100 次每次 200ms 就是 20 秒,想并行用 Promise.all 配合 map。
第四个坑,并发误伤。把有依赖关系的操作放进 Promise.all,比如第二个请求的 URL 依赖第一个的响应,结果先发出去拿不到参数,判断依赖关系再决定串行还是并行。
下一步
async/await 是写给事件循环看的语法糖,真正决定执行顺序的是底层那台调度机器。下一章 Node.js 事件循环 把它拆开看,这是理解 Node.js 的最后一层地基。