
前两周公司后台管理系统的首页接口压力有点异常我顺着链路排查的时候发现问题根本不在后端而在前端一个很隐蔽的写法Vue3页面一打开同时触发十几个接口请求但代码是用await一个个串行等回来的等最后一个接口返回用户已经盯着空白页好几秒了。后来我把这一坨并发请求改成Promise.allSettled统一管理又基于 Vue3 的组合式 API 做了一层结果处理优化页面加载速度和容错能力完全不是一个档次。这篇就把我在实际项目里总结的这套Promise.allSettled并发请求结果处理方案完整分享出来适合正在写 Vue3 后台管理系统、聚合页、数据看板这类“一进来就要拉很多接口”场景的开发者参考。1. 一打开页面就要发十几个请求并发请求为什么不能写成串行 await1.1 业务场景还原聚合页的接口依赖关系很多 Vue3 后台管理项目里都有这种页面顶部是数据统计卡片中间是表格列表侧边是通知数量底部还有几个下拉框的可选数据。这些数据之间没有依赖关系谁先返回都不影响谁属于典型的并发请求场景。我当时的页面大概有十一个接口包括用户总数、订单总额、待办事项、最新公告、权限列表、字典数据等等。如果像下面这样写代码确实简单const userRes await getUserCount(); const orderRes await getOrderAmount(); const todoRes await getTodoList(); // 每个请求都阻塞等待总耗时 所有接口耗时之和这种写法的最大问题不是慢而是任何一个接口挂了后面所有接口都不会执行。比如用户接口超时 10 秒那订单、待办、公告全都得陪着等。而且页面必须等这十一个接口全部返回才能关闭 loading。实际体验就是首屏白屏时间被最慢的接口绑架。1.2 并发和串行的本质区别请求是发出的不是等出来的要理解并发请求先要分清两个概念发起请求和等待结果。// 这行代码执行时请求已经发出去了 const p1 getUserCount(); // 真正阻塞的是 await const res await p1;所以并发请求的正确打开方式是先一口气把请求全部发出去让浏览器或 HTTP 客户端同时维护这些连接然后再统一等待结果。这样总耗时约等于最慢那个接口的时间而不是所有接口时间相加。串行耗时: t1 t2 t3 ... t11 并发耗时: max(t1, t2, t3, ... , t11)这个差距在接口多的时候是指数级的。十一个接口每个平均 300ms串行要 3.3 秒并发最快只要 500ms 左右。1.3 哪些请求适合并发哪些不适合不是所有接口都适合一次性全发出去。我在项目里总结的判断标准有三个接口之间没有数据依赖A 接口的返回参数不需要作为 B 接口的入参。接口之间没有时序要求比如 token 刷新和业务请求同时发就可能出现 token 刷新还没完成业务请求先带着旧 token 跑了。接口没有互斥性比如从下拉框 A 切换到下拉框 B旧请求可能比新请求后返回导致页面数据被旧结果覆盖。聚合页的数据基本都满足前两条所以才值得用并发处理。而那种“先拿用户 ID再拿订单列表再拿订单详情”的链路式请求就必须串行 await不能硬并发。2. 为什么是 Promise.allSettled它和 Promise.all 的失败隔离差别在哪2.1 Promise.all 的失败放大问题很多开发者一提到多个请求并发第一反应就是Promise.all。但Promise.all有一个非常致命的特性只要其中一个 Promise 失败整个 Promise.all 立刻进入 rejected 状态其他还没返回的结果全部丢弃。放在聚合页场景里就是十个接口成功了一个接口超时了结果页面上十个成功数据全都用不上还得走 catch 分支统一提示“加载失败”。用户看到的是一片错误页面尽管 90% 的数据本来是好的。2.2 Promise.allSettled 的结果结构成败分开返回Promise.allSettled的行为不一样。它会等待所有请求全部结束不管成功还是失败然后把每个结果以固定结构返回// 成功项 { status: fulfilled, value: { code: 200, data: [...] } } // 失败项 { status: rejected, reason: Error(timeout) }每一个请求的结果彼此独立成功的不受失败影响失败项也不会干扰别人。这就叫失败隔离是聚合页最需要的特性。我用一个表格对比一下常见的三种并发方案方案等待所有结束失败是否影响其他适用场景Promise.all是但一个失败直接中断影响整体 rejected多个请求有强关联失败必须整体回滚Promise.allSettled是全部结束后返回不影响各自保留结果聚合页、看板、批量拉取失败只影响自己Promise.race否谁先完成用谁第一个失败则整体失败超时控制、竞速场景2.3 一个小坑allSettled 对 rejected 项不抛错但也不代表数据一定可用这里要提醒一句allSettled只保证 Promise 层面的失败被捕获不代表业务层面的失败都被处理了。很多接口返回格式是{ code: 500, message: 服务器错误 }HTTP 状态是 200Promise 状态是 fulfilled但业务上其实是失败的。所以后面第 4 章我会专门讲做完allSettled之后还需要对每个结果做一层业务状态校验才能真正判断这条数据能不能用。3. 在 Vue3 组合式 API 里落地并发请求的状态管理与结果映射3.1 用 script setup ref 管理多个请求的加载状态我在 Vue3 项目里用的是script setup组合式 API。先定义一个请求配置数组把每个接口的名称、请求函数、默认数据源都声明清楚后续所有逻辑都基于这个配置展开script setup import { ref, reactive, onMounted, onBeforeUnmount } from vue import { getUserCount, getOrderAmount, getTodoList, getNoticeList } from /api/dashboard const loading ref(false) const requestError ref() const resultMap reactive({ userCount: null, orderAmount: null, todoList: [], noticeList: [] }) const requestList [ { key: userCount, fetcher: () getUserCount() }, { key: orderAmount, fetcher: () getOrderAmount() }, { key: todoList, fetcher: () getTodoList() }, { key: noticeList, fetcher: () getNoticeList() } ] async function loadDashboard() { loading.value true requestError.value // 先把所有 fetcher 都执行一遍请求瞬间全部发出 const tasks requestList.map(item item.fetcher()) const results await Promise.allSettled(tasks) // 后续处理 results handleSettledResults(results) } onMounted(loadDashboard) /script这段代码的核心是requestList.map(...)它比直接写四行fetch的好处是请求配置和结果处理逻辑解耦了后续新增接口只需要在数组里加一项不用改动主流程。3.2 用索引和 key 把结果准确映射回页面数据Promise.allSettled返回的 results 数组和传入的 tasks 数组是一一对应的这个对应关系不会被异步时序打乱。所以处理结果时直接按索引取 key 就行function handleSettledResults(results) { results.forEach((result, index) { const { key, defaultData } requestList[index] if (result.status fulfilled) { // 注意业务层校验在后面对应位置处理的 resultMap[key] result.value.data ?? defaultData } else { // 失败时用默认值兜底不让模板抛错 resultMap[key] defaultData console.warn(请求 ${key} 失败:, result.reason) } }) }这里有个容易被忽略的细节forEach遍历的顺序和接口返回的顺序无关只和任务声明顺序有关。比如第三个接口明明先返回但因为 allSettled 要等所有任务结束所以最终 results 里的顺序依然是[任务0结果, 任务1结果, 任务2结果, ...]。这个顺序保证了我可以放心用requestList[index]反查 key。3.3 局部 loading 优化不要让一个失败请求挡住所有数据渲染如果只有一个全局loading布尔值那无论用不用 allSettled体验上都是“全部加载完才能展示”。为了体现并发优化的价值我通常把 loading 拆成两个维度首次加载 loading页面没有任何数据时的整页 loading。局部刷新 loading数据已有只刷新某个卡片时给对应区域一个独立 loading。具体做法是在 requestList 里给每项增加loadingKey并维护一个requestLoadingMapconst requestLoadingMap reactive({ userCount: false, orderAmount: false, todoList: false, noticeList: false }) async function loadDashboard(keys requestList.map(item item.key)) { const targets requestList.filter(item keys.includes(item.key)) targets.forEach(item { requestLoadingMap[item.key] true }) const tasks targets.map(item item.fetcher()) const results await Promise.allSettled(tasks) results.forEach((result, index) { const item targets[index] requestLoadingMap[item.key] false // ...结果处理 }) }这样用户点击“刷新订单”时只有订单卡片的 loading 在转其他卡片纹丝不动。这种细节在对用户体验要求高的管理后台里比所有数据一次性刷新要舒服得多。4. 结果整理的艺术业务状态校验、错误兜底与数据类型守卫4.1 fulfilled 并不等于成功补一层业务状态校验前面提过HTTP 200 Promise fulfilled 不代表业务真的成功。很多后端的返回结构是{ code: 200, data: [...], message: ok } { code: 400, data: null, message: 参数错误 }所以在handleSettledResults里我会再加一道业务状态判断。把校验函数单独抽出来方便复用function isBizSuccess(payload, successCodes [200]) { if (payload null) return false if (typeof payload ! object) return false return successCodes.includes(payload.code) }然后结果处理逻辑升级为function handleSettledResults(results) { let hasError false results.forEach((result, index) { const { key, defaultData } requestList[index] if (result.status fulfilled isBizSuccess(result.value)) { resultMap[key] result.value.data ?? defaultData } else { hasError true resultMap[key] defaultData const reason result.status rejected ? result.reason : result.value.message console.warn(接口 ${key} 异常:, reason) trackError(key, reason) } }) requestError.value hasError ? 部分数据加载失败已展示默认内容 : }这样处理后即使某个接口返回 code 400页面也只是把对应区域切成默认值并提示“部分数据加载失败”而不是整个页面崩溃。用户看到的是“四个卡片中三个正常一个兜底”比“整页白屏”要好得多。4.2 数据类型的确定性接口返回 null 时页面可能直接 explodeVue3 模板里如果访问了 null 的属性控制台会报错页面相应区域也会渲染失败。比如接口返回{ data: null }模板里写resultMap.userCount.total浏览器会抛Cannot read properties of null。所以我在 requestList 里强制要求每个接口配置defaultDataconst requestList [ { key: userCount, fetcher: () getUserCount(), defaultData: { total: 0, trend: 0, unit: 人 } }, { key: todoList, fetcher: () getTodoList(), defaultData: [] } ]这样无论请求失败还是成功但返回 null最终放进resultMap的都不会是 undefined 或 null模板里的结构始终是完整的。这个小小的习惯帮我少处理了很多线上报错。4.3 把结果处理封装成通用 hookuseConcurrentFetch如果项目里多个页面都要用并发请求我会把整套逻辑抽成一个组合式函数。封装前先定义好入参和返回值// useConcurrentFetch.js import { reactive, ref } from vue export function useConcurrentFetch(requestList) { const loading ref(false) const requestError ref() const resultMap reactive({}) const loadingMap reactive({}) requestList.forEach(item { resultMap[item.key] item.defaultData loadingMap[item.key] false }) async function run(keys) { const targets keys ? requestList.filter(item keys.includes(item.key)) : requestList targets.forEach(item { loadingMap[item.key] true }) loading.value targets.length 0 const results await Promise.allSettled(targets.map(item item.fetcher())) results.forEach((result, index) { const item targets[index] loadingMap[item.key] false if (result.status fulfilled isBizSuccess(result.value)) { resultMap[item.key] result.value.data ?? item.defaultData } else { resultMap[item.key] item.defaultData } }) loading.value requestList.some(item loadingMap[item.key]) } return { loading, loadingMap, resultMap, requestError, run } }页面调用就非常简单了const { loading, loadingMap, resultMap, requestError, run } useConcurrentFetch(requestList) onMounted(() run())这个 hook 我在两个项目里复用过了新增页面只需要替换 requestList 配置并发、容错、loading 全部自动处理。这也是“结果处理优化”里最值得做的一件事比每个页面复制粘贴一段并发逻辑要省太多维护成本。5. 进阶优化给请求分组限量防止接口被浏览器或网关拒掉5.1 十几路请求全发确实快但服务端未必扛得住前面一直强调并发的好处但并发不是越大越好。浏览器对同一个域名的并发连接数有限制一般是 6 个左右超出部分会排队。服务端网关也经常有每秒钟请求数限制短时间突增十几路请求可能直接触发限流返回 429。所以当请求数量超过十个时我会给并发请求做一个分批限量处理。核心思路是不要把 requestList 一次性全发出去而是固定同时最大并发数为 4 或 6每批结束再补下一批。这里我分享一个不带第三方库的简单实现async function runWithLimit(tasks, limit 4) { const results new Array(tasks.length) let index 0 async function worker() { while (index tasks.length) { const current index index 1 try { results[current] { status: fulfilled, value: await tasks[current]() } } catch (error) { results[current] { status: rejected, reason: error } } } } const workers Array.from({ length: Math.min(limit, tasks.length) }, () worker()) await Promise.all(workers) return results }这个实现是典型的“线程池”思路worker函数不断从任务队列里取任务执行取完为止。最终 results 的顺序依然和 tasks 传入顺序一致和前面 allSettled 的行为保持兼容。如果限制为 4那同一时刻最多 4 个请求在飞既不会压垮网关也不至于退回串行。5.2 缓存与轮询场景allSettled 结果处理还能怎么复用聚合页还有一种常见玩法定时的数据轮询。比如每隔 30 秒重新拉一次所有卡片数据。这时候我会把run方法和setInterval配合并注意三个细节上一次请求还没完成时不要发起下一次请求否则会造成结果乱序。组件卸载时清除定时器。请求取消逻辑用AbortController在组件卸载时取消未完成的请求。对于最后一点我在 requestList 的 fetcher 里提前预留了信号参数function createCancelableFetcher(fetcher, signal) { return () fetcher({ signal }) } const controller new AbortController() async function loadDashboard() { const signal controller.signal const tasks requestList.map(item createCancelableFetcher(item.fetcher, signal)) const results await Promise.allSettled(tasks) // ...处理逻辑 } onBeforeUnmount(() controller.abort())这样用户在页面切换、组件销毁之后未完成的请求不会再回写状态避免 Vue3 里常见的“组件已卸载但仍更新响应式数据”的警告也避免了无意义的流量浪费。5.3 按钮防重复点击把 loading 状态和并发请求绑定最后分享一个交互层面的优化。像“刷新全部”这种按钮如果用户连点三次就会发起三批并发请求。我的处理方式很简单按钮的 disabled 绑定到loading上loading 在请求结束时统一关闭。template el-button :loadingloading clickrun() 刷新全部 /el-button /templaterun()内部因为 allSettled 会等所有请求结束才把loading.value置为 false所以按钮在请求期间天然是禁用状态不需要额外引入锁变量。5.4 我认为最值得坚持的三个习惯我把这套方案在项目里跑了几个月踩过一些坑最后沉淀下来三条建议谁用谁知道第一请求配置要和业务页面分离。requestList 单独放一个文件里页面只关心调用run()接口新增、删除、参数变化都在配置文件里改不污染页面逻辑。第二所有结果统一走默认值兜底。不管接口成功还是失败、返回 null 还是数组最终渲染层拿到的数据结构必须稳定。这个守住了模板基本不会炸。第三并发限制宁可保守不可激进。对生产环境来说6 个并发已经是比较稳妥的选择盲目追求“全部同时发出”在用户量上来之后很容易吃一跤。我之前在部署环境里遇到过这样的情况本地测试十五个请求并发毫无压力线上用户一多就出现接口超时把并发限制改成 5 之后立刻稳定。这个教训说明并发优化要结合真实网络环境和后端压测结果来配置而不是只盯着浏览器控制台的 Network 面板。如果后续你们的聚合页也遇到这种“数据源多、失败容忍度高、展示要求精细”的场景不妨直接把Promise.allSettled 分批限流 结果映射这套组合用起来改动量不大收益却非常明显。