人人都会AI编程

11.1 异步错误捕获机制

更新时间:2026-07-11

Node.js 的异步模型带来了高并发能力,但也让错误处理变得比同步代码更复杂。在传统的同步编程中,只要一层 try/catch 包裹可能出错的代码,就能兜底。但在异步回调、Promise、async/await 混用的场景中,错误可能会被“吞掉”或导致进程直接崩溃。本节会从回调时代讲起,一路梳理到 async/await,并给出真实项目中的全局兜底策略。

11.1.1 同步 try/catch 在异步中的局限

很多从同步语言转向 Node.js 的开发者最容易踩的坑,就是以为 try/catch 能捕获异步回调中的错误。下面这段代码看起来合乎直觉,但实际上是无效的:

try {
  setTimeout(() => {
    throw new Error('异步错误会被丢弃');
  }, 100);
} catch (err) {
  // 这块代码永远不会执行
  console.error('捕获到错误:', err);
}

原因是:当 setTimeout 的回调真正执行时,外层的 try/catch 栈帧早已退出。错误是在一个新的执行上下文、新的调用栈中抛出的,与当初的 try 无任何关联。这恰恰是事件循环机制决定的:宏任务和微任务分别在自己的队列中执行,它们与同步代码属于不同的“宏观”执行单元。

同理,原生 fs.readFile 这类回调式 API 也不能用 try/catch 捕获:

const fs = require('fs');
try {
  fs.readFile('/nonexistent', (err, data) => {
    if (err) throw err; // 这个 throw 不会被 try 捕获
  });
} catch (err) {
  // 同样不会执行
}

因此,Node.js 社区在 Promise 普及之前,形成了一套错误优先回调的约定来解决这个问题。

11.1.2 错误优先回调约定 (Error-first Callback)

早期 Node.js 几乎全部 API 都采用回调函数风格,并且遵循一个严格的约定:回调的第一个参数保留给错误对象,如果没有错误则为 null。开发者需要在每个回调内部判断 err,然后再处理数据。

const fs = require('fs');

fs.readFile('/path/to/file', (err, data) => {
  if (err) {
    // 统一处理错误:记录日志、返回错误码等
    console.error('读取文件失败:', err.message);
    return;
  }
  // 成功分支
  console.log(data.toString());
});

这种约定虽然解决了捕获的问题,却带来了“回调地狱”:层层嵌套让错误处理与正常业务逻辑交织在一起,很不优雅。而且如果某层忘了检查 err 或忘了 return,错误会继续往下执行,导致意料之外的行为。因为没有任何机制强制开发者处理回调中的错误,所以还是时不时会发生遗漏。

另一个常见陷阱是对 err 直接使用 throw。在回调中 throw 会直接导致进程崩溃(因为没有 try/catch 包裹),这在线上是致命的。例如上面的 if (err) throw err 就是一个危险操作,除非上层有全局兜底。

11.1.3 Promise 异常:从回调森林到链式捕获

Promise 的出现将异步错误处理提升了一个层次,它把错误传播与成功结果分离到两个通道中。使用 Promise 时,我们不再需要手动在每个回调里判断 err,而是通过 .catch().then() 的第二个参数统一捕获。

const fsPromises = require('fs/promises');

fsPromises.readFile('/path/to/file')
  .then(data => {
    console.log(data.toString());
  })
  .catch(err => {
    console.error('读取失败:', err.message);
  });

Promise 内部如果抛出异常或者调用了 reject,该错误会自动沿着链向下传播,直到被最近的 .catch() 捕获。这种链式传播避免了回调嵌套中的错误遗漏,也让代码的意图更清晰。

不过需要注意:

  • 忘记添加 .catch() 会导致 UnhandledPromiseRejection。Node.js 14+ 对所有未捕获的 Promise 拒绝会触发 unhandledRejection 事件,如果没有监听,进程可能直接退出(未来版本可能会强制终止进程)。
  • .then 的成功回调里抛出错误,后续的 .catch 也能捕获,因为 Promise 链将同步异常也包装成拒绝。
Promise.resolve('初始值')
  .then(res => {
    throw new Error('在 then 中抛出');
  })
  .catch(err => {
    console.log('被捕获:', err.message); // 会执行
  });

Promise 在实践中也有一个反模式:在 Promise 构造函数内部使用 try/catch 包裹异步回调,这是一种常见的误解。Promise 构造器内部是同步执行,而异步回调的错误不会自动转换为 reject。

new Promise((resolve, reject) => {
  fs.readFile('/path', (err, data) => {
    // 必须显式 reject
    if (err) return reject(err);
    resolve(data);
  });
});

在 Node.js 8 之后,可以利用 util.promisify 把回调式 API 转为返回 Promise 的函数,避免手动包装:

const util = require('util');
const readFile = util.promisify(fs.readFile);

readFile('/path')
  .then(data => { /* ... */ })
  .catch(err => { /* ... */ });

11.1.4 async/await 错误处理:同步语法下的异步陷阱

async/await 本质上还是 Promise,但它让异步代码看起来像同步代码,同时也让错误处理回到了熟悉的 try/catch 写法。这让很多开发者觉得“终于可以歇一口气了”,但如果不理解其底层依旧是 Promise,反而会产生新的误区。

