Async/Await 与 Node.js 中的错误处理

1. 概述

async/await 可以让你在 Node.js 中编写看起来像同步代码的异步代码,比使用 callback 或 .then() 链更易读。但如果不理解其底层机制,很容易写错错误处理方式,导致 UnhandledPromiseRejection 让整个应用崩溃。

本文从 async/await 的工作原理开始,介绍标准的错误处理模式,再到与 Promise.all、循环和 Express 中间件结合时的常见错误。读完后,你将知道如何安全地编写异步函数,并更快地调试异步错误。

2. Async/Await 如何工作

声明为 async 的函数总是返回一个 Promise,无论你在里面 return 什么。await 只是在调用点“暂停”,将控制权交给事件循环,不会阻塞线程。

async function getUser(id) {
  const response = await fetch(`https://api.example.com/users/${id}`);
  const user = await response.json();
  return user;
}

getUser(1).then(user => console.log(user.name));

await fetch(...) 这一行等待 fetch 返回的 Promise resolve 才继续执行。如果 fetch reject(比如网络中断),错误会在 await 处立即抛出——如果你没有捕获它,整个 getUser 返回的 Promise 也会被 reject。

注意: async function 总是返回 Promise,即使你没有在内部 await 任何东西。调用它时忘记 await 或 .then() 是“代码执行顺序错乱” bug 最常见的原因。

3. 使用 try/catch 捕获错误

在异步函数中处理错误的标准方法是在 await 周围包装 try/catch。

async function getOrder(orderId) {
  try {
    const order = await orderRepository.findById(orderId);
    if (!order) {
      throw new Error(`未找到订单 ${orderId}`);
    }
    return order;
  } catch (err) {
    console.error('获取订单失败:', err.message);
    throw err; // re-throw 以让调用者知道并继续处理
  }
}
  • try 包围所有可能 throw 或 reject 的逻辑。
  • catch (err) 接收手动 throw 的错误和 Promise reject 的错误。
  • 最后 throw err 以免“吞掉”错误——调用者仍然会收到被 reject 的 Promise。

注意: 如果你捕获了错误但没有重新 throw 也没有 return 有效值,异步函数会以 undefined 解决。调用者认为一切正常,但数据却缺失——这种 bug 很难发现。

3.1. 自定义错误类

为了清晰地分类错误(业务错误、系统错误等),应该创建自定义错误类。

class PaymentError extends Error {
  constructor(message, code) {
    super(message);
    this.name = 'PaymentError';
    this.code = code;
  }
}

async function chargeCard(order) {
  const result = await paymentGateway.charge(order.total);
  if (!result.success) {
    throw new PaymentError('卡被拒绝', 'CARD_DECLINED');
  }
  return result;
}

捕获错误时,你可以检查 err instanceof PaymentError 根据错误类型进行不同处理,而不是比较字符串消息容易出错。

4. Promise.all 和 Promise.allSettled 中的错误

Promise.all 并行运行多个 Promise,但只要有一个 reject,整个就会立即 reject,其他 Promise 仍在“后台”运行但结果会被忽略。

async function notifyCustomers(customers) {
  const results = await Promise.all(
    customers.map(c => emailService.send(c.email))
  );
  return results;
}

如果第 2 个客户的 emailService.send 失败,整个函数会立即 reject——你不知道客户 1、3、4 是否已成功发送。

当你想知道所有 Promise 的结果,无论是否失败,使用 Promise.allSettled:

async function notifyCustomersSafely(customers) {
  const results = await Promise.allSettled(
    customers.map(c => emailService.send(c.email))
  );

  const failed = results.filter(r => r.status === 'rejected');
  if (failed.length > 0) {
    console.warn(`${failed.length} 封邮件发送失败`);
  }
  return results;
}

Promise.allSettled 永远不会 reject。results 中的每个元素的形式为 { status: 'fulfilled', value } 或 { status: 'rejected', reason }。你自己决定如何处理。

[
  { status: 'fulfilled', value: true },
  { status: 'rejected', reason: Error: SMTP timeout },
  { status: 'fulfilled', value: true }
]

5. 在循环中使用 Await 的常见错误

在 forEach 中使用 await 是一个非常常见的错误——forEach 的回调不会等待异步。

// 错误: forEach 不能 await
async function processOrders(orders) {
  orders.forEach(async order => {
    await orderService.process(order);
  });
  console.log('完成'); // 立即运行,不等待订单处理完成
}

forEach 调用异步回调后忽略返回的 Promise,不会等待 resolve。console.log('完成') 在任何订单处理完成之前就运行了——如果 process 抛出错误,该错误不会被任何地方捕获,导致 UnhandledPromiseRejection。

如果需要顺序处理,使用 for...of 修复:

