六维教程

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 的最后一层地基。

上一篇
Node.js Promise 异步编程
下一篇
Node.js 事件循环