Node.js 异步转同步:3 种做法一次讲清含 Promise 与 async await

编程狮(w3cschool.cn) 2026-09-22 15:36:46 浏览数 (51)
反馈

Node.js 异步转同步的本质不是让代码真变慢阻塞,而是用 Promise 与 async/await 把“回调地狱”写成自上而下、看起来像同步的顺序逻辑,从而更易读、易维护。

Node.js 异步转同步3 种做法一次讲清

本文给出三条落地路径:直接用原生 Promise 包裹异步、用 async/await 消除 .then 嵌套、用 util.promisify 把旧回调 API 改造成返回 Promise 的函数。每种都附可运行示例与典型坑。读完你能把一段嵌套五六层的回调重构成清爽的线性代码,并避开 await 写在普通函数里这种常见报错。

一、先看结论:三种做法怎么选

做法 写法 适用场景 是否推荐
new Promise 包裹 手动 resolve / reject 现有 API 是回调,暂时不想用 await 过渡方案
async/await async 函数 + await 绝大多数业务代码 首选
util.promisify 自动包装回调 API 旧回调 API 改造 改造旧代码
原始回调 直接把函数当参数 极简一次性脚本 简单场景可用

一句话:优先写 async/await;需要包装现有回调时用 Promise;改造老 API 用 util.promisify。

二、思路总览:回调、Promise 与 async/await 的关系

Node.js 从诞生起就靠事件循环处理高并发,异步是它的底色。最早的写法是回调(callback),即把“完成后要做什么”作为函数参数传进去;一旦步骤多,就会出现层层缩进的“回调地狱”,错误处理也散落各处。

Promise 把异步结果封装成对象,用 .then 链式调用把嵌套摊平。async/await 则是 Promise 的语法糖,让异步代码在视觉上接近同步。

写法 代码形态 错误处理 可读性
回调 层层嵌套 每层 if (err)
Promise .then 链式 末端 .catch
async/await 自上而下线性 try/catch

理解三者关系很关键:

  • async 函数永远返回 Promise;
  • await 只能在 async 函数内部使用;
  • await “暂停”的是当前异步函数的执行,而非整个进程。

下面用同一段读文件逻辑展示三种写法,直观感受差异。建议边读边对照 Node.js 速查手册 里的 API 列表,避免记错方法签名。

const fs = require('fs');

// 1) 回调写法
fs.readFile('a.txt', 'utf8', (err, data) => {
  // 回调中抛错不会被外部捕获,这里仅作演示
  if (err) throw err;

  console.log(data);
});

// 2) Promise 写法(见第三节)
// 3) async/await 写法(见第四节)

三、做法一:用 Promise 包裹并链式调用

当现有 API 还是回调形式,又不想立刻上 await,可以用 new Promise 手工包裹,把成功调 resolve、失败调 reject。这样原来的回调就被转换成了 Promise,可以用 .then 串起后续步骤,用单个 .catch 统一兜底错误,不必在每层都写 if (err)

.then 链里返回的值会自动包成新 Promise,返回另一个 Promise 则会等待它完成,于是多层异步能被拉成一条线。注意 .catch 要放在链末端,否则中间抛错若没有捕获,进程可能以未处理拒绝退出。

下面演示读两个文件并拼接内容的链式写法:

const fs = require('fs');

// 把回调式 readFile 包装成返回 Promise 的函数
function read(file) {
  return new Promise((resolve, reject) => {
    fs.readFile(file, 'utf8', (err, data) => {
      // 出错时 reject,成功时 resolve
      err ? reject(err) : resolve(data);
    });
  });
}

read('a.txt')
  .then(d => d + '\n')       // 处理第一个文件内容
  .then(console.log)          // 输出结果
  .catch(err => console.error('读取失败:', err)); // 统一捕获错误
要点 说明
resolve 成功时调用,传递结果
reject 失败时调用,传递错误
.then 处理上一步结果,返回新 Promise
.catch 放在链末端,统一捕获错误

四、做法二:async/await 把异步写成线性

async/await 是日常最推荐的方式。把函数声明成 async,内部就能用 await 等待 Promise 落定,代码读起来从上往下像同步一样。多个无依赖的异步可以先用 Promise.all 并发,再 await,既保持顺序清晰又不等多余时间。

try/catch 包裹 await 即可捕获异常,比 .then 链的 .catch 更贴近普通同步代码的写法。注意 await 必须在 async 函数里,否则报语法错误;顶层如需使用,可借助顶层 await(ESM)或包一层 async 立即执行函数。

想查完整 API 与事件循环细节,可看 Node.js 文档教程fs/promisesutil 的章节,里面有每个方法的返回类型说明。

const fs = require('fs').promises;

async function main() {
  try {
    // 两个文件无依赖,用 Promise.all 并发读取
    const [a, b] = await Promise.all([
      fs.readFile('a.txt', 'utf8'),
      fs.readFile('b.txt', 'utf8'),
    ]);

    // 输出拼接结果
    console.log(a + b);
  } catch (e) {
    // 统一捕获读取错误
    console.error('出错了:', e.message);
  }
}

main();
写法 是否等待 适用场景
await 单个 Promise 等待完成 有依赖的步骤
Promise.all([...]) 并发后一起等待 多个无依赖任务
try/catch 捕获 await 错误 统一错误处理

五、做法三:util.promisify 改造旧回调 API

