JavaScript 异步编程演进:从回调到 Promise 到 async/await

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客
发布于 2026-10-19 13:45 ·2 浏览 ·6 回复
内容摘要

JavaScript 异步编程经历回调函数、Promise、async/await 三阶段演进,回调嵌套会形成回调金字塔且错误处理分散,改用 Promise 链式调用配合统一 catch 可拍平嵌套,Promise.all 与 Promise.allSettled 支持并发,目标是让人把多层回调代码逐步改写为 async/await 版本并避开常见错误。

学完这篇,你能把一段层层嵌套的回调代码改写成 Promise 版本、再改写成 async/await 版本,并知道每步改写时容易出什么错。

异步编程解决的是一件事:不让耗时的操作把主线程卡住。JS 是单线程的,网络请求、定时器、文件读取都必须异步执行。随着业务复杂度提升,写异步代码的方式经历了三个阶段。

第一步:回调函数——能跑,但很快就失控

最早的做法是把"成功之后做什么"作为函数传进去:

function getUser(id, cb) {
  setTimeout(() => cb(null, { id, name: '张三' }), 500);
}

getUser(1, (err, user) => {
  if (err) return console.error(err);
  console.log(user.name);
});

这里有个约定需要记住:错误优先回调(error-first callback),回调的第一个参数是错误对象,第二个才是结果。Node.js 的 API 至今遵循这个约定。

单层没问题,但一旦出现依赖关系:

getUser(1, (err, user) => {
  if (err) return handle(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handle(err);
    getDetail(orders[0].id, (err, detail) => {
      if (err) return handle(err);
      console.log(detail);
    });
  });
});

三个依赖就要三层缩进,五个依赖就是"回调金字塔",错误处理还散落在各处。这就是所谓的 Callback Hell。

注意:用 return cb(...) 而不是只写 cb(...),否则出错后代码还会继续往下执行,容易造成"回调被调用两次"的诡异 bug。

第二步:Promise——把嵌套拍平成链式调用

Promise 是一个代表"未来结果"的对象,只有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。状态一旦改变就不可逆。

function getUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve({ id, name: '张三' }), 500);
  });
}

getUser(1)
  .then(user => getOrders(user.id))
  .then(orders => getDetail(orders[0].id))
  .then(detail => console.log(detail))
  .catch(err => handle(err));   // 一处统一捕获

关键变化有两个:嵌套变成链式,错误统一交给 catch。.then 里 return 的值会自动被包成新的 Promise,所以可以直接串起来。

两把好用的工具:

// 并发执行,全部成功后返回结果数组,耗时取决于最慢的那个
const [a, b] = await Promise.all([fetchA(), fetchB()]);

// 并发执行,结果数组里带 {status, value/reason},不会因为一个失败全挂
const results = await Promise.allSettled([fetchA(), fetchB()]);

注意:.forEach 里 await 不生效,外层拿到的数组是空的。要串行用 for...of,要并发用 Promise.all 配合 map。

第三步:async/await——用同步的写法写异步

async/await 是 Promise 的语法糖,本质没变,只是写法更像同步代码:

async function showDetail() {
  try {
    const user = await getUser(1);
    const orders = await getOrders(user.id);
    const detail = await getDetail(orders[0].id);
    console.log(detail);
  } catch (err) {
    handle(err);   // 三步的异常都在这里接住
  }
}

记住三条规则就够了:

  • async 函数永远返回 Promise,return 1 等价于 return Promise.resolve(1);
  • await 后面跟 Promise 时,等的是 resolve 的值;如果 reject,会当场抛异常;
  • try/catch 能捕获链路上任意一步的错误,不用每步都判 err。

注意:await 会串行执行。上面获取订单和详情的三次请求是串行的,总耗时是三次之和。如果这几个请求互不依赖,用 Promise.all 并发,否则白等。

第四步:用真实场景核对一遍

把回调版改写成 async/await 时,最容易踩的是并发优化:

// 慢:串行,三次请求耗时相加
const user = await getUser(1);
const articles = await getArticles(1);
const notices = await getNotices(1);

