☰
Promise.all 实战:告别串行等待,前端并发请求优化加载速度
2026/10/22 14:24:20 网站建设 项目流程

去年我接手一个内部管理后台的首屏优化任务,页面本身不复杂——顶部是用户信息,中间是操作日志表格,底部堆着角色权限树和几张统计图表。刚看到线上反馈说"打开要四秒"时,我第一反应是接口太慢,后端得背锅。结果走查 Network 面板才发现,六个接口里最快的只要 180 毫秒,最慢的也就 700 毫秒,按常理完全不该让用户等那么久。真正拖垮加载时间的,是代码里一个接一个的await——每个接口都在等前一个完全返回后才发起,整个请求队列像一串糖葫芦似的排在网络瀑布图里。要解决这种问题,我最先想到的就是 Promise.all,用它把串行的等待改成并发发起,加载速度的提升往往能直接用一个瀑布图的变化说清楚。

这篇内容不是讲 Promise.all 的 API 用法,那文档里写得很明白。我想分享的是在真实项目里用 Promise.all 优化加载速度时,你会遇到的时机问题、依赖问题、超时问题和渲染策略问题,以及我踩过坑之后留下来的几套稳定写法。适合已经写过几个页面、对异步请求有基本概念,但还没系统做过加载优化的人参考。

1. 慢的根源往往不在接口本身,而在请求排队

1.1 串行等待的时间累积公式

先回到那个管理后台。初始代码的样子很典型,每个模块一个函数,从上到下依次执行:

// 改造前:串行等待,耗时 = 所有接口耗时之和 async function loadDashboard() { showLoading(); const userRes = await request('/api/user'); renderUser(userRes); const roleRes = await request('/api/roles'); renderRoles(roleRes); const logRes = await request('/api/logs'); renderLogs(logRes); const statRes = await request('/api/stats'); renderStats(statRes); const msgRes = await request('/api/messages'); renderMessages(msgRes); const notifyRes = await request('/api/notifications'); renderNotifications(notifyRes); hideLoading(); }

单看每行代码都没毛病:先请求一个数据,渲染一块区域,再请求下一个。逻辑清晰,可代价就是加载时长被硬生生拉长成了所有接口耗时的总和。用公式描述就是 T 串行 = T1 + T2 + T3 + ... + Tn。当时六个接口串行跑出来分别是 180 毫秒、350 毫秒、220 毫秒、480 毫秒、700 毫秒、150 毫秒,加起来 2080 毫秒,再加上页面渲染和静态资源加载,转圈时间接近三秒。

而 Promise.all 优化加载速度的核心思路,就是把这套加法关系改成取最大值:T 并行 = max(T1, T2, ..., Tn)。所有请求同时发出去,最终总耗时不再等于各段之和,而是取决于最慢的那一个。还是上面那六个接口,并发后总耗时理论上会逼近 700 毫秒左右——也就是那个最慢的角色权限接口。光改这一处,页面等待时间就能砍掉一大半。

我见过不少开发者觉得"反正就那几次请求,串行也没差多少",这是典型的错觉。两个接口各 300 毫秒,串行 600 毫秒、并行 300 毫秒,差距确实不明显;但十个接口各 300 毫秒,串行就是三秒,并行仍然只有 300 毫秒,差距整整十倍。凡是页面里要同时拉取多个独立数据源的场景,并发收益会随着接口数量线性放大。

1.2 浏览器的连接复用与并发上限

在动手并发改造之前,还得先搞清楚底层传输层能扛多少并发。HTTP/1.1 时代,同一个域名下浏览器一般只允许六条左右并发连接,超出部分会排队。HTTP/2 支持多路复用,可以在同一条连接上同时传多个请求,并发能力大幅提升,但在实际网络环境和部分代理场景里依然存在连接数限制。也就是说,Promise.all 只是在前端代码层面把请求同时发出,真正跑多快还得看传输层允许多少并发。

不过在实践中,页面需要并发请求的接口数量通常是个位数到十来个,不会轻易撞上浏览器的连接墙。真正的瓶颈往往不在连接数,而在服务端接口的响应差异——只要有一个接口特别慢,Promise.all 就会卡在它身上。这个问题后面会专门讲超时兜底怎么处理。

