异步是 JavaScript 这门语言绕不开的核心议题。从早期依赖回调的 Node.js 风格 API,到 ES6 引入 Promise,再到 ES2017 落地 async/await,再到如今工程实践中常见的并发池、协程式调度,异步编程的抽象层级在持续上升。但抽象并不等于"自动正确"——不少团队在真实业务里依然踩着 forEachawait 失效、并发请求部分失败未兜底、Promise.race 误用等坑。

这篇文章把目前生产环境里常见的 7 种异步模式梳理一遍,按抽象层级从低到高展开,每个模式都给出真实代码和踩坑提示。读完你应当能在业务里准确选择合适的异步结构,而不是无脑套 async/await

一、回调函数:一切的起点

回调是最原始的异步抽象。事件循环把任务推入宏任务队列,回调在未来某个时刻被调用。Node.js 早期约定俗成的"错误优先"回调风格(err-first callback)至今仍能在 fs.readFilechild_process.exec 等模块看到。

const fs = require('fs');

fs.readFile('./config.json', 'utf8', (err, data) => {
  if (err) {
    console.error('读取失败', err);
    return;
  }
  const config = JSON.parse(data);
  console.log(config);
});

回调本身没问题,问题在"串行依赖"——第二步要用第一步的结果,第三步要用第二步的结果。于是代码会向右缩进,形成经典的回调地狱(Callback Hell):

getUser(userId, (err, user) => {
  if (err) return handleError(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);
    getOrderItems(orders[0].id, (err, items) => {
      if (err) return handleError(err);
      renderInvoice(user, orders[0], items);
    });
  });
});

这种结构可读性差、错误处理散落各处、几乎无法复用。它催生了下一代抽象:Promise。

二、Promise:把"未来值"对象化

Promise 是一个表示异步操作最终完成(或失败)及其结果值的对象。它有三种状态:pending(待定)、fulfilled(已兑现)、rejected(已拒绝)。状态一旦从 pending 转为 fulfilled 或 rejected 就不可逆,这是 Promise 可靠性的根本来源。

Promise 把回调嵌套拍平成链式调用,同样的"用户→订单→明细"流程:

getUser(userId)
  .then(user => getOrders(user.id))
  .then(orders => getOrderItems(orders[0].id))
  .then(items => renderInvoice(items))
  .catch(err => handleError(err))
  .finally(() => hideLoading());

.catch 会捕获整条链上任何一个 then 抛出的错误,错误处理从"每层一个 if err"收敛成一处。这是 Promise 相对回调最大的工程价值。

需要注意一个细节:.then 的回调返回值会被 Promise 包装。返回普通值会进入下一个 .then,返回 Promise 会等它决议,throw 会进入 .catch。新手常误以为返回值直接同步可用。

三、async/await:同步式写法

async/await 是 Promise 的语法糖。async 函数永远返回 Promise,await 会暂停当前 async 函数的执行,等待右侧 Promise 决议后把结果"返回"给左侧变量,看起来就像同步代码。

async function loadInvoice(userId) {
  try {
    const user = await getUser(userId);
    const orders = await getOrders(user.id);
    const items = await getOrderItems(orders[0].id);
    return renderInvoice(user, orders[0], items);
  } catch (err) {
    handleError(err);
  } finally {
    hideLoading();
  }
}

这里的关键词是"暂停当前 async 函数"。这点经常被误解:await 不会阻塞主线程,也不会阻塞事件循环。它只是把当前 async 函数的后续代码挂起,挂载为微任务(microtask)在 Promise 决议后排队执行。事件循环依然在转,其他任务该跑照跑。

所以 async/await 是"看起来同步、本质仍是异步"的语法糖,它没有改变 JS 单线程事件循环的本质,只是让代码读起来更线性。

四、Promise 的四个并发组合器

当多个异步操作互不依赖时,串行 await 是性能浪费。Promise 提供了四个并发组合器,它们的行为差异必须吃透,否则会写出隐藏 bug。下面这张表是我们团队内部培训时的速查表:

