在 Callback 时代,Node.js 约定错误优先回调(err-first callback),通过 if (err) 分支手动传递错误。这种方式在面对深层嵌套时极易漏捕异常,形成“回调地狱”。Promise 和 async/await 从根本上改变了异步错误处理的方式,使代码既便于组合,也能用同步风格的 try/catch 统一捕获。
1. Promise 的异常机制:rejection 与 .catch()
Promise 有三种状态:pending、fulfilled、rejected。当操作失败时,Promise 会进入 rejected 状态,并携带一个错误原因。这个错误不会像同步代码那样被抛出到外层,而会沿着 Promise 链向下传递,直到被 .catch() 捕获。
function fetchData() {
return new Promise((resolve, reject) => {
// 模拟异步操作
setTimeout(() => {
const err = new Error('数据读取失败');
reject(err);
}, 100);
});
}
fetchData()
.then(data => console.log(data))
.catch(error => {
console.error('捕获到错误:', error.message);
});
.catch() 本质上就是 .then(null, rejectionHandler) 的语法糖。它不仅可以捕获由 reject() 显式引发的错误,还可以捕获在 .then() 回调中同步抛出的异常。例如:
Promise.resolve()
.then(() => {
throw new Error('同步错误');
})
.catch(err => {
console.error(err.message); // 会被捕获
});
但需要注意,如果在 .then() 中异步抛出的错误(如 setTimeout 内),无法被外层 .catch() 捕获,因为此时回调栈已经脱离 Promise 的控制:
Promise.resolve()
.then(() => {
setTimeout(() => {
throw new Error('异步错误'); // 无法被 .catch 捕获,会导致进程崩溃
}, 100);
})
.catch(err => {
// 永远不会执行
});
因此,在 Promise 链中要始终通过 reject() 来表示异步失败,而不是随意在异步回调中 throw。
2. async/await:用同步写法处理异步错误
async/await 让异步代码的外观看上去与同步代码几乎一致,也让 try/catch 重新成为主要的错误捕获手段。一个 async 函数内部,await 一个 Promise 时,如果该 Promise 被 reject,这个错误会作为异常被抛出到当前执行环境中,可被 try/catch 捕获。
async function getUser(id) {
try {
const user = await db.getUser(id); // 若 db.getUser 返回 rejected Promise,抛出异常
const posts = await db.getPosts(user.id); // 若失败也会被 catch 捕获
return { user, posts };
} catch (error) {
console.error('获取用户数据失败:', error.message);
// 可以在这里返回默认值或重新抛出
throw error;
}
}
这种写法比 Promise 链的 .catch() 更直观,尤其是当有多个 await 调用时,无需写链式回调,也避免了深层嵌套。但也要注意几个常见陷阱:
- 忘记
await:如果调用异步函数时忘了加await,得到的只是一个 pending Promise,并且错误不会被捕获,最终可能变成unhandledRejection。 - 循环中的异步:
forEach等数组方法不会等待异步回调内部的操作,它们的异常也无法被外层的try/catch捕获。如果需要顺序处理,应该使用for...of循环并用await。
// ❌ 错误写法:forEach 中的 async 回调错误无法被捕获
try {
[1, 2, 3].forEach(async (id) => {
const data = await getData(id); // 若抛出异常,外层 try/catch 抓不到
});
} catch (err) {
// 不会执行
}
// ✅ 正确写法:使用 for...of 或 Promise.all 配合映射
try {
for (const id of [1, 2, 3]) {
const data = await getData(id); // 异常会被捕获
}
} catch (err) {
console.error('处理失败:', err);
}
3. 全局异常兜底:unhandledRejection
即使代码中已经使用了 .catch() 或 try/catch,仍然可能存在未被处理的 rejected Promise。例如,某个异步调用既没有 await,也没有 .catch(),或者在一个非 async 函数中直接调用了一个 async 函数。这种情况下,Promise 的错误会变成“未处理的拒绝”,可能导致进程崩溃。
从 Node.js v15 开始,未处理的 Promise rejection 会导致进程以非零状态退出。因此,必须在全局层面注册兜底处理:
process.on('unhandledRejection', (reason, promise) => {
console.error('全局未处理 Promise 拒绝:', reason);
// 记录日志、发送告警,必要时优雅退出
});
这个处理器应放在项目入口文件的最顶部,用于捕获所有未被局部处理的 Promise 异常,防止进程意外崩溃。需要注意的是,unhandledRejection 仅用于兜底,不应替代局部的精确错误处理,因为此时已经没法恢复上下文。
4. 实践中的错误处理模式
在真实项目中,一个健壮的异步错误处理体系通常包含以下几点:
- 在 Service/Repository 层抛出有意义的应用错误,如通过自定义
AppError类携带状态码和错误详情。 - 在路由 handler 中统一 try/catch(或使用中间件封装),将错误转发给 Express/Koa 的统一错误处理中间件,避免在每一个路由里重复写日志和响应。
- 利用高阶函数减少重复模板:例如封装一个
asyncHandler(fn)函数,将 async 路由包装为自动捕获异常的中间件。 - 结合日志系统:捕获错误后使用 winston 或 pino 记录完整上下文,便于排查。
// Express 异步错误包装器
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// 使用
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await userService.getById(req.params.id);
res.json(user);
}));
5. 小结
- Promise 的 rejection 会被
.catch()捕获,同步抛出的异常也一样,但异步回调中的 throw 无法捕获。 - async/await 让
try/catch成为可能,代码可读性和错误处理一致性都大幅提升,但需注意await的使用方式。 - 全局
unhandledRejection是最后一道防线,必须注册以防止进程意外退出。 - 团队应规范错误处理模式,避免在每个异步调用处都重复 try/catch,同时确保所有 rejected Promise 都有明确的处理路径。
良好的异步错误处理不仅是保证服务稳定性的基础,也是后续定位线上问题的关键。将错误捕获与日志、告警系统相连,可以在问题发生时快速响应,实现真正的可观测性。