还有一点很容易被忽略:Promise.all 本身并不减少网络传输的数据量,它不是压缩,也不是缓存,它改变的只是请求的发起时机和等待方式。很多人以为用了 Promise.all 接口响应就变快了,其实不是,单个接口的耗时基本不变,变的是你的页面不用再白等上一段请求的间隙。理解这个本质,后面无论是加缓存还是加预加载,都能找到自己该站的位置。

2. Promise.all 的并发本质与收口逻辑

2.1 Promise 创建即执行:那个被误解的"并发时机"

新手甚至不少写过一段时间的人,都会以为只有把 Promise 放进 Promise.all 数组里,请求才会开始。真实情况完全不是这样——Promise 构造函数里的执行函数在 Promise 创建那一刻就同步执行了,跟它是否被放进 Promise.all 没有关系。

这意味着Promise.all([fetch('/api/a'), fetch('/api/b')])和先写const p1 = fetch('/api/a'); const p2 = fetch('/api/b');再Promise.all([p1, p2])效果完全一样,两个 fetch 都是声明时已经发出。

这个理解直接决定你怎么组织代码。我经常在代码 review 里看到这种写法:

// 错误示范:Promise.all 里出现 await,实际还是在串行 const result = await Promise.all([ await fetch('/api/user'), await fetch('/api/list'), ]);

这里的await出现在数组元素内部,等于先把第一个 fetch 等完,再发起第二个 fetch,Promise.all 收到的已经是两个完成的 Promise。表面上用了 Promise.all,实际上依然是串行执行,优化意义完全消失。正确的写法是把 await 放在整个 Promise.all 外面:

// 正确写法:两个请求同时发起,一起等待 const [userRes, listRes] = await Promise.all([ fetch('/api/user'), fetch('/api/list'), ]); const user = await userRes.json(); const list = await listRes.json();

这个细节一定要在代码审查里盯紧。这种"假并发"的危害比完全不用 Promise.all 更大,因为它从语法上看很像已经优化过了,排查页面慢的问题时很容易被误导,反而绕远路。

2.2 结果聚合与快速失败:all 方法的两条硬规则

Promise.all 的收口逻辑可以拆成两层。第一层是聚合,它把数组里所有 Promise 的返回值按传入顺序组成一个新数组返回。注意顺序是固定的,和接口实际返回的先后无关。比如第一个接口 500 毫秒返回,第二个接口 200 毫秒返回,最终数组的第一个元素仍然是第一个接口的结果,不会因为返回晚而排到后面去。这一点在你需要按索引对应数据源时特别有用,省去了手动排序的功夫。

第二层是快速失败:只要数组里任何一个 Promise 变成 rejected,Promise.all 会立刻进入 rejected 状态,抛出第一个遇到的错误。哪怕其他请求后来成功了,你拿到的也只是报错。对某些业务来说这是合理的——比如三个数据一起渲染一个完整组件,缺了任何一个都没法展示,快速失败能帮你尽早暴露问题,而不是让用户面对一个残破的页面。但对另一种业务,比如用户信息接口挂了、推荐列表接口正常,二者本来互相独立,直接用 Promise.all 就会把原本能展示的模块也一起拖下水。

这两条规则决定了 Promise.all 不是万物通用,使用前必须问清楚:这批请求是"缺一不可"还是"互相独立、可以部分失败"?这也是下一节改造过程中首先要确认的事。

3. 一次典型页面的加载优化改造实录

3.1 改造前:六个接口串行 await 的真实耗时分布

回到开头那个管理后台。当时我在 Network 面板里看到的瀑布图就是典型的一字长蛇:每个请求都从前一个请求结束之后才开始,首尾相接,中间几乎没有重叠。这种情况下,页面顶部还有一个全局 loading 遮罩,在hideLoading()执行之前所有区域都被挡住,用户唯一能看到的就是转圈。串行等待的缺点被这个 UI 设计放到了最大。