方法 是否等待全部 第一个 reject 的影响 失败后是否仍返回 典型场景
Promise.all 等全部 fulfilled 立即 reject 整体 否(fast-fail) 多个互不依赖请求,要么全成要么整体失败
Promise.allSettled 等全部 settle 不影响,记录为 rejected 项 是(返回每个结果状态) 批量上报、采集基线数据,关心每一项
Promise.race 只等第一个 settle 第一个若是 reject 则整体 reject 否(第一个决定结果) 请求超时控制、多源竞速
Promise.any 等第一个 fulfilled 忽略,除非全部 reject 否(取第一个成功) 多 CDN 容灾、最快可用源

最容易踩坑的是 Promise.all 的 fast-fail:只要有一个 reject,整体立刻 reject,其余请求虽然仍会跑完(无法取消 Promise),但它们的结果会被丢弃。如果你的业务需要"尽量全部拿到,个别失败容错",应该用 allSettled

const results = await Promise.allSettled(
  userIds.map(id => fetchUserProfile(id))
);

const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value);
const failed = results.filter(r => r.status === 'rejected')
                      .map((r, i) => ({ id: userIds[i], reason: r.reason }));

console.log(`成功 ${ok.length} 个,失败 ${failed.length} 个`);

Promise.any 在多源容灾里很顺手——同一个资源从主 CDN 和备用 CDN 同时拉,谁先成功用谁,只有两个都失败才报错。注意 Promise.any 在所有 Promise 都 reject 时抛出的是 AggregateError,需要用 err.errors 取出全部原因。

五、for...of + await:顺序异步的正确写法

当业务要求严格顺序(例如每一步依赖上一步、或者后端限流要求串行)时,应该用 for...of 循环搭配 await。它会真正地一个接一个等待。

async function syncImport(records) {
  const out = [];
  for (const record of records) {
    // 每条导入成功后才处理下一条
    const res = await importOne(record);
    out.push(res);
  }
  return out;
}

这里有一个高频陷阱:千万不要用 forEach 代替 for...offorEach 接收的是普通同步回调,await 在它内部不生效,循环会"立即完成",所有请求实际上并发乱序跑出去:

// 错误写法:await 不会按预期等待
records.forEach(async record => {
  await importOne(record); // 这里 forEach 不会等!
});
console.log('完成'); // 会在导入真正完成前就打印

原因在于 Array.prototype.forEach 的签名是 forEach(callback),它对 callback 返回的 Promise 视而不见。这是面试和真实代码里最常见的异步 bug 之一,正确做法是用 for...of(顺序)或 Promise.all(并发)替代。

六、生成器与协程式调度

在 async/await 普及之前,社区曾大量使用 generator(生成器)函数配合 co 这样的库来模拟协程调度。generator 的 yield 可以暂停函数执行,外部 next() 驱动它继续,天然适合做"暂停-恢复"的控制流。

function* loadFlow() {
  try {
    const user = yield getUser(userId);
    const orders = yield getOrders(user.id);
    const items = yield getOrderItems(orders[0].id);
    return { user, order: orders[0], items };
  } catch (err) {
    handleError(err);
  }
}

// 手写一个最简驱动器,类似 co 的核心逻辑
function run(gen) {
  const it = gen();
  return new Promise((resolve, reject) => {
    function step(method, arg) {
      const { value, done } = it[method](arg);
      if (done) return resolve(value);
      Promise.resolve(value).then(
        v => step('next', v),
        e => step('throw', e)
      );
    }
    step('next');
  });
}

run(loadFlow).then(console.log);

其实 async/await 在引擎层面正是 generator + 自动驱动器的语法糖(早期 Babel 就是这么转译的)。今天业务代码里直接写 generator 调度的场景不多了,但它仍广泛存在于:状态机(用 yield 表达状态)、Saga 中间件(redux-saga 用 generator 描述副作用,便于校验和取消)、自定义可迭代数据流。理解 generator 是理解更复杂异步控制(如 async generator、可取消任务)的基础。

值得一提的是 ES2018 引入的 async function*for await...of,能在异步迭代里逐项 await,适合处理 Node.js 可读流这种"异步分批到达"的数据:

