本节摘要:请求一多,"谁先谁后、谁依赖谁、失败了怎么收场"就成了主要矛盾。本节从回调地狱的形成机理讲起,用同一段"串行取三个接口"的需求演示回调、Promise 链、async/await 三种写法的演化,然后解决编排问题——串行、并行、限流并发各用什么工具,失败时的全有或全无策略,最后补上"上一次请求晚到覆盖新结果"的竞态防护。
订单详情页要取四份数据:订单基础信息、买家信息(依赖订单里的买家 ID)、物流轨迹(依赖订单号)、推荐商品(独立)。用最原始的回调写法:
fetchOrder(orderId, function (order) { fetchBuyer(order.buyerId, function (buyer) { fetchLogistics(order.trackingNo, function (logistics) { fetchRecommendations(function (recs) { render(order, buyer, logistics, recs); // 终于到正事 }, failHandler); }, failHandler); }, failHandler); }, failHandler);
代码向右无限生长,业务逻辑被缩进淹没——这就是教科书级的回调地狱。但它的病根值得说透,不是"缩进难看"这么浅:
其一,控制反转。你把"接下来做什么"交给了回调的调用方(fetchOrder 的实现),它什么时候调、调几次、有没有异常吞掉,你说了不算。其二,同步异步混杂。想在外面用 for 循环依次发请求?回调不返回值,循环拿到的是一堆 undefined。其三,错误处理碎片化。每个回调的失败都要单独接,failHandler 复制了四遍。其四,无法组合。"等订单和买家都到了再并行取物流与推荐"这种编排逻辑,在回调世界里要手写状态计数器。
Promise 解决的正是这四个病根,而不是"美化缩进"。

订单页的需求用 Promise 链写:
fetchOrder(orderId) .then(order => Promise.all([ Promise.resolve(order), // 把已到手的值混进并行队列 fetchBuyer(order.buyerId), fetchLogistics(order.trackingNo), fetchRecommendations() ]) ) .then(([order, buyer, logistics, recs]) => { render(order, buyer, logistics, recs); }) .catch(showError); // 全链错误一处收口
再用 async/await 写:
async function loadPage(orderId) { try { const order = await fetchOrder(orderId); // 串行依赖:等 const [buyer, logistics, recs] = await Promise.all([ // 并行无依赖:同发 fetchBuyer(order.buyerId), fetchLogistics(order.trackingNo), fetchRecommendations() ]); render(order, buyer, logistics, recs); } catch (err) { showError(err); } }
两个版本能力完全等价,但 async 版的可读性优势明显:"先等 order,再同发三个"这个业务时序,就是代码的物理顺序。Promise 链的 Promise.resolve(order) 这种"把旧值塞进新队伍"的小技巧,暴露了链式写法在表达复杂编排时的勉强。
但这不意味着链式写法没价值:简单的单步变换(取数据、转格式、落缓存)用 then 链反而轻快;复杂时序(条件分支、循环、中途变量多)用 async。两者是同一地基上的两种形状,混用毫无障碍。
Promise.all:全部成功才成功,任一失败立即整体失败(失败早退,但其余请求不会被取消,只是结果被丢弃)。适合"缺一不可"的渲染场景——订单页三块数据齐了才画页面。
两个容易被忽视的要点。其一,接受数组里的非 Promise 值(上例的 order 混用技巧由此而来)。其二,失败时其他在途请求继续跑完——想要"失败即全部叫停",用 AbortController 手动联动取消(4.4 节组合示例)。
Promise.allSettled:等全部落定,不管成败,给你每份的结果与状态。适合"尽力而为"的场景——页面主数据之外的通知、红点这些辅助信息,挂了不该拖垮主渲染:
const [main, notices] = await Promise.allSettled([ fetchOrder(orderId), fetchNotices() ]); if (main.status === 'fulfilled') renderOrder(main.value); if (notices.status === 'rejected') hideNoticeBadge(); // 通知挂了 静默降级
Promise.race:谁先落定用谁的结果(无论成败)。最经典的用途是超时竞争——请求与一个定时器赛跑(3.5 节超时的另一种实现思路),也用于"多源取同一数据,谁快用谁"。
Promise.any:第一个成功者胜出,全失败才失败。适合多 CDN 或多线路探测的场景。四个组合器的选择速记:全要 all、全等 allSettled、抢先 race、首个成功 any。
"依次"处理一列请求,async 函数里直接写 for 循环:
async function importAll(items) { for (const item of items) { await importOne(item); // 前一个完成才发下一个 严格串行 } }
⚠️ 高频陷阱:把"依次"误写成 items.map(async item => ...) 加外层 await——map 里的 async 函数立即全部启动,你得到的是并行而不是串行:
// 这是并行 不是串行 十个请求同时飞出 await Promise.all(items.map(async item => { await importOne(item); }));
两个写法各有正确用途:串行用在后一个依赖前一个结果、或必须严格按序执行的场合(导入、迁移);并行用在互不依赖的批量请求。语义想清楚再选工具,别让"看起来像循环"骗了自己。
并行放闸有代价:一百个请求同时发,浏览器对同域并发数有限制(HTTP/1.1 下约 6 个),排队反而更慢;服务端还可能触发限流(429)。串行又太慢。中间态是限流并发——维持固定数量的在途请求,完成一个补一个。一个二十行的池子实现:
async function runWithLimit(tasks, limit = 4) { const results = []; const executing = new Set(); for (const task of tasks) { const p = Promise.resolve().then(task).then( r => { executing.delete(p); return r; }, e => { executing.delete(p); throw e; } ); executing.add(p); results.push(p); if (executing.size >= limit) { await Promise.race(executing); // 等最快的一个完成 腾出名额 } } return Promise.all(results); } // 50 个批量校验请求 以 4 个为一批滚动执行 await runWithLimit(idList.map(id => () => validate(id)), 4);
机制一句话:用 Promise.race 盯着在途集合,谁先完成就放下一个进来。并发数的经验值:对自家接口 4 到 6,对第三方开放接口 2 到 3(还要看对方的限流文档)。5.1 节会看到 axios 生态里有现成的并发控制插件,但机制就是这二十行。
组合请求的失败处理是个设计决策,不是技术默认值:
全有或全无(Promise.all 加 catch):一块失败整页报错。适合强一致场景——账单页任何一块缺失都是事故。
逐个降级(allSettled 加逐项判断):失败的块显示局部错误或隐藏,其余照常。适合信息聚合页——新闻流里某卡片挂了不该连累整版。
自动重试失败项(allSettled 后对失败项退避重试一轮,再 all 一次剩余):批量任务的质量增强,注意只对网络类与 5xx 类错误重试(3.5 节的分类在此变现)。
let results = await Promise.allSettled(tasks); const failedIdx = results.map((r, i) => r.status === 'rejected' ? i : -1).filter(i => i >= 0); if (failedIdx.length) { const retried = await Promise.allSettled(failedIdx.map(i => tasks[i]())); failedIdx.forEach((idx, k) => { if (retried[k].status === 'fulfilled') results[idx] = retried[k]; }); }
3.4 节搜索联想的竞态问题,在流程控制层面再强调一次,因为它出现在一切"输入驱动请求"的场景:筛选、切换标签页、关键词变化。防护三选一:
序号对账(3.4 节示例):响应回来时核对请求序号,过期即丢弃。零依赖,最通用。
取消旧请求:发新请求前 abort 旧的,过期响应根本不会到达:
let currentController = null; async function onFilterChange(criteria) { if (currentController) currentController.abort(); // 掐掉上一棒 currentController = new AbortController(); try { const data = await request('/api/list', { signal: currentController.signal, body: criteria }); render(data); } catch (err) { if (err.name !== 'AbortError') showError(err); // 自己取消的 不算错 } }
闭包过期标记:组件化场景(React 类组件的 isMounted 思想的简化版)——闭包里存一个版本号,渲染回调核对。本质与序号对账相同。
选择建议:能用取消就用取消(还省流量),框架场景用框架提供的取消或过期标记,裸 JS 场景序号对账最省心。
不是,取决于语义。串行语义(后面的依赖前面)就该在 for 里 await;想要并行就用 Promise.all 包一批再 await 一个。把"循环加 await"一概贬为反模式的说法,混淆了两种正确的场景。
是的,已成功的结果会随整体 rejection 一起被丢弃。需要"保住成功部分"就用 allSettled。这也是两者最本质的区别:all 是事务语义,allSettled 是观测语义。
没有普适答案,量两个约束取小:浏览器同域并发上限(HTTP/1.1 约 6,HTTP/2 多路复用放宽但服务端仍有流控)与服务端限流阈值。对自家服务从 4 起步压测调整。
用综合练习收束本章。场景:运营仪表盘页,顶部四个统计卡(今日订单、活跃用户、总营收、告警数),中部一个趋势图(依赖时间范围选择),底部一个明细表(分页)。数据接口互相独立,时间范围切换只影响趋势图。要求:进入页面后总耗时约等于最慢的一个接口而非四个之和;切换时间范围时旧范围的请求不能污染新图表;任一卡片失败只影响自己。
先自己设计再对答案。并行层:四卡加首屏趋势图五个请求进 Promise.allSettled(任一失败不该拖垮全页,故不用 all);逐项检查结果,失败的卡片渲染错误态。竞态层:时间范围切换走"取消旧请求"方案——保存当前范围的控制器,切换时 abort 上一轮,AbortError 静默。降级层:明细表独立加载独立分页,与首屏无依赖,甚至可以延迟到首屏渲染完再发(4.1 节的优先级思想)。收尾:全部落定后隐藏页面级骨架屏——注意用 allSettled 而非计数器回调,代码短一半还不会漏分支。
这个练习的价值在于体会"编排设计先于代码":动手前先画出依赖图(谁依赖谁、谁可并行、谁能延迟)、标出竞态点(哪些操作会互相取代)、定义失败策略(整体降级还是局部降级)——三样画完,代码基本是照着抄。反过来,不画图直接写,大概率在第三个 then 里开始迷失,然后靠不断打补丁凑出一段谁也不敢动的代码。流程控制的能力,一半在工具(本节的组合器与池子),另一半在这张动笔前的设计图。
工作里还有一类任务:接手别人写好的多请求代码,判断它有没有问题。这里给一个"读码路线",比逐行啃快得多。第一步数 await 的层次:顶层串行了多少个请求,每个串行的理由是什么——说不出理由的串行就是嫌疑对象。第二步找失败出口:每个请求的错误有没有被接住、接住后是局部降级还是整体失败、有没有静默吞掉的 catch(空的 catch 块是重灾区)。第三步查竞态可能性:找出所有由用户输入触发的请求,问一句"快速连续触发会怎样"——没有取消或对账的,标红。第四步看并发面:有没有无上限的 map 并行(可能打爆连接与限流)、有没有不必要的逐个串行(白付延迟)。四步下来,一段几百行的数据加载代码的健康状况基本有数,比直接重构安全得多——读懂编排与写出编排是同一种能力的两副面孔,练习写的同时也在练读。接手存量代码时按这条路线走一遍并留下笔记,一个月后你会拥有一份别人没有的资产:整个项目数据流的活地图,从依赖结构到竞态点再到失败出口,哪块地雷埋在哪,图上清清楚楚——重构与排障的速度,从此与这份地图的精度成正比。而画图本身还有一个隐性收益:向别人解释系统时,你随手就能在白纸上复现依赖结构与竞态点——能把复杂讲简单的人,在任何团队都会被记住。