改造前要做的第一件事,是确认六个接口之间不存在调用链或鉴权依赖。比如/api/user如果返回的是用户身份标识,而/api/roles必须拿这个标识去请求权限,那就不能并行——必须先等用户信息回来。我当时的六个接口并没有这种关系,纯粹是从不同维度拉取同一份页面数据,天然适合并发。

另外我特意确认了产品的渲染预期:这个页面是允许渐进式分区块展示,还是必须等全部数据到齐后一次性完整呈现?当时的交互设计选择了后者,所有区块同时出现,这样 Promise.all 统一收口的方案就跟产品预期完全对齐了。如果产品希望"先看到一部分,再看另一部分",那就不该硬套 Promise.all,而是应该走"先到先渲染"的渐进式方案。

3.2 改造后:并发发起、统一收口的实现

确认无误之后,改造本身很简单,核心就是把六个独立请求收进 Promise.all:

// 改造后:六个请求并发发起,耗时 = 最慢接口耗时 async function loadDashboard() { showLoading(); try { const [userRes, roleRes, logRes, statRes, msgRes, notifyRes] = await Promise.all([ request('/api/user'), request('/api/roles'), request('/api/logs'), request('/api/stats'), request('/api/messages'), request('/api/notifications'), ]); renderUser(userRes); renderRoles(roleRes); renderLogs(logRes); renderStats(statRes); renderMessages(msgRes); renderNotifications(notifyRes); } finally { hideLoading(); } }

真正有信息量的不是这两行改动,而是改动前后的对比。在同样的网络环境下,用性能工具连续测了两轮:改造前页面从发起到hideLoading平均耗时 2280 毫秒,改造后平均耗时 740 毫秒。页面等待时间省掉了约 68%,而代码改动量几乎没有——这就是并发模型带来的杠杆效应。

这种收益只在特定条件下最明显:接口数量多、单个接口不算特别慢、且需要同时拿到结果。如果本来串行总时长就一两百毫秒,再怎么优化天花板也有限,甚至没必要为了用并发而用并发。

3.3 同一套网络环境下的收益对比

把典型场景的对比整理一下,方便你判断自己的项目适不适合做同样的改造:

方案总耗时渲染时机风险
串行 await各接口耗时之和每个接口到达后逐块渲染首屏等待时间线性累积
Promise.all 并发最慢接口耗时全部数据到齐后统一渲染受最慢接口和单点失败影响
分层并发每层内取最大、层间累加每层完成后逐层渲染需要预先梳理依赖关系

表中的第三行"分层并发"是我在依赖场景里的延伸用法,下一节详细说。总之,看到页面加载慢,先别急着怪接口,打开瀑布图看一眼请求是不是串行排队。如果是,Promise.all 就是性价比最高的解药。

4. 分层并发与分批并发:真实业务里必须处理的两种变形

4.1 接口存在依赖时:先画依赖图,再分层并发

Promise.all 只适合处理无依赖的并发请求,但真实项目里接口依赖非常常见,比如先拿用户身份,再根据用户角色拿可见菜单,再根据菜单权限取具体数据。遇到这种情况,我的做法是先画出依赖关系图,把没有依赖的层内的请求放同一批并发,层与层之间串行衔接,每层内部依然用 Promise.all 并发。

具体而言,先发第一层没有依赖的请求,拿到结果后,再把结果作为参数传给第二层请求数组,再用一次 Promise.all 并发发第二层。不要试图把整条依赖链路的所有请求都塞进一个 Promise.all——那样既拿不到中间参数,又会让最慢的链路卡住全部请求。

举个例子,一个内容管理首页,需要先请求当前用户 ID,再并发请求该用户的文章列表、草稿箱、粉丝数据和通知设置。依赖图就是 user 接口在最前面,后面四个接口都只依赖 user ID,它们彼此之间没有依赖关系。改造后的代码可以这样组织:

// 第一层:先拿用户信息 const userRes = await request('/api/user'); // 第二层:依赖 user.id 的四个接口并发发起 const [articleRes, draftRes, fanRes, settingRes] = await Promise.all([ request(`/api/articles?uid=${userRes.id}`), request(`/api/drafts?uid=${userRes.id}`), request(`/api/fans?uid=${userRes.id}`), request(`/api/settings?uid=${userRes.id}`), ]);