项目里常遇到只提供回调形式的老接口,比如 redis、mysql 旧驱动或自定义 SDK。Node.js 内置 util.promisify 能把这些“最后一个参数是回调 (err, result)”的函数,自动包装成返回 Promise 的版本,免去了手写 new Promise 的样板代码。

规则很明确:被包装的函数必须调用回调时第一个参数是错误对象,第二个是结果。包装后直接 await 即可。对于多参数的回调,可用 util.promisify.custom 自定义转换逻辑。

下面把经典的 dns.lookup 回调改造成 Promise 用法,体会这层糖衣的便利:

const util = require('util');
const dns = require('dns');

// 把回调式 dns.lookup 转成返回 Promise 的函数
const lookup = util.promisify(dns.lookup);

(async () => {
  try {
    // 直接 await 包装后的函数
    const { address } = await lookup('example.com');
    console.log('解析地址:', address);
  } catch (e) {
    console.error(e);
  }
})();
条件 说明
回调参数顺序 第一个是错误对象,第二个是结果
包装后返回值 返回 Promise
多参数回调 用 util.promisify.custom 自定义
适用 API 旧回调风格、SDK、驱动

六、典型坑排查:未捕获拒绝与错误作用域

把异步“转同步”写顺了,仍有两类高频报错。

常见坑 现象 正确做法
await 写在非 async 函数 直接抛 SyntaxError 给外层补 async 或改用 .then
Promise 被 reject 没人 catch 触发 UnhandledPromiseRejection,进程可能退出 async 内用 try/catch,链末端挂 .catch
forEach 里用 await 不会按预期等待 改用 for...of 或 Promise.all + map
循环里串行 await 本可并发却串行执行 无依赖时提前 Promise.all

其一,await 写在非 async 函数里,Node.js 直接抛 SyntaxError,解决方法是给外层补 async 或改用 .then

其二,Promise 被 reject 却没人 catch,进程会打印 UnhandledPromiseRejection 并最终退出,务必在 async 函数内用 try/catch,或在 Promise 链末端挂 .catch

另一个隐蔽点是循环里的 awaitfor 循环中 await 会串行执行,若本可并发就应提到循环外 Promise.all;而 forEach 里用 await 不会按预期等待,因为 forEach 本身不 await 回调。下面列出两个对照示例,帮你看清差异:

// 错误:forEach 不等待回调里的 await
[1, 2, 3].forEach(async n => { await task(n); });

// 正确:for...of 会真正串行等待
for (const n of [1, 2, 3]) { await task(n); }

// 正确:无依赖时用 Promise.all 并发
await Promise.all([1, 2, 3].map(n => task(n)));

Node.js 异步转同步三种做法

为了把三种写法真正用稳,建议做一次本地实操对比。先在项目里配置好 fs/promisesutil 的引入,运行同一段读文件逻辑分别用回调、Promise 和 async/await 三种写法,检查输出顺序是否和预期一致;再用 Promise.all 把无依赖的异步并发起来,验证总耗时是否明显下降。

这里有个清晰的适用边界:

做法 适用场景 阅读体验
回调 极简一次性脚本 一般
Promise 包装现有回调 较好
async/await 绝大多数业务 最好
util.promisify 改造老回调 API

如果某次 await 写在普通函数里导致语法失败,先把外层补成 async 再用 try/catch 修复错误作用域;若进程报 unhandledRejection,检查是不是漏了 .catch。把这套写法固化进团队的代码规范,新人照着写就不会再陷入回调地狱,也能在提交前验证异步流程是否符合预期。遇到历史回调 API 时,util.promisify 一行即可接入现代写法,避免重复手写样板 Promise,也能让老代码和新逻辑保持同一套错误处理习惯。示例代码按官方文档整理,可直接复用。

总结

Node.js 异步转同步不是消灭异步,而是用更好的抽象管理异步。

  • 回调适合极简场景但易陷入嵌套;
  • Promise 把流程摊平并集中错误处理;
  • async/await 在此基础上给出最贴近同步的阅读体验;
  • util.promisify 则把历史回调 API 拉进同一体系。

实战中优先写 async 函数、用 await 等待、用 try/catch 兜底、用 Promise.all 并发独立任务,就能既保持 Node.js 的高并发优势,又拥有清晰的代码结构。这也是编程狮在 Node.js 实战课里重点训练的代码习惯。

延伸学习

  1. Node.js 快速入门课程 系统学 Node.js
  2. Node 事件循环笔记 看事件循环原理
  3. Node.js 教程 复习基础

常见问题

Q:await 一定要写在 async 函数里吗?

A:是的。await 只能在 async 函数或模块的顶层 await 中使用,写在普通函数会直接报语法错误。若需在入口用,可把逻辑包进 async 函数并立即调用。

Q:Promise 被 reject 没处理会怎样?

A:Node.js 会触发 unhandledRejection,长期放任可能导致进程退出。应在 async 函数内用 try/catch,或在 Promise 链末端加 .catch 统一捕获错误。

Q:forEach 里用 await 为什么不等?

A:forEach 不会等待回调里的 Promise,所以循环看起来并发但顺序不可控。需要顺序执行请用 for...of,需要并发就用 Promise.all 配合 map

Q:util.promisify 对多参数回调怎么办?

A:默认只支持 (err, result) 形式。若回调有多个结果参数,可以用 util.promisify.custom 指定自定义转换逻辑,或者自己用 new Promise 手动包装。

0 人点赞