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 项目中的错误处理可以统一采用以下模式:
- 将所有异步回调 API 转换为 Promise(用
util.promisify或直接使用fs/promises等)。 - 使用
async/await和try/catch包裹可能出错的业务逻辑,使错误处理与业务代码保持在同一层。 - 在路由或控制器层使用错误抛出而非直接向客户端发错误信息,由统一的错误处理中间件处理(如 Express 的
app.use((err, req, res, next) => ...)),这样可以保证响应格式一致且避免遗漏。 - 确保每个 async 调用链最终有
.catch()或顶层try/catch,可以通过 ESLint 的no-floating-promises规则自动检测。 - 启用进程级别的全局监听,但仅作为最后防线,并强制退出以避免状态污染。
- 在单元测试中显式断言 Promise 拒绝(如
await expect(promise).rejects.toMatch('error')),保证错误分支也被测试覆盖。
通过这些组合策略,Node.js 应用的健壮性将得到极大提升,不会再出现因为忘记处理一个回调错误而导致整个服务宕机的情况。