人人都会AI编程

Promise 异常、async/await 错误处理

更新时间:2026-07-11

在 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 都有明确的处理路径。

良好的异步错误处理不仅是保证服务稳定性的基础,也是后续定位线上问题的关键。将错误捕获与日志、告警系统相连,可以在问题发生时快速响应,实现真正的可观测性。