async function processOrdersSequentially(orders) {
  for (const order of orders) {
    await orderService.process(order);
  }
  console.log('完成');
}

或者如果想并行处理,使用 Promise.all 与 map:

async function processOrdersParallel(orders) {
  await Promise.all(orders.map(order => orderService.process(order)));
  console.log('完成');
}

6. Express 中间件中的错误处理

Express 默认不会捕获 route handler 中异步函数的错误——如果你在内部 throw,请求会挂起或在旧版 Node 中导致进程崩溃(Express 4 不会自动捕获 Promise rejection)。

// 风险: Express 不会自动捕获异步错误
app.get('/orders/:id', async (req, res) => {
  const order = await orderService.getOrder(req.params.id);
  res.json(order);
});

安全的做法是编写一个 wrapper 将错误传给 next():

function asyncHandler(fn) {
  return (req, res, next) => {
    fn(req, res, next).catch(next);
  };
}

app.get('/orders/:id', asyncHandler(async (req, res) => {
  const order = await orderService.getOrder(req.params.id);
  if (!order) {
    const err = new Error('未找到订单');
    err.statusCode = 404;
    throw err;
  }
  res.json(order);
}));

app.use((err, req, res, next) => {
  res.status(err.statusCode || 500).json({ message: err.message });
});

asyncHandler 包装 route handler,如果返回的 Promise 被 reject,.catch(next) 会将错误传给 Express 的错误处理中间件,而不是让它自由落体。

注意: Express 5(在撰写本文时仍是试验版)已经会自动捕获异步 handler 的 rejection,但对于目前广泛使用的 Express 4,你仍然需要这个 wrapper。

7. 超时和 AbortController

一个容易被忽略的错误:await 一个永不 resolve 的 Promise 会导致函数无限挂起。使用 AbortController 来限制等待时间。

async function fetchWithTimeout(url, timeoutMs = 3000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    return await response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new Error(`对 ${url} 的请求在 ${timeoutMs}ms 后超时`);
    }
    throw err;
  } finally {
    clearTimeout(timer);
  }
}

setTimeout 在 timeoutMs 后调用 controller.abort(),导致 fetch 以名为 AbortError 的错误 reject。finally 始终清理计时器以避免内存泄漏,无论成功还是失败。

8. 为异步函数编写测试

使用 Jest 来测试成功和失败的情况。

<!-- package.json (dependencies 部分,不是 Maven) -->
npm install --save-dev jest
// orderService.js
async function getOrder(orderId, repository) {
  const order = await repository.findById(orderId);
  if (!order) {
    throw new Error(`未找到订单 ${orderId}`);
  }
  return order;
}

module.exports = { getOrder };
// orderService.test.js
const { getOrder } = require('./orderService');

describe('getOrder', () => {
  it('发现时返回订单', async () => {
    const mockRepo = { findById: jest.fn().mockResolvedValue({ id: 1, total: 100 }) };

    const order = await getOrder(1, mockRepo);

    expect(order).toEqual({ id: 1, total: 100 });
  });

  it('找不到订单时抛出错误', async () => {
    const mockRepo = { findById: jest.fn().mockResolvedValue(null) };

    await expect(getOrder(99, mockRepo)).rejects.toThrow('未找到订单 99');
  });
});

mockResolvedValue 模拟 Promise resolve 为给定值。rejects.toThrow 检查 Promise 以期望的消息 reject——这是测试异步错误的标准方式,而不是在测试中手动包装 try/catch。

PASS  ./orderService.test.js
  getOrder
    ✓ 发现时返回订单 (3 ms)
    ✓ 找不到订单时抛出错误 (2 ms)

9. 需要避免的常见错误

  • 忘记 await: 调用异步函数但不 await 导致代码执行顺序错乱,错误被“吞掉”。
  • 混合使用 .then() 和 await: 容易引起混淆,在同一个函数中只选择一种风格。
  • try/catch 范围过大:包装与异步无关的逻辑会导致难以判断错误来源。
  • 在 catch 中不重新 throw:导致调用者收到 undefined 而不是错误,在上层代码中造成隐性 bug。
  • 在 forEach 中使用异步回调:无法等待,应该使用 for...of 或 Promise.all。

注意: 如果你的逻辑中没有任何 I/O 或异步操作,就不需要将函数标记为 async——这只会增加不必要创建 Promise 的开销。

10. 总结

async/await 使 Node.js 中的异步代码更易读,但正确的错误处理才是让应用稳定运行的关键。始终在 await 周围包装 try/catch,在需要时重新 throw 错误,当你想知道所有并行任务的结果时使用 Promise.allSettled,还要记得为 Express route handler 编写 wrapper。为成功和错误情况编写测试可以让你在之后重构异步逻辑时更有信心。

nodejsasync-awaitjavascripterror-handlingpromise