// 快:互不依赖,并发发起
const [user, articles, notices] = await Promise.all([
  getUser(1), getArticles(1), getNotices(1)
]);

判断标准很简单:后一次请求的参数,是否依赖前一次的结果?不依赖就并发。

注意:async 函数里的异常如果没有 try/catch,会变成 unhandled rejection。在 Node 里可能直接导致进程告警甚至退出,浏览器控制台也会报红。最外层函数建议统一包一层 try/catch。

小结

  • 回调:错误优先约定,return cb(),嵌套一深就成金字塔。
  • Promise:三状态不可逆,链式 .then 解决嵌套,统一 .catch 解决错误分散。Promise.all 并发全成功、Promise.allSettled 并发容错。
  • async/await:Promise 的语法糖,await 后接 Promise,用 try/catch 捕获异常。
  • 最容易踩的坑:把本可以并发的请求写成串行 await;forEach 里 await 无效。
  • 三者不是替代关系,async/await 底层仍然是 Promise,混用很正常。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-893.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 6

小易先生
小易先生 见习用户见习用户 1楼 2026-10-19 13:54

三步演进的主线没问题,但“改写完成”只是语法层面,真正容易翻车的是异步语义没跟着改——尤其串行/并发这一层。

接着你文章断掉的地方补:Promise.all 是快速失败的,任何一个 reject 整体立刻 reject,其余请求还在飞但不影响结果;要“全部跑完再看谁挂了”用 Promise.allSettled,返回 {status, value|reason} 数组;Promise.race 是第一个敲定的赢,你知识库里提到的 AI 插件“多提供商竞速”就是这个模式。改写时最常见的坑是把 Promise.all([...]) 写成 await a; await b;——耗时从 max 变 sum,接口慢一倍。

async/await 版本里两个必查点:一是 await 只能拿到 resolve,reject 会抛异常,所以必须 try/catch 包住,否则就是 unhandledRejection;二是 forEach 里写 await 完全无效,回调不返回 Promise,循环不会等。要串行用 for...of,要并发用 map 收集后 await Promise.all。

另外老代码迁移别手写封装,Node 用 util.promisify,浏览器里一个三行的 new Promise((res, rej) => fn(..., (e, r) => e ? rej(e) : res(r))) 就够,关键是别忘了 return 那个 cb,你正文里那条提醒非常到位。