这种"层内并发、层间串行"的结构,是异步接口编排里最常见也最实用的模式,比一股脑全并发安全得多。它对应的耗时模型是 T = L1 + max(L2-1, L2-2, ...) + L3 + ...,每一层内的并发收益都很直接。

4.2 请求量很大时:用分片并发保护浏览器和服务端

当"并发"的对象不是几个接口,而是几十上百个资源请求时,会遇到另一个完全不同的麻烦:前端一次性发出 100 个请求,服务端瞬时压力陡增是风险一,浏览器基于连接限制把请求压成长串队列是风险二。这类场景多半出现在批量操作里,比如批量更新商品状态、批量拉取详情、批量压缩上传图片。

处理思路很经典,把大数组切分成小批次,每批并发限制在一个安全数量,批与批之间串行递进。我用一个工具函数封装了这套逻辑:

// 分片并发工具:每批并发 limit 个任务,全部完成后进入下一批 async function chunkifyConcurrent(items, limit, task) { const results = []; for (let i = 0; i < items.length; i += limit) { const chunk = items.slice(i, i + limit); const chunkResults = await Promise.all(chunk.map((item) => task(item))); results.push(...chunkResults); } return results; } // 使用示例:每批只发 5 个请求 const allDetail = await chunkifyConcurrent( idList, 5, (id) => request(`/api/detail/${id}`) );

这个函数能保证整体速度明显优于全串行,又不会像一次性全并发那样给服务端造成瞬时冲击。limit 的取值需要实测,我一般从 5 开始调,观察服务端响应耗时的变化再上下浮动。有的内部系统能扛住 20 个并发,有的第三方接口明确要求 QPS 不能超过 5,那就老老实实把 limit 设成 3 甚至 2。

分片并发还有一个必须注意的点:每一片的 Promise.all 如果因为某一项失败而整体返回 rejected,会导致这一片之后的批都无法正常启动。所以这种批量场景通常要跟下一节提到的 allSettled 或错误捕获配合使用,否则一个坏数据就能让整个批量任务中断在中途。

5. 给 Promise.all 穿上防护:超时、部分失败与降级策略

5.1 allSettled:允许一部分请求先失败

Promise.all 的快速失败特性在某些场景下是个坑。比如页面上要加载运营位配置、公告列表和用户偏好设置,其中运营位接口挂掉了,Promise.all 直接 reject,公告列表和用户偏好也一起白等。可在业务上,运营位只是"锦上添花"的模块,接口挂了不应该影响公告正常展示。

这种情况下我推荐换成 Promise.allSettled。它和 all 的区别是:不关心成功还是失败,会等所有 Promise 都落定,然后把每个结果包装成{status: 'fulfilled', value}或{status: 'rejected', reason}返回。你在代码里可以根据 status 字段分别处理,也可以给每个模块设置独立的降级方案:

const results = await Promise.allSettled([ request('/api/banners'), request('/api/announcements'), request('/api/preferences'), ]); const banners = results[0].status === 'fulfilled' ? results[0].value : fallbackBanners; const announcements = results[1].status === 'fulfilled' ? results[1].value : fallbackAnnouncements; const preferences = results[2].status === 'fulfilled' ? results[2].value : defaultPreferences;

使用 allSettled 的代价是多写一些状态判断代码,不能像解构数组那样直接拿值。取舍标准就一句话:这批数据是不是"缺一不可"?如果是核心数据,缺一个页面就没法看,用 all 的快速失败反而更合适;如果只是局部增强模块,用 allSettled 更稳妥。

5.2 超时兜底:race 与 AbortController 的配合

Promise.all 还有一个让人头疼的地方:如果数组里某个请求一直 pending,比如后端处理线程卡住迟迟不返回,页面就会一直处于 loading 状态。接口超时本身不是并发问题,但一旦你做了并发优化,超时的影响面会更大——一个慢接口拖住所有接口的结果。