async function* paginate(api) {
  let page = 1;
  while (true) {
    const { items, next } = await api(page);
    if (!items.length) break;
    yield* items;
    if (!next) break;
    page++;
  }
}

for await (const item of paginate(fetchPage)) {
  console.log(item); // 每页拉到后逐条处理
}

七、并发池:手写 pLimit 控制最大并发

Promise.all 一次性把所有请求打出去,数量少时没问题,但如果要批量处理 5000 条任务(例如批量发请求、批量上传分片),瞬间打满连接池、触发服务端限流、把客户端网络卡死几乎是必然的。生产环境的解法是并发池(concurrency pool):限制同时进行的任务数,跑完一个补一个。

下面是一个手写的 pLimit 实现,核心逻辑不到 30 行,行为和知名的 p-limit 库一致:

function pLimit(concurrency) {
  const queue = [];
  let activeCount = 0;

  const next = () => {
    if (queue.length === 0 || activeCount >= concurrency) return;
    activeCount++;
    const { fn, resolve, reject } = queue.shift();
    Promise.resolve()
      .then(fn)
      .then(resolve, reject)
      .finally(() => {
        activeCount--;
        next();
      });
  };

  return function (fn) {
    return new Promise((resolve, reject) => {
      queue.push({ fn, resolve, reject });
      next();
    });
  };
}

使用方式:用一个 limit 实例把每个任务函数包一层,Promise.all 依然负责汇总结果,但任意时刻在跑的任务不会超过设定的并发数。

const limit = pLimit(6); // 最大并发 6
const ids = Array.from({ length: 1200 }, (_, i) => i);

const results = await Promise.all(
  ids.map(id => limit(() => fetch(`/api/item/${id}`)))
);

我们在一个数据同步任务里实测:直接 Promise.all 跑 1200 个请求会稳定触发目标服务 429(Too Many Requests),平均耗时也会因重试拖到 40 秒以上;加上 pLimit(6) 后无报错,整体耗时稳定在 28 秒左右,吞吐反而更高。并发不是越大越快,找到目标服务能承受的拐点才是关键。

八、错误处理的几个盲区

异步代码的错误处理比同步代码坑多,挑三个最常踩的提醒一下。

1. forEach 里的 await 不生效,错误也接不住

前面已经说过 forEach 不会等待 async 回调。连带的问题是:回调里 throw 出来的错误不会被外层 try/catch 捕获,会变成 unhandledrejection。结论:需要 await 的循环,永远用 for...of

2. 并发请求的部分失败要用 allSettled

Promise.all 做批量请求时,一旦其中一条失败,整体 reject,前面已经成功的请求结果全被丢弃,UI 上要么全显要么全不显,体验很差。改成 allSettled 配合按状态分类,能让成功的正常展示、失败的降级提示,这才是生产环境该有的容错。

3. 忘记 await 导致错误被吞

调用一个 async 函数却忘了 await,相当于拿到一个 pending 的 Promise 直接丢掉。一旦内部 reject,没有任何 catch 接住,就成了静默错误。Node.js 从 15 版本起默认对 unhandled rejection 直接退出进程,前端浏览器虽然不退出但会在控制台报警。Lint 规则 no-floating-promises(来自 typescript-eslint)能从静态分析层面挡住这类问题,强烈建议开启。

九、怎么选:一页决策表

把上面七种模式按场景收敛成一句话决策:

  • 单步异步:async/await 包一层 try/catch,最直观。
  • 串行依赖:async 函数里多个 await,或 for...of + await。
  • 互不依赖的并发:Promise.all(要求全成)或 allSettled(允许部分失败)。
  • 竞速/超时:Promise.race 或 Promise.any。
  • 大批量任务:并发池 pLimit,保护下游服务。
  • 复杂可取消副作用:generator 或成熟的 Saga 方案。

异步编程的本质,是用合适的抽象把"未来才会发生的事"组织成可控、可读、可容错的代码。抽象层级越高越省心,但底层原理(事件循环、微任务/宏任务、Promise 状态机)始终决定着上层抽象的行为边界。理解了这层,遇到再复杂的异步场景都能拆得开、调得出。