async function readConfig() {
  try {
    const data = await fsPromises.readFile('/path/config.json', 'utf8');
    const config = JSON.parse(data);
    return config;
  } catch (err) {
    console.error('配置读取失败:', err.message);
    // 可以返回默认配置或抛出错误
    throw err; // 如果想让调用者感知,再次抛出
  }
}

这里的 try/catch 能够正常捕获错误,因为 await 让当前 async 函数暂停等待 Promise 的结果,如果 Promise 变为 rejected,它就相当于在 await 表达式中抛出错误,从而被外层 try 捕获。这比 .then().catch() 链更加直观。

但以下常见错误依然存在:

  • 忘记 await:如果不加 await,返回的是一个 Promise,错误不会被捕获。
  • 顶层 async/await:在模块顶层使用 await 需要 Node.js 的 ES 模块支持(.mjs 或在 package.json 中设置 "type": "module"),否则只能在 async 函数内部使用。很多项目的入口脚本并没有用 try/catch 包裹顶层的 async 调用,或没有使用 .catch(),这会导致 UnhandledPromiseRejection。
  • 并行请求中某个失败:使用 Promise.all 时,只要有一个 Promise 失败,整个就失败了,并且只报告第一个被拒绝的错误。如果希望所有请求都执行完再汇总错误,应该使用 Promise.allSettled
const results = await Promise.allSettled([
  fetchUser(1),
  fetchUser(2),
  fetchUser(3),
]);

for (const result of results) {
  if (result.status === 'fulfilled') {
    console.log(result.value);
  } else {
    console.error('某个请求失败:', result.reason.message);
  }
}

此外,async 函数在调用时必须在某处使用 .catch()try/catch,否则错误会丢失。比如:

async function start() {
  throw new Error('启动失败');
}
// 这里没有捕获,会导致 unhandled rejection
start();

正确的方式是:

start().catch(err => console.error(err));

或者用 IIFE 包裹调用并捕获:

(async () => {
  try {
    await start();
  } catch (err) {
    console.error(err);
  }
})();

11.1.5 全局异常捕获与进程保护

即便我们在业务代码中尽心尽力地处理每个可能的错误,总有一些意想不到的地方会抛出异常。为了不让整个 Node.js 进程因为未捕获的错误而崩溃,我们需要全局兜底机制。

Node.js 提供了两个关键的事件:

  • uncaughtException:当同步代码或异步回调中抛出了一个没有被 try/catch 捕获的错误,且一直冒泡到事件循环顶部时,会触发此事件。如果不监听,进程将直接退出。
  • unhandledRejection:当 Promise 被拒绝,但没有 .catch()await 处理时,会触发该事件。

我们可以监听这两个事件进行最后的日志记录或资源清理:

process.on('uncaughtException', (err) => {
  console.error('未捕获的异常:', err);
  // 做清理工作(如关闭数据库连接)后退出
  process.exit(1);
});

process.on('unhandledRejection', (reason, promise) => {
  console.error('未处理的 Promise 拒绝:', reason);
  // 可记录,但不一定退出,视项目策略而定
});

实际项目中的最佳实践:

  • uncaughtException 发生后,进程可能处于不一致的状态,内存被破坏或引用了已经释放的资源,因此建议记录日志后调用 process.exit(1) 退出,并由进程守护工具(如 PM2、Kubernetes)重启。不要试图恢复执行,那可能导致更严重的问题(如后续把错误的数据写入了数据库)。
  • unhandledRejection 同样需要重点关注,在开发阶段应确保没有任何 Promise 未被处理。可以利用 Node.js 的 --unhandled-rejections=strict 标志让未处理的拒绝强制终止进程。
  • 使用成熟的日志库(如 winston、pino)将错误信息写入标准错误输出或日志文件,并接入异常监控系统(如 Sentry、阿里云 ARMS),这样在进程重启前能保留完整的错误上下文。
  • 不要在 uncaughtException 中再次抛出异常,否则进程会再次崩溃,并且这次可能无法捕获。

另一个常见的工具是 domain 模块,但由于有内存泄漏和性能问题,已在 Node.js 中被标记为不推荐使用。现代项目应统一使用上述事件加良好的 async/await 实践。

11.1.6 兼顾代码可读性与安全性的错误处理模式

综合以上历史演进,我们如今在 Node.js 项目中的错误处理可以统一采用以下模式:

  1. 将所有异步回调 API 转换为 Promise(用 util.promisify 或直接使用 fs/promises 等)。
  2. 使用 async/awaittry/catch 包裹可能出错的业务逻辑,使错误处理与业务代码保持在同一层。
  3. 在路由或控制器层使用错误抛出而非直接向客户端发错误信息,由统一的错误处理中间件处理(如 Express 的 app.use((err, req, res, next) => ...)),这样可以保证响应格式一致且避免遗漏。
  4. 确保每个 async 调用链最终有 .catch() 或顶层 try/catch,可以通过 ESLint 的 no-floating-promises 规则自动检测。
  5. 启用进程级别的全局监听,但仅作为最后防线,并强制退出以避免状态污染。
  6. 在单元测试中显式断言 Promise 拒绝(如 await expect(promise).rejects.toMatch('error')),保证错误分支也被测试覆盖。

通过这些组合策略,Node.js 应用的健壮性将得到极大提升,不会再出现因为忘记处理一个回调错误而导致整个服务宕机的情况。