解决办法是给每个请求单独加超时控制,再用 AbortController 真正取消掉底层的网络请求,而不是仅仅"等超时后忽略结果"。很多人讲超时只提 Promise.race,但单独使用 race 有个瑕疵:竞赛输掉的一方虽然不再被 await 了,底层网络请求其实还在飞,既浪费带宽又可能引发后续状态错乱。正确做法是把超时和取消绑定在一起:

// 请求级超时:超过 8 秒主动 abort function requestWithTimeout(url, timeout = 8000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer)); } // 整体超时兜底:10 秒内没有全部完成就报错 const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('整体请求超时')), 10000) ); const data = await Promise.race([ Promise.all(urls.map((url) => requestWithTimeout(url))), timeoutPromise, ]);

请求级超时让每个 fetch 都有独立的 abort 能力,不会因为某一个接口卡死而让其他请求继续空转。整体超时再给 Promise.all 的收口加一个上限,不至于让页面 loading 无限期挂起。注意 abort 之后 fetch 会抛 AbortError,调用处要捕获这个错误类型,避免把主动取消当成接口异常上报到监控系统。

5.3 渲染收口策略:拿到数据之后别急着操作 DOM

回到"优化加载速度"本身,Promise.all 节省的是等待时间,但最终用户体验还取决于拿到数据后的渲染方式。我见过一个团队并发做得很好,请求全都同时发出,结果渲染时又出了问题——拿到第一个接口结果就立刻操作 DOM,接着拿第二个又操作一次,页面内容来回跳动,用户看完觉得自己开了个假页面。

如果你选用了 Promise.all 统一收口,那就让渲染也同步收口。先在所有数据到齐后构建完整的数据对象,再一次性渲染所有区块,这样既减少 DOM 操作次数,也避免中间态导致布局抖动。在使用前端框架的项目里,多次 setState 会触发多次 diff 和更新,统一收口后只触发一次,往往也更高效。

但反过来,如果页面内容确实可以渐进式展示,产品也没有设置全局 loading,那就没必要用 Promise.all 硬等全部数据。这种场景下并行请求的目的反而是"尽快拿到第一个数据",而不是"同时拿到所有数据"。所以做每次优化前,先问自己一个问题:用户在这个页面最关键的信息是什么?把这个信息对应的接口调到最优先,其他模块的数据并行拉取但不必阻塞首屏,这种精细化调优比笼统地"全部并发"更贴合真实体验。

6. 我在实际项目里养成的几个习惯

做了不少加载优化之后,我总结出几条自己的判断习惯,放在最后供你参考。

第一,先测量再做决定。拿到"页面慢"的反馈,别急着重构,先把 Network 面板的瀑布图截图看一眼。如果请求是错开排列的、前一个结束后隔了一段才开始下一个,那才有并发优化的意义;如果本来看上去就是重叠并发但页面依然慢,问题大概率在接口响应时长或渲染逻辑上,这时候用 Promise.all 也救不了。

第二,并发改动一定要配合 loading 状态的设计。无脑 Promise.all 的代价是等待全部结束后才统一展示,如果用户原本能在 300 毫秒内先看到第一个区域内容,而你的并发方案让他们必须等 900 毫秒才能看到全部内容,体验不一定算优化。真正的优化方向是"关键区先到先渲染,次要区并发等待",两者结合。

第三,别忘了和缓存一起用。Promise.all 优化的是"必须走网络"的部分,已经返回过的数据完全可以存进内存缓存或本地缓存层。我给项目的 request 函数包过一层 Map 缓存,相同参数的请求在短期内直接命中缓存,并发加上缓存,整体耗时还能进一步压低。

最后,每次代码里出现 Promise.all,我的习惯是旁边配一行注释,写明"为什么这些请求可以并发"。这不是形式主义,而是给后来维护的人留一个判断依据——如果哪天新增了一个依赖前一个接口的请求,误放进数组里,其他同事能凭注释快速发现异常。毕竟并发优化最大的隐性成本,是打破了代码原本"自上而下、一步一步"的直觉顺序,多点注释能把这份可读性补回来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询