有个事情我一直想聊聊。

前段时间帮一个朋友看他的 Node.js 项目,有个接口特别慢——用户列表页加载要 6 秒多。我一开始以为是数据库查询的问题,结果打开 Chrome DevTools 一看,数据库查询本身只花了 200ms。

那剩下的 5 秒多去哪了?

我翻了翻代码,发现问题出在一个看起来完全"正常"的 for...of 循环里。他在循环里一个一个地 await,本来可以并行跑完的 10 个请求,被他写成了排队等候。

老实讲,这种写法我自己以前也干过。而且我敢打赌,正在看这篇文章的你,项目里大概率也有类似的代码。


陷阱一:循环里的 await 是个隐形杀手

先看一段几乎每个人都写过的代码:

// ❌ 看起来没毛病,但慢得离谱
async function getUserProfiles(userIds) {
  const profiles = [];
  for (const id of userIds) {
    // 每次循环都要等上一个请求完成才发下一个
    const profile = await fetchProfile(id);
    profiles.push(profile);
  }
  return profiles;
}

这段代码的问题在于:假设 fetchProfile 每次耗时 500ms,10 个用户就是 5000ms 串行等待

但这 10 个请求之间根本没有依赖关系。它们完全可以同时发出去。

改一种写法,效果天差地别:

// ✅ 并行请求,快了不止一点
async function getUserProfiles(userIds) {
  const promises = userIds.map(id => fetchProfile(id));
  // 所有请求同时发出,等最慢的那个完成就行
  const profiles = await Promise.all(promises);
  return profiles;
}

同样 10 个请求,并行跑的话总耗时约等于单个最慢请求的时间,大概 500-600ms。从 5 秒到 0.5 秒,差了将近 10 倍

就改了两行代码。


陷阱二:Promise.all 的"一挂全挂"问题

不过用 Promise.all 也不是没有坑。

它有一个很多人不知道(或者知道但没当回事)的特性:只要有一个 Promise 失败,整个 Promise.all 就会立即 reject,其他已经成功的结果你也拿不到。

// ⚠️ 如果第3个用户的请求失败了
// 前2个成功的结果也会被丢掉
async function getUserProfiles(userIds) {
  try {
    const profiles = await Promise.all(
      userIds.map(id => fetchProfile(id))
    );
    return profiles;
  } catch (error) {
    // 这里只能拿到第一个失败的错误
    // 成功的那些结果?没了
    console.error('有一个请求挂了:', error);
    return [];
  }
}

如果你的业务场景是"10 个里挂了 1 个也没关系,其他 9 个该展示还得展示",那 Promise.all 就不合适了。

这时候该用的是 Promise.allSettled

// ✅ 不管成功失败,全部结果都拿到
async function getUserProfiles(userIds) {
  const results = await Promise.allSettled(
    userIds.map(id => fetchProfile(id))
  );

  // 每个结果都有 status 字段:'fulfilled' 或 'rejected'
  const profiles = results
    .filter(r => r.status === 'fulfilled')
    .map(r => r.value);

  const failures = results
    .filter(r => r.status === 'rejected');

  if (failures.length > 0) {
    console.warn(`${failures.length} 个请求失败,但不影响其他结果`);
  }

  return profiles;
}

Promise.allSettled 会等所有 Promise 都结束——不管成功还是失败——然后把每个结果都完整地告诉你。你可以自己决定怎么处理失败的那几个。

这在做列表页、批量操作、数据聚合的时候特别有用。


陷阱三:无意识的瀑布式 await

这个坑更隐蔽。看看这段代码,你觉得有什么问题?

// ❌ 看起来像是在一步步处理数据
// 实际上是无意识的串行瀑布
async function loadDashboard() {
  const user = await fetchUser();
  const orders = await fetchOrders();
  const notifications = await fetchNotifications();

  return { user, orders, notifications };
}

三个请求之间有依赖关系吗?没有。用户信息、订单列表、通知列表——三个完全独立的数据源。

但因为你一行一行 await,它们就变成了:先等用户 → 再等订单 → 最后等通知。三个各 300ms 的请求,加起来就是 900ms。

怎么改?

// ✅ 三个独立请求并行发出
async function loadDashboard() {
  const [user, orders, notifications] = await Promise.all([
    fetchUser(),
    fetchOrders(),
    fetchNotifications(),
  ]);

  return { user, orders, notifications };
}

改完之后总耗时 ≈ 300ms。快了 3 倍。

判断标准其实很简单:两个 await 之间,后面那个是否依赖前面那个的结果?如果不依赖,它们就应该并行。


陷阱四:await 不是万能胶水

还有一种场景经常被忽略。有时候你 await 了一个根本不需要等待的操作:

// ❌ 日志记录不需要等待——你又不用它的返回值
async function processOrder(order) {
  const result = await saveOrder(order);

  // 记录日志非得等它写完吗?
  await logActivity('order_created', order.id);

  return result;
}

logActivity 是个写日志的操作——你不需要它的返回值,也不需要等它完成才继续。这是一个典型的"发射后不管"(fire-and-forget)的场景。

// ✅ 不需要等待的操作就别等
async function processOrder(order) {
  const result = await saveOrder(order);

  // 用 void 明确表示"我知道这是个 Promise,我故意不等它"
  void logActivity('order_created', order.id);

  return result;
}

void 前缀可以消除 ESLint 的 no-floating-promises 警告,同时也让代码意图更清晰:我知道这是异步操作,我故意不 await 它。

当然,如果日志写入失败会影响业务,那你还是得 await。但大多数场景下,日志、埋点、统计这类操作完全没必要阻塞主流程。


陷阱五:错误处理的"吞错"问题

最后一个,也是最容易被忽视的。

// ❌ 看起来做了错误处理,但其实在吞错
async function fetchData() {
  try {
    const data = await riskyApiCall();
    return data;
  } catch (error) {
    console.log(error); // 就打了个 log 然后呢?
    // 没有 return,没有 throw
    // 调用方拿到的是 undefined
  }
}

调用 fetchData() 的人以为拿到了数据,结果拿到了 undefined,然后这个 undefined 一路传下去,在某个不相关的地方爆出 Cannot read property 'xxx' of undefined

你 debug 半天,发现根因在这段看起来"做了错误处理"的 try-catch 里。

// ✅ 要么重新抛出,要么返回明确的错误标记
async function fetchData() {
  try {
    const data = await riskyApiCall();
    return data;
  } catch (error) {
    console.error('请求失败:', error.message);
    // 方案A:重新抛出,让调用方决定怎么处理
    throw error;
    // 方案B:返回明确的错误标记
    // return { error: true, message: error.message };
  }
}

要么把错误重新抛出去让上层处理,要么返回一个明确的标记告诉调用方"我失败了"。千万别 catch 了之后啥也不干——这比不写 try-catch 还危险。


一个简单的自查清单

下次写 await 的时候,花 3 秒问自己三个问题:

  1. 这几个 await 之间有依赖关系吗? 没有 → 用 Promise.all
  2. 这个操作的结果我真的需要等吗? 不需要 → void 发出去就行
  3. 我的 catch 里有没有吞掉错误? 有 → 要么 throw 要么返回明确标记

这三个问题,可能帮你省掉一半的接口响应时间,还有好几个小时的 debug 时间。


你们项目里有没有在循环里写 await 的代码?改完快了多少?评论区聊聊。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