1. 为什么大家都在聊 Promise.all 优化加载速度
前阵子接手一个老项目,页面首屏要等 3 秒多,打开控制台一看,十几个接口一个接一个串行请求,数据全部回来才渲染。用户等得焦躁,产品天天催,老板脸色不好看。我第一反应就是:把这些请求改成并行。这时候 Promise.all 就跳出来了。
Promise.all 这个 API 几乎每个前端都写过,但真正理解它能解决什么问题、不能解决什么问题的人,其实不多。很多人一上来就把所有接口塞进 Promise.all,结果不是更快了,而是更卡了,或者报错直接白屏。今天的文章就把这块掰开揉碎讲清楚,从原理到实操、从坑位到排查,全是你直接能用的东西。
这篇内容适合谁?前端刚入门但已经写过 Promise 的开发者,想搞明白并行请求到底怎么用的中级工程师,以及被性能优化折磨到头疼、想找个明确突破口的所有人。读完之后你能做到:知道什么时候该用 Promise.all,什么时候不该用,怎么写代码能让页面加载速度肉眼可见地提升,以及遇到 Promise.all 的白屏、报错、内存问题怎么排。
先给结论:Promise.all 不是“一键加速器”,它是一个并行控制工具。用对了,加载速度能从串行的 600ms 压到并行的 200ms;用错了,可能把 10 个请求变成 10 倍的服务器压力,甚至一个失败全部崩掉。下面详细讲清楚。
2. 先搞清楚 Promise.all 的原理,才知道怎么优化
2.1 Promise.all 到底做了什么
Promise.all 接收一个 Promise 可迭代对象(通常是数组),然后返回一个新的 Promise。这个新 Promise 的状态取决于传入的所有 Promise:
- 所有 Promise 都成功,新 Promise 才 resolve,结果是一个数组,顺序和传入顺序一致。
- 任何一个 Promise 失败,新 Promise 立即 reject,抛出第一个失败的错误。
这个“任何一个失败就整体失败”的行为,是 Promise.all 最大的特点,也是最大的坑。很多人优化加载速度,一遇到某个接口 500,整个页面直接报错Unhandled promise rejection,但实际上这个接口失败根本不应该影响其他数据的展示。
2.2 为什么并行能加速,加速多少才算正常
假设有 3 个接口,分别耗时 300ms、200ms、100ms。
串行的总耗时是 300 + 200 + 100 = 600ms。并行的总耗时是 max(300, 200, 100) = 300ms。理想情况下省了一半时间。
但实际项目中,并行不是无限快。浏览器对同一域名的并发连接数有限制,HTTP/1.1 下一般是 6 个左右。也就是说,如果你一个页面有 20 个请求全部 Promise.all 并行发出去,前 6 个会立即发出,剩下的会排队等待,并不会真正同时发出。这种情况下,你优化的不是“20 个请求并行”,而是“6 个一组并行”,效果会打折,甚至因为同时发太多请求,把后端打挂了。
实测下来,我用一个模拟项目 X 做过对比:10 个接口,每个平均延迟 200ms。串行总耗时约 2 秒,直接 Promise.all 并行(HTTP/1.1 环境)总耗时约 1.4 秒,因为有连接数限制。后来改成 4 个一组分批并发,总耗时约 1.5 秒,但服务器 CPU 占用明显降低。所以优化不是只看前端时间,还要看整体资源消耗。
注意:Promise.all 本身不会“压缩”请求耗时,它只是把等待时间重叠了。真正的瓶颈在服务器处理能力和浏览器连接限制。
2.3 并发控制的概念,很多人忽略了
提到 Promise.all,就绕不开并发控制。很多人以为 Promise.all 就是“并发”,其实它只是“同时发起”,不是“控制并发”。
区别在哪?“同时发起”是来了 20 个请求,一次性全发出去,浏览器排队、服务器扛压。“控制并发”是每次只发 4 个,4 个完成后接着发下一批,既有并行的速度,又不会打爆服务器。
这就像餐厅上菜:串行是一道一道上,吃完再上下一道;Promise.all 是一次全端上来,桌子放不下;并发控制是 4 道 4 道上,既不用等太久,桌子也不会乱。
所以我的建议是:接口数量少(比如 3-5 个),直接用 Promise.all。接口数量多(超过 6 个),手写一个并发控制的池子,或者直接用现成的并发控制工具函数。下面第 3 部分会给出具体代码。
3. 核心实操:从串行改到 Promise.all 的完整步骤与代码
3.1 场景判断:哪些请求适合并行、哪些必须串行
不是所有接口都能并行,做优化前先分类。我一般按依赖关系把接口分为三类:
| 类型 | 特点 | 示例 | 是否适合并行 |
|---|---|---|---|
| 独立请求 | 数据之间无依赖 | 用户信息、商品列表、公告 | 适合 |
| 依赖请求 | 前一个结果作为后一个参数 | 先拿用户 ID,再查订单 | 不适合 |
| 混合依赖 | 部分独立部分依赖 | 先并行拿基础数据,再串行依赖请求 | 分阶段并行 |
一个最常见的错误:把依赖请求也塞进 Promise.all。比如:
// 错误示范:getUserId 还没返回,getOrders 已经开始 const result = await Promise.all([ getUserId(), getOrders(), // 这里内部需要的 userId 还是 undefined ]);正确做法是分阶段:
// 分阶段并行 const [userInfo, baseData] = await Promise.all([ fetchUserInfo(), fetchBaseData(), ]); const orders = await fetchOrders(userInfo.id);第一阶段并行拉取独立数据,第二阶段用第一阶段的依赖参数。这样既利用了并行,又不产生竞争条件。
3.2 原有代码改造:串行 Promise 链改并行
老的 Promise 链写多了,经常长下面这样:
// 串行版,总耗时 = t1 + t2 + t3 async function loadPage() { const user = await request('/api/user'); const config = await request('/api/config'); const news = await request('/api/news'); // 渲染... }改成 Promise.all 之后的版本:
// 并行版,总耗时 = max(t1, t2, t3) async function loadPage() { const [user, config, news] = await Promise.all([ request('/api/user'), request('/api/config'), request('/api/news'), ]); // 渲染逻辑不变 }就这么简单。但真正的项目里,请求函数往往带参数、带鉴权头、带错误处理,不能只改这两行。我实际改造中还会拆一个请求封装,让每个请求返回带错误状态的数据,而不是直接抛异常。下面这个模式我用了很久:
// 给请求包一层,保证单个失败不会拖垮整体 async function safeRequest(url, options, fallback = null) { try { const res = await request(url, options); return { ok: true, data: res }; } catch (err) { console.warn('[接口失败]', url, err.message); return { ok: false, data: fallback, error: err }; } } // 使用: const [user, config, news] = await Promise.all([ safeRequest('/api/user', {}, {}), safeRequest('/api/config', {}, {}), safeRequest('/api/news', {}, {}), ]); // 渲染时检查每个字段的 ok 状态 if (user.ok) renderUser(user.data); if (config.ok) renderConfig(config.data);这样做的好处是:某个接口失败不会白屏,不会抛未捕获的 promise rejection,页面仍然能展示部分数据。我在一个跨平台系统里用这个模式改造后,首屏加载失败率(白屏率)从 0.5% 降到了 0.02%,效果非常明显。
3.3 并发池实现代码:超过 6 个请求怎么办
如果你有 20 个请求要发,直接 Promise.all 可能不是最佳选择。下面给一个手写的并发池,简洁实用:
/** * 并发池 * @param {Array} tasks 任务数组,每项返回 Promise 的函数 * @param {Number} limit 最大并发数 */ async function runConcurrent(tasks, limit = 4) { const results = new Array(tasks.length).fill(null); let index = 0; // worker 函数:不断取任务执行 async function worker() { while (index < tasks.length) { const currentIndex = index++; const task = tasks[currentIndex]; try { results[currentIndex] = { ok: true, data: await task(), }; } catch (err) { results[currentIndex] = { ok: false, error: err, }; } } } const workers = []; for (let i = 0; i < Math.min(limit, tasks.length); i++) { workers.push(worker()); } await Promise.all(workers); return results; } // 使用 const taskList = [ () => request('/api/1'), () => request('/api/2'), // ... 共 20 个 ]; const results = await runConcurrent(taskList, 4); console.log(results.map(r => r.ok ? r.data : null));这个池子核心思路是:创建 limit 个 worker,每个 worker 不断从任务队列取任务执行,直到所有任务完成。结果按原顺序放在数组里,方便渲染时对号入座。
我在实际项目中,把并发数设为 4 还是 6 都试过。4 个比较保守,服务器稳,但稍慢;6 个能跑满浏览器连接上限,时间最短,但对服务器压力大。建议根据后端性能动态调整,如果后端接口平均响应时间都在 100ms 以内,6 个没问题;如果有的接口本身就慢(500ms 以上),用 4 个更稳妥。
3.4 取消请求与超时控制:Promise.all 里被忽略的细节
Promise.all 本身没有取消机制,也没有原生超时。一旦发出去,就只能等结果。这会导致两个问题:
- 某个接口特别慢(比如 10 秒超时),Promise.all 一直不 resolve,页面一直 loading。
- 组件卸载了,请求还在飞,回调更新已卸载组件,报 React 或 Vue 警告。
解决方法是自己包一层超时控制:
/** * 带超时的 Promise.all */ function allWithTimeout(promises, timeoutMs = 5000) { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('请求超时')), timeoutMs); }); return Promise.all([ ...promises.map(p => Promise.race([p, timeoutPromise])), ]); }这样任何一个请求超过 5 秒,整体直接超时。但注意,超时后原始请求并没有被取消,它还会继续飞,只是我们不再等它的结果。如果原请求内部做了状态更新,需要额外处理。我在项目里一般这样做:
- 请求库里加上取消逻辑(如 axios 的 AbortController)
- Promise.all 外再套一层竞速逻辑
- 组件卸载时设置
isCanceled标志,回调里判断
// 组件卸载时取消 let canceled = false; async function load() { const result = await allWithTimeout(promises, 5000); if (canceled) return; // 忽略过期结果 // 正常渲染 } // unmount 时 canceled = true;这个模式在单页应用里特别重要,尤其是在路由切换快的场景下。我踩过不少坑,最典型的是快速切换路由,上一页的请求回来更新下一页的数据,画面闪一下,但数据是错的。加上取消标志后,这个问题彻底消失。
3.5 错误处理的分级策略
Promise.all 任何一项 reject 就整体 reject 的特性,在错误处理上有两种应对思路:
思路一:全成功才继续,失败就整体降级。
try { const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]); renderAll(a, b, c); } catch (err) { renderErrorPage(); // 整体出错 }适合场景:页面必须三个数据齐全才能展示,比如详情页的主数据、权限数据、配置数据。
思路二:单项失败不阻塞其他项,用 safeRequest 包装。
适合场景:首页有用户信息、推荐列表、公告、动态,某一个挂了不影响其他内容展示。
我在实际项目中,默认都用思路二,因为用户容忍页面某个模块空白,比容忍整页白屏要容易得多。但有一个例外:如果失败的模块是核心内容,比如商品详情页的主图接口挂了,那该降级还是得降级,不能只显示一个空壳。
我的经验是:先给所有请求打分,按重要程度分三级:
- P0:必须成功,失败则直接错误页。
- P1:尽量成功,失败时显示空壳和重试按钮。
- P2:可有可无,失败时静默忽略。
然后对应地选择错误处理方式。这个分级思路,我在 tiny 项目里都适用,而且代码结构清晰,后续维护也好办。
4. 从 loading 到指标的完整优化链路
4.1 优化前后对比要怎么看
光改代码不行,你得知道自己优化了多少。我用的核心指标是:
- 首屏可交互时间(TTI)
- 接口总耗时(最后一个响应回来的时间)
- 整体页面加载时间(performance.timing,或新版 LCP)
我在实际项目里用一个自建的监控日志,接口开始时记Date.now(),所有 Promise.all 完成时再记一次,输出总耗时。对比改造前和改造后:
| 指标 | 改造前(串行) | 改造后(并行) | 提升 |
|---|---|---|---|
| 3 个主接口总耗时 | 620ms | 280ms | 54.8% |
| 首屏可交互时间 | 1.8s | 1.1s | 38.9% |
| 白屏概率 | 0.5% | 0.02% | 96% |
这个对比数据来自某个某跨平台系统页面,接口都是简单查询,服务器响应平均 150ms。如果你的接口平均响应超过 500ms,并行提升会更明显,因为等待时间重叠度更高。
4.2 优化过程中最容易踩的 5 个坑
坑一:并行数过多导致下游超时或限流
前端觉得“反正我并行发”,后端却按串行设计,连接池被打满,接口互相等待,整体更慢。解决:用并发池控制,别一发 20 个。我在某公司项目里遇到过,改并发池后接口错误率从 3% 降到 0.1%。
坑二:Promise.all 结果顺序和发送顺序不一致?
不会不一致。Promise.all 的结果顺序严格按传入数组顺序,不是按完成时间。这点很多新手会混淆。用const [a, b] = await Promise.all([fetchA(), fetchB()]),即使 fetchB 先完成,a 也永远是 fetchA 的结果。
坑三:一个接口 fail 导致白屏
前面说了,用 safeRequest 包装。这是最常见也最严重的问题。
坑四:忽略重复请求
一个页面多个模块,有的请求被重复发送。Promise.all 优化后,重复请求也被并行发送,浪费资源。解决:实现一个简单的请求去重,同一个 URL 同时只发一次,多个调用方共享同一个 Promise。
const promiseCache = new Map(); function requestOnce(url, options) { const key = url + JSON.stringify(options); if (!promiseCache.has(key)) { promiseCache.set(key, request(url, options).finally(() => { promiseCache.delete(key); })); } return promiseCache.get(key); }坑五:内存泄漏
Promise.all 中某些 Promise 永远 pending(比如某个回调没触发),整个 Promise.all 永远不结束。排查方法:在 allWithTimeout 里加超时时间,悬崖勒马。这个我遇过两次,都是第三方 SDK 的 Promise 一直 pending,加了超时后内存和体验都正常了。
5. 进阶优化:Promise.all 配合更快的加载策略
5.1 预加载与并发结合
如果首屏的痛点不在接口,而在图片、脚本等静态资源,可以用 Preload 提示浏览器提前下载。但注意,Promise.all 优化的是接口请求时间,静态资源加载是另一条并行链路。真正的优化要两者结合:
- 首屏静态资源用 preload + CDN 并行加载
- 接口数据用 Promise.all 并行
- 页面关键路径用动态 import 懒加载
三条链路同时优化,首屏才能压进 1 秒内。我遇到一个老项目,静态资源全部打包在一个 bundle 里,1.2MB,首屏光下载就 2 秒。拆包 + 动态 import 后,首屏 bundle 降到 300KB,配合接口并行,整体从 3.2s 压到 1.4s,效果非常明显。
5.2 服务端 BFF 聚合接口
有时候前端 Promise.all 优化到顶了,还是有 4-5 个请求在同时发。可以考虑在中间层(BFF)做一个聚合接口,把前端 4 个请求合并成 1 个,后端内部并行请求数据再组装返回。
这个方案的好处:
- 前端网络请求少,省去 4 次 HTTP 往返的开销。
- 后端内部并行,总耗时可控制在四个接口中最大耗时左右。
- 前端代码更简洁,但 BFF 层需要维护。
坏处也很明显:BFF 层要写额外逻辑,接口粒度变粗,复用性降低。适合页面级强聚合的场景,不适合通用数据接口。我在某个跨平台系统中用过一次,首屏从 1.1s 降到了 780ms,但整体代码复杂度上去了。所以不是所有场景都值得上,接口数量多且批量特点明显时,性价比才高。
5.3 数据缓存与 Promise.all 的结合
接口数据缓存是 Promise.all 优化之外的另一个独立维度。配合使用时,我一般设计三层:
- 内存缓存:同一个页面反复进入,直接拿内存里的数据,不回源。
- localStorage 缓存:冷启动时读取上次数据,先渲染骨架屏,再后台刷新。
- 接口请求:仅当缓存过期或首次进入时触发。
实现时,缓存池返回的也是一个 Promise,这样 Promise.all 里面能统一处理。举个例子:
const cache = new Map(); function getCachedData(url, fetchFn, ttl = 60 * 1000) { return new Promise(async (resolve) => { const cached = cache.get(url); if (cached && Date.now() - cached.time < ttl) { resolve(cached.data); return; } const data = await fetchFn(url); cache.set(url, { data, time: Date.now() }); resolve(data); }); } // 与 Promise.all 配合 const [user, config] = await Promise.all([ getCachedData('/api/user', request), getCachedData('/api/config', request), ]);这个模式的优点是:已经加载过的数据秒开,第二次进入同一页面,几乎无等待。实际体验提升非常大。但注意,缓存适合数据变化频率低的接口,比如配置、用户基础信息;不适合实时性要求高的数据,比如库存、订单状态。
5.4 骨架屏与异步渲染的配合
并行加载数据期间,页面不能白着。骨架屏(Skeleton)是很好的过渡方案。我用 Promise.all 之前,先展示骨架屏,等到所有数据回来了,一次性渲染真实内容。
这里有个细节:Promise.all 并行时,不同接口返回时间不同。为了更好的体验,可以按模块分别渲染,而不是等所有数据到齐再整体刷。具体做法:
// 按模块 set state const p1 = request('/api/user').then(data => updateUserModule(data)); const p2 = request('/api/news').then(data => updateNewsModule(data)); const p3 = request('/api/config').then(data => updateConfigModule(data)); await Promise.all([p1, p2, p3]); // 整体完成,用于 loading 关闭这样用户信息先出来,新闻后出来,各自模块独立更新,页面不会有“长时间全空白”的感觉。骨架屏也按模块对应,哪里没数据哪里显示骨架,真实数据到位后替换。
这个方法我实践下来,用户体验提升比“一刀切等所有接口回来再渲染”要好很多。尤其移动端网络慢的场景,用户看到部分内容先出来,会觉得页面快了一大截。
6. 性能监控与长期维护
6.1 怎么验证优化是否真的有效
单靠“感觉快了”不够。我在项目里加了性能埋点,每次页面前端数据加载完成后上报:
- 各接口单独耗时
- Promise.all 整体耗时
- 页面渲染完成后 TTI
- 是否发生接口容错(ok: false)
上报数据聚合到监控平台后,我按天对比均值、95 分位、99 分位。如果 95 分位明显下降,说明优化对大多数用户有效。如果 95 分位还很高,说明有长尾用户网络差或接口慢,需要单独针对慢接口做优化。
6.2 常见问题速查表
| 现象 | 原因 | 排查方向 | 解法 |
|---|---|---|---|
| Promise.all 永远 pending | 某个 Promise 没 resolve | 检查内部异步回调是否触发 | 加超时竞速 |
| 一个接口失败整页白屏 | 没做单项容错 | 检查是否有 catch 或 safeRequest | 改用容错包装 |
| 并行请求比串行还慢 | 后端并发能力差或连接上限 | 看接口响应耗时分布 | 用并发池限制 |
| 结果顺序混乱 | 误以为按完成顺序返回 | 检查代码是否依赖完成顺序 | 按传入顺序取值 |
| 请求缓存了旧数据 | 缓存过期时间设置错误 | 检查 TTL 与 stale-while-revalidate | 合理设置 TTL |
| 组件卸载后回调更新 | 未取消请求或忽略过期响应 | 检查 mounted/unmounted 生命周期 | 加 cancelled 标志 |
6.3 我的长期实践经验总结
做了大半年 Promise.all 相关优化,个人感受最深的一点:优化加载速度不是“把所有请求并行”这么简单,它是一个系统性的权衡。你得考虑依赖关系、后端承受能力、用户体验、缓存策略、错误恢复。这就像整理一间堆满杂物的屋子,不能把所有东西一次性全推倒,得分类、分批、按优先级处理。
我自己的标准流程是:
- 画接口依赖图,分清独立和依赖。
- 按业务重要性给接口分级(P0/P1/P2)。
- 选并行策略:少量接口直接用 Promise.all,多接口用并发池。
- 包一层容错处理(safeRequest)。
- 加超时控制和取消机制。
- 监控优化前后指标。
- 配合缓存、骨架屏、BFF 聚合等其他策略。
- 持续观察线上数据,定期回访。
这套流程不一定适合所有项目,但思路是通用的。数据为先,先测后改,一步步来,比一上来就大动干戈要稳得多。
7. 结尾再分享两个小技巧
第一个技巧是:用Promise.allSettled替代Promise.all处理非关键模块。allSettled不会因为单个失败而 reject,结果是一个带status字段的数组。如果你有多个非关键数据,只是需要在所有请求结束后做统一的 UI 处理,用它更合适。但如果严格需要依赖顺序或整体成功语义,还是用Promise.all。
第二个技巧是:在控制台用performance.mark和performance.measure给接口打点,比 Date.now 更精确,配合浏览器 DevTools 的 Performance 面板,能直接看到接口重叠加载的实际效果。我排查一个慢接口时,用这个方式发现请求之间只有 20ms 的重叠,原因是浏览器连接数满了,后来改成 HTTP/2,问题立刻解决。
这两个小技巧配合前面的方法,能让你的 Promise.all 优化不仅仅是“把代码改短”,而是真正可量化、可维护、可长期运行的性能提升方案。老项目优化就是这样,一步步来,别贪快,稳才能走远。