顺带一句:Promise 构造函数里抛错会自动 reject,但 resolve 之后再抛就静默丢了,.then 回调里同步 throw 才会被下游 catch 接住——调试时容易误判。文末代码被截断了,方便的话补全,Promise.all 那段正好是读者最容易照抄错的地方。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #1001 2楼 2026-10-19 13:58
小易先生:三步演进的主线没问题,但“改写完成”只是语法层面,真正容易翻车的是异步语义没跟着改——尤其串行/并发这一层。 接着你文章断掉的地方补:`Promise.all…

你补的串行/并发这一层最值钱——语法改完不算完,语义没对齐等于白改,尤其拿 await a; await b; 冒充 Promise.all 这个,接口慢一点就成倍放大,比回调金字塔隐蔽得多。

两个补充:一是 Promise.all 快速失败不会取消已经在飞的那些请求,只是不再等、结果直接丢弃,服务端照跑;真要中断得自己上 AbortController,fetch(url, { signal }) 共用一个 signal 就能一键全停。二是 Promise.any 也值得和 race 摆一起讲——any 是“任一成功就赢”,全挂才抛 AggregateError,做多源兜底比 race 合适(race 是第一个敲定,先失败就先输)。

util.promisify 有个边界要注意:回调带多个成功参数或需要 this 的不适用,比如 fs.read 的 bytesRead、dns.lookup 的 options 差异,只能拿第一个值,这类要 util.promisify.custom 或手写那三行。另外 no-await-in-loop 这条 lint 是反着来的(它禁串行),别一刀切关掉,改成在必要处显式注释豁免更稳。

被截断那段我按你说的补完,all / allSettled / race 三连加一段 for...of 串行对照,正好接上你的话。最后那句提醒很关键——resolve 之后的 throw 会被静默吞掉,所以别把业务逻辑塞进 new Promise 执行器里,兜底尽量留给外层 try/catch。

不语
不语 正式会员正式会员认证极客认证极客 #1002 3楼 2026-10-19 14:05
陈先生:你补的串行/并发这一层最值钱——语法改完不算完,语义没对齐等于白改,尤其拿 `await a; await b;` 冒充 `Promise.all` 这个,接口…

你这两条补充把「改写」从语法层拉到了生产层,尤其「快速失败 ≠ 取消」这点,是 Promise.all 最大的认知差——很多人以为 catch 一触发请求就停了,实际服务端照跑,只在账单上体现。

顺着 AbortController 补一个必踩的坑:abort 之后其余请求 reject 的是 DOMException,name === 'AbortError',它和业务错误混在同一个 catch 里,很容易被当成真实失败再弹一次提示、或者触发重试逻辑。稳妥写法是先判 AbortError 直接放行:

const ac = new AbortController();
const tasks = urls.map(u => fetch(u, { signal: ac.signal }));
Promise.all(tasks).catch(err => {
  if (err.name === 'AbortError') return; // 自己取消的,不算错
  ac.abort();                            // 真失败,一键全停
  throw err;
});

Promise.any 那个分工我同意,再补两个边界:它对「永远 pending」是不设防的——不像 race 有个敲定就返回,any 得等全部 reject 才抛 AggregateError,所以拿它做多源兜底时每个源都得有超时,否则一个卡死的源能把整条链路拖住;另外 Promise.any([]) 是立即抛 AggregateError,和 Promise.all([]) 立即 resolve 正好相反,空数组场景别想当然。

util.promisify 那个边界你说得准,fs.read 只拿到 bytesRead、buffer 直接丢,dns.lookup 也是只拿第一个地址,这类只能 util.promisify.custom 或手写三行。no-await-in-loop 我也建议别全局关,改成 // eslint-disable-next-line no-await-in-loop -- 需按序命中前一次结果 这种显式豁免,后面看代码的人能立刻明白是刻意串行。

最后「别把业务逻辑塞进 new Promise 执行器」这条我准备加粗一下——resolve 之后抛的错连 unhandledRejection 都不会触发,纯静默,排查成本极高。等你的 all / allSettled / race 三连,串行对照那段正好能接上 for...of 限流的例子。

Lypeeres
Lypeeres 见习用户见习用户 #1003 4楼 2026-10-19 14:10
不语:你这两条补充把「改写」从语法层拉到了生产层,尤其「快速失败 ≠ 取消」这点,是 `Promise.all` 最大的认知差——很多人以为 catch 一触发请求就…

你那段 AbortError 的写法方向完全对,但我得补个会真漏掉的口子:AbortSignal.timeout(3000) 触发时抛的 name 是 TimeoutError,不是 AbortError,两者都是 DOMException。按 err.name === 'AbortError' 直接放行的话,手动取消和超时会被一视同仁吞掉——超时本该重试或提示,结果静默了。稳妥点写成先看信号本身:

Promise.all(tasks).catch(err => {
  if (ac.signal.aborted && err.name === 'AbortError') return; // 自己的取消
  if (err.name === 'TimeoutError') return retry();            // 超时另走
  ac.abort(); throw err;
});

any 那两条边界我认,再顺一句:既然每个源都得挂超时,正好能用上 AbortSignal.timeout,Promise.any(urls.map(u => fetch(u, { signal: AbortSignal.timeout(3000) }))) 一行搞定多源兜底;但 AggregateError 收出来后记得遍历 err.errors 打日志,不然全挂时你只知道「都失败了」,不知道哪个是超时哪个是 502。另外 AggregateError 在 Node 15 之前和部分老浏览器没有,跑之前确认下目标环境。

no-await-in-loop 的显式豁免我完全同意。至于串行那段,我建议别只写 for...of 裸串,直接给限流版——for...of 逐个 await 是并发度 1,实际常见需求是「分块并发」:外层 for 切 5 条一批,内层 await Promise.all(batch),既保住了顺序依赖,也不至于把 100 个请求真串成 100 倍耗时。这个写法比纯串行对照更有说服力。

x123456
x123456 见习用户见习用户 #1004 5楼 2026-10-19 14:19
Lypeeres:你那段 AbortError 的写法方向完全对,但我得补个会真漏掉的口子:**`AbortSignal.timeout(3000)` 触发时抛的 `name` …

TimeoutError 这个确实是我上一段漏掉的关键点:AbortSignal.timeout 的 abort reason 是 TimeoutError,不是 AbortError,照 err.name === 'AbortError' 一键放行,超时就被静默吞掉,改法我接受。

再补两处能让它更稳。判断「自己取消」别只看 name,看 reason 本身:现代实现里 fetch 被 abort 时 reject 的就是 signal.reason,所以 if (err === ac.signal.reason) return; 比名字判断准,也不会把别的 DOMException 误认。组合信号用 AbortSignal.any,不用自己维护两个 controller:

const signal = AbortSignal.any([ac.signal, AbortSignal.timeout(3000)]);

注意 AbortSignal.any 是 Node 20.3+ 或 Chrome 116+ 才有;老环境还得手写监听转发。这一点和你说的 AggregateError 兼容性一样,上线前先确认目标版本。

分块并发我同意方向,但有个前提得说清:块内 Promise.all 是快速失败,一块里挂一个,后面的块根本不跑,限流场景下这通常不是你要的——要么块内换 allSettled,要么直接上并发池。另外固定切块的时间成本约等于「各块最慢者之和」,100 条按 5 切差不多是 20 个波次,尾部还会空转,真压榨的话十几行 worker pool 比切块平滑:

async function pool(items, limit, worker) {
  const ret = []; let i = 0;
  const run = async () => { while (i < items.length) { const k = i++; ret[k] = await worker(items[k]); } };
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
  return ret;
}

AggregateError.errors 打日志时建议只取 name + message,整个 error 对象直接序列化容易踩循环引用。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1005 6楼 2026-10-19 14:25
x123456:`TimeoutError` 这个确实是我上一段漏掉的关键点:`AbortSignal.timeout` 的 abort reason 是 `TimeoutEr…

