一、异常处理的核心不是“捕获”,是“栈还在不在”
Node.js的异常传播机制有一条朴素的分界线:同步调用栈是否仍然存活。try/catch是JavaScript语言层面的结构,它按调用栈工作。当异常在try块所覆盖的同步执行路径中被throw时,catch能够截获它。一旦当前调用栈已经退出——比如setTimeout的回调、Promise的.then或await之后的恢复点——try/catch就失去了作用域。
这条分界线比记住三种“错误类型”更有用。同步错误、Promiserejection、回调中的异步抛出,它们的差异根源不在语法,而在异常发生时还有没有一个可被catch覆盖的栈帧。
// 代码号学习编程:三条路的分叉点
try {
JSON.parse('{invalid'); // ① 同步抛出,catch 接住
} catch (e) {
console.log('A:', e.name); // SyntaxError
}
try {
Promise.reject(new Error('B')); // ② 创建了 rejection,但 try 接不住
} catch (e) {
console.log('不会执行');
}
try {
setTimeout(() => {
throw new Error('C'); // ③ 在未来的 tick 抛出,try 早已退出
}, 0);
} catch (e) {
console.log('也不会执行'); // 直接冲向 uncaughtException
}
②和③的失败不是“写法不够规范”,而是try/catch的语义边界决定的。理解这一点之后,async/await的try/catch为什么有效也就清楚了:await将异步操作的恢复点重新拉回当前async函数的同步执行路径中,异常在await表达式处“落地”,此时try块依然在场。Node.js从v12起为async/await提供了异步栈追踪支持,这让捕获到的rejection堆栈能够跨越异步边界回溯调用来源。
二、Error-firstCallback:约定有效,但有一处高频塌陷
Node.js核心API中接受回调的异步方法,普遍遵循Error-first约定:回调的第一个参数是Error对象或null。这不是语言强制,而是社区契约,由Node.js早期设计确立并延续至今。
const fs = require('node:fs');
fs.readFile('./config.json', 'utf8', (err, data) => {
if (err) {
console.error('读取失败:', err.code); // 例如 ENOENT
return; // 关键:必须 return
}
console.log(JSON.parse(data));
});
return缺失是这个模式最常见的塌陷点。if(err)块执行完之后,代码会继续往下走,data此时是undefined,JSON.parse(undefined)会抛出一个与原始错误毫无关系的SyntaxError,把真正的故障现场淹没掉。在代码审查中,Error-first回调里if(err)之后是否紧跟return或else,值得逐处检查。
Error-first的另一处局限在于链式操作的累积。三层以上的嵌套回调中,每一层都要独立处理错误,代码横向膨胀。这也是Promise和async/await在Node.js生态中逐步成为主流异步错误处理方式的原因。
三、Promiserejection与async/await:接住,或者被进程接住
3.1一个不返回、不catch的async调用
async function loadUser(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// 错误写法
loadUser(1); // 返回的 Promise 被丢弃,rejection 无人认领
// 正确写法一
loadUser(1).catch(err => console.error('加载失败:', err.message));
// 正确写法二
async function main() {
try {
const user = await loadUser(1);
console.log(user);
} catch (err) {
console.error('加载失败:', err.message);
}
}
loadUser(1)返回一个Promise。如果它reject且没有任何.catch或await接住,Node.js会触发process的unhandledRejection事件。从Node.js15起,未处理的rejection默认导致进程退出,这一行为在后续版本中保持。这意味着“忘了catch”不再是静默失败,而是直接的生产事故。
3.2异步栈追溯的实际价值
在async函数中,try/catch捕获到的错误堆栈能够包含跨await的调用链。对比回调时代,这是一个实质性的可观测性提升。一个从数据库查询层reject、经过service层await传递、最终在路由层被捕获的错误,堆栈中可以看到完整的调用路径,而不是像callback那样只剩一个孤立的回调位置。
四、进程级兜底:uncaughtException不是“继续运行”的开关
process.on('uncaughtException')捕获的是已经没有任何其他handler接住的同步异常,以及从异步回调中直接抛出的异常。当一个异常到达这里时,当前操作的状态通常是不可知的:可能已经修改了部分内存数据、可能持有未释放的文件描述符、可能处于事务中间状态。
把uncaughtException当作“全局catch”让进程继续服务,是Node.js生产环境中风险较高的做法之一。进程在异常抛出后的状态无法保证一致,继续处理请求可能产生连锁的数据问题。合理的做法是:在该事件处理器中完成必要的同步清理(记录日志、关闭关键句柄),然后以非零码退出,由进程管理器重启。
process.on('uncaughtException', (err) => {
console.error('未捕获异常,进程即将退出:', err);
// 同步清理:刷新日志缓冲、关闭数据库连接池(若同步可行)
process.exit(1);
});
unhandledRejection的定位类似,但场景集中在Promise链。从Node.js15起默认退出行为已经将两者对齐。在编写库代码时,不应在库内部注册这两个进程级handler,因为这会干扰使用方对进程生命周期的控制。
五、边界测试与错误码识别
5.1一个可运行的边界测试
// 代码号学习编程:验证 try/catch 的边界
const assert = require('node:assert');
// 边界一:同步 throw 被捕获
assert.throws(() => { throw new TypeError('sync'); }, TypeError);
// 边界二:async 函数中的 throw 被 await 后的 try/catch 捕获
(async () => {
try {
await (async () => { throw new RangeError('async'); })();
assert.fail('不应到达');
} catch (e) {
assert(e instanceof RangeError);
}
})();
// 边界三:未 await 的 async 函数 rejection 不会被同步 try/catch 捕获
try {
(async () => { throw new Error('unawaited'); })();
} catch (e) {
assert.fail('不应捕获到');
}
// 该 rejection 最终由 unhandledRejection 处理
边界三的输出需要依赖进程级事件观察。在测试中,可以临时注册process.once('unhandledRejection',...)来断言该rejection确实到达了进程级,而不是被误认为被try/catch拦截。
5.2系统错误码的工程含义
Node.js系统错误的error.code是比error.message更稳定的识别依据。官方文档明确指出,code只在主版本之间可能变化,而message字符串可以在任意版本间调整。
| 错误码 | 常见来源 | 工程含义 |
|---|---|---|
ENOENT |
fs操作 |
文件或目录不存在。在容器环境中常指向路径挂载或WORKDIR配置问题 |
EACCES |
fs、child_process |
权限不足。检查文件模式与运行用户 |
ECONNREFUSED |
net、http |
目标端口无服务监听。常出现在依赖服务未启动或启动顺序错误时 |
ECONNRESET |
net、http |
对端强制关闭连接。可能是超时、重启或网络中间设备干预 |
EMFILE |
fs、net |
文件描述符耗尽。需要检查句柄泄漏或调高ulimit |
ERR_UNHANDLED_ERROR |
EventEmitter |
'error'事件无监听器时的默认抛出 |
六、自定义错误类:按层组织,而不是按“类型”堆砌
用newError('用户不存在')和newError('参数格式错误')区分业务异常,在catch块里靠字符串匹配判断,是项目初期常见但后期难以维护的做法。更结构化的方式是在应用层定义一个基类,携带可被错误中间件读取的属性。
// errors.js
class AppError extends Error {
constructor(message, code, status) {
super(message);
this.name = this.constructor.name;
this.code = code; // 业务码,如 'USER_NOT_FOUND'
this.status = status; // HTTP 状态码
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 'NOT_FOUND', 404);
}
}
class ValidationError extends AppError {
constructor(detail) {
super(detail, 'VALIDATION_FAILED', 400);
}
}
在错误处理中间件中,一次instanceofAppError判断即可统一处理所有业务异常并输出对应状态码,非预期错误则记录完整堆栈后返回通用500。这种分层的价值不在于“更面向对象”,而在于把可预期的操作失败与程序缺陷导致的异常在代码结构上分开,让日志和告警策略可以据此设置不同的阈值。
个人在项目中的反思是:早期不建错误类,等到几十个路由各自res.status(400).json({msg:'...'})之后,想把所有参数校验错误统一加上requestId和字段定位信息,需要改动的地方遍布整个代码库。自定义错误类不是过度设计,它的成本在项目规模扩大后会以维护效率的形式返还。
七、Express5的async处理与仍然需要手动做的事
Express5对返回Promise的handler增加了自动next(err)行为:async路由中throw或reject的Promise会自动进入错误中间件,不再需要express-async-handler之类的包装。这解决的是路由handler返回的Promise的rejection传递问题。
但有一个场景仍然需要手动处理:在handler内部启动了一个未被await的异步操作。例如在请求处理中触发一个“即发即忘”的审计日志写入,如果该写入失败,它不经过handler返回的Promise链,Express5的自动传递机制覆盖不到。这类旁路异步操作需要独立的.catch或在一个受控的任务队列中执行。
app.post('/orders', async (req, res) => {
const order = await createOrder(req.body); // Express 5 自动传递 rejection
auditLog('order_created', order.id)
.catch(err => logger.error('审计日志写入失败', err)); // 手动接住旁路异步
res.status(201).json(order);
});
八、常见误区速查
误区一:try/catch包住整个路由handler就能捕获所有错误。只能捕获handler同步执行路径中的错误和await落地处的rejection。setTimeout、未await的async调用、EventEmitter的'error'事件,都在try/catch的作用域之外。
误区二:unhandledRejection处理器注册了就安全了。注册只是阻止了默认的进程退出行为,但rejection对应的操作已经失败,业务状态可能处于不一致。该事件应作为可观测性信号使用,而非恢复手段。
误区三:error.message可以用来做程序判断。message随版本和语言环境变化。判断错误类型用error.code(系统错误)或自定义的error.code/error.name(业务错误),instanceof对自定义类适用。
误区四:async函数里没写await的调用不会出问题。它返回的Promise如果不被接住,rejection会直奔unhandledRejection。这是异步代码中隐蔽的泄漏点,lint工具(如@typescript-eslint/no-floating-promises)可以辅助发现。
九、练习与思考
练习一:构造一个场景,使process.on('uncaughtException')被触发,同时在该handler中读取process.memoryUsage()并写入文件。观察进程退出前数据是否成功落盘。然后改为在try/catch中处理同一异常,对比两者的可控性差异。
练习二:编写一个自定义RetryableError类,携带retryAfter毫秒数。实现一个withRetry(fn,maxAttempts)包装函数,仅当捕获到RetryableError且未超过次数时重试,其他错误直接向上抛出。用边界测试覆盖:成功、可重试失败后成功、可重试失败达上限、不可重试错误立即抛出。
思考题:在一个HTTP服务中,数据库连接池在超时后会reject查询Promise。如果每个路由的asynchandler都依赖Express5的自动错误传递,一个数据库超时错误会在哪里被捕获?它最终返回给客户端的是什么状态码?如果希望数据库超时返回503而非500,需要在错误处理链的哪一层做判断?
十、延伸阅读与参考文献
Node.js官方文档·Errors
https://nodejs.org/api/errors.html
错误类体系、系统错误属性(code、syscall、errno)、Node.js错误码全表的权威来源。error.code优于error.message做判断的依据出自此处。
Node.js官方文档·Errorpropagationandinterception
https://nodejs.org/api/errors.html#error-propagation-and-interception
区分同步throw、Promiserejection、Error-firstcallback、EventEmitter'error'事件四种传播机制的官方说明。文中关于异步API错误报告方式的分类是理解本节内容的基准。
Node.js官方文档·--unhandled-rejections模式
https://nodejs.org/api/cli.html#--unhandled-rejectionsmode
未处理rejection在进程层面的行为配置。从Node.js15起默认throw模式(导致退出)的变更记录可以在此追溯。生产环境调整该标志前应充分评估。
阿里云开发者社区·Node.js错误处理与全局异常捕获
https://developer.aliyun.com/article/1744543
对同步异常、Promiserejection、异步回调错误三条路径的对照分析,以及Express5自动错误传递的边界说明。其中关于uncaughtException定位的表述与工程实践一致。
HerokuCodeish·BestPracticesinErrorHandling
https://www.heroku.com/codeish-podcasts/51-best-practices-in-error-handling/
关于async/await异步栈追溯的版本节点(Node.js12+)以及按层(入站请求、出站调用、数据库层)组织错误处理而非分散catch的思路,对大型服务的错误架构设计有参考价值。