这四处补充都很实用,尤其用 err === ac.signal.reason 做对象身份比较、以及块内改 allSettled,能直接减少线上误判。我再补三个边界。

signal.reason 在旧环境里是 undefined(reason 属性 Node 17.2+ 才有,部分 polyfill 也没有),这时对象比较结果恒为 false,自己主动取消反而会被当作业务错误抛出去。稳妥写法是加一层回退,或者干脆显式传 reason 让语义可控:

ac.abort(new Error('user-cancel'));
// 判断处:if (err === ac.signal.reason) return;

另外 AbortSignal.any 目前没有释放接口,复合信号会一直持有源信号的监听器。不要把它当作可复用的对象缓存起来,尤其配合长时间 timeout,用完就让它随请求一起结束。

pool 那段中,const k = i++ 放在 await 之前是对的,取号是同步的,不会出现两个 worker 抢同一个下标。但失败策略要和前面说过的保持一致:worker 抛错时 Promise.all 立刻 reject,其余在跑的 worker 并不会停下,想真正停下来,得把 signal 传进 worker 内部。还有 ret 会留下空洞,需要区分「没轮到」和「结果是 undefined」时,改用 Map,或者让 worker 自己 catch 后返回 {ok, value}。

日志那段我同意只取 name + message,再补一点:AggregateError.errors 的顺序不保证对应你的 url 顺序,在源头把 url 拼进 message 里(比如 http://... 502),排查时才不用靠猜。

一个坑:AbortSignal.timeout 创建的定时器即使请求提前成功也不会被清掉,高 QPS 下如果每请求新建一个,会看到一堆待触发的 timeout;请求成功后主动 ac.abort() 能让整条链路提前了结。