Vue3并发请求最佳实践:用Promise.allSettled替代Promise.all 做后台管理系统这几年我几乎每周都要写一遍并发请求。页面初始化要同时拉五六个下拉框、详情页要拆成好几个独立接口、批量操作要逐个看结果……最常见的写法就是Promise.all但真正让我开始研究Promise.allSettled的是一次线上事故下单明细页三个接口其中一个超时整个页面白屏了。后来我把这个页面的并发全换成了Promise.allSettled才明白这俩 API 表面差别不大背后对结果处理的思路完全不同。这篇文章就把我在 Vue3 里用Promise.allSettled做并发请求、以及把结果处理优化到能直接上生产环境的过程完整拆给你看。如果你正被“其中一个接口报错、整页跟着崩”折磨或者觉得allSettled返回的结构很难用可以往下看。1. 场景拆解并发请求里的那些“暗坑”1.1 为什么不用 Promise.all先说实话Promise.all不是不能用于并发请求它适合的场景是“缺一不可”。比如你有用户 id 和订单 id两个请求都成功才能渲染详情任何一个失败都不该展示页面这时候用all是对的。但绝大多数后台管理页面不是这个模型。用户管理列表页通常有搜索条件下拉、状态下拉、表格数据三个接口它们互不依赖等全部返回后各填各的区域。用all会让一个接口的超时拖死整个页面这就是体验事故的来源。我遇到最典型的一次配送区域列表页同时请求区域分组、物流公司和配送员三个接口某个第三方物流公司接口返回 502前端直接弹全屏错误。其实就算物流公司接口失败区域分组也能正常展示配送员列表也能正常展示。产品想要的是“局部错误、局部降级”而不是一损俱损。所以从那以后凡是不互相依赖的并发请求我默认不碰Promise.all只在必要的时候用。1.2 Vue3 项目里的典型痛点在 Vue3 项目里这个问题会被放大。原因在于 Composition API 让每个功能模块更独立了大家在setup里各自写请求却缺少一个统一的“并发结果收口”思路。更麻烦的是如果用老项目升级上来的代码mixin里的异步逻辑拆到组合式函数之后原先“一个请求一个请求串行”的写法还保留着性能反而更差。如果你在后台管理系统、uni-app、或者基于 Vue3 的 PC 客户端里都写过这种代码你大概率也遇到过只有导出接口挂了其他模块全部转圈明明是几个独立的表单校验接口一个慢就把校验按钮 disable 了。这些问题的本质都一样把多个互不相关的请求用了一个“整体成功”的容器去装。容器本身没问题选错容器才是问题。2. Promise.allSettled 核心原理返回值你真的读懂了吗2.1 allSettled 与 all 的差异在动手写优化之前先把 API 本身看透。Promise.all和Promise.allSettled的行为差异直接决定了后续代码怎么写。对比项Promise.allPromise.allSettled整体状态全部成功才变成 fulfilled所有结果无论成败都会 settle失败影响一个 reject整体 reject其他结果丢失失败不影响其他请求各自返回状态返回结果一组 value失败时只有 reason每一项都是 { status, value/reason }语义全体通过各记各账从语义上就能看出来all是“一票否决制”allSettled是“各回各家”。这决定了它们的结果处理方式完全不一样all拿到的是纯数据数组失败时直接进 catchallSettled拿到的是带状态的对象数组。后者看着繁琐却保留了每个请求的完整现场——哪个成功、哪个失败、错误是什么全都拿得到。还有一个细节容易被忽略allSettled对传入的非 Promise 值会先做Promise.resolve包装。所以就算你漏传了某个普通对象它也不会同步 throw而是作为一个 fulfilled 的结果混在里面。这一点在排查问题时很容易让人迷惑后面我会再提。2.2 返回值结构的隐藏含义allSettled返回的结构是许多新手最头疼的地方——它不是一组纯数据而是一个对象数组。每一项长这样// fulfilled 的结果 { status: fulfilled, value: 响应数据 } // rejected 的结果 { status: rejected, reason: Error 对象 }如果你直接写res.value在 rejected 那一项会得到undefined不会报错但如果你在 TypeScript 下编译器会明确告诉你value在rejected分支不存在。这就是结果处理的第一个坑拿值之前必须先看状态。另外有个容易被忽略的点结果顺序和传入顺序是一一对应的。这一点很重要它意味着你可以通过index或者预先约定的key把结果映射回对应的业务位。比如先传用户选项、再传部门选项返回的第一个结果是用户选项第二个是部门选项即使在并发执行期间谁先完成无所谓结果数组里的位置也是固定的。这为后面的“字段级更新”和“按 key 映射”提供了基础。3. 结果处理优化从“收到结果”到“用得好结果”3.1 结果归一化摆脱 status 地狱既然返回结构是“半成品”第一件事就是归一化。我的做法是不管业务细节先把所有结果统一成{ ok: true, data }或{ ok: false, error }这种语义明确的结构。function normalizeResults(results) { return results.map((res, index) { if (res.status fulfilled) { return { ok: true, data: res.value }; } return { ok: false, error: res.reason }; }); }归一化最大的价值不是少写几行 if而是让后续代码不再依赖 Promise 的status关键词。很多同学在组件里直接写res.status fulfilled一旦代码里出现十几处这种判断语义就乱了遇到status拼错、分支漏写的情况很难查。统一成ok字段后业务层只关心“这个请求成功了没有”不再关心 Promise 内部怎么表达。如果你嫌map之后还要拿着 index 去对应原始 key可以做得更彻底一点直接用一组带 key 的 task 和 reduce 一把梭const tasks [ { key: userOptions, promise: () fetchUserOptions() }, { key: deptOptions, promise: () fetchDeptOptions() }, { key: roleOptions, promise: () fetchRoleOptions() }, ]; const results await Promise.allSettled(tasks.map((task) task.promise())); const dataMap results.reduce((acc, res, index) { const key tasks[index].key; if (res.status fulfilled) { acc[key] { ok: true, data: res.value }; } else { acc[key] { ok: false, error: res.reason }; } return acc; }, {});这样拿到手的就是dataMap.userOptions.data、dataMap.roleOptions.error这种一眼就看懂的字段而不是一排results[2]。这一步是后续所有优化的地基。3.2 字段级更新让响应式状态各归各位在 Vue3 里拿到归一化结果之后最忌讳的做法是“一个 loading 管所有、一个 data 塞整包”。正确做法是按 key 单独管理每个接口的 loading、data、error。我一般这么初始化import { reactive } from vue; const state reactive({ userOptions: { loading: true, data: [] }, deptOptions: { loading: true, data: [] }, roleOptions: { loading: true, data: [] }, });为什么用reactive而不是ref因为ref.value整体重新赋值会直接替换引用如果你某个 computed 里引用了state.userOptions.data一旦这个字段还没来得及初始化就会报undefined。用reactive配合固定字段每个 key 独立更新不会出现“给整个对象赋值一块新数据结果某个派生状态瞬间拿不到旧值”的问题。接口返回后按 key 单独 setasync function loadData() { const dataMap await fetchAllSettled(tasks); // userOptions 成功 if (dataMap.userOptions.ok) { state.userOptions.data dataMap.userOptions.data; state.userOptions.loading false; } // deptOptions 失败只把 dept 标记为错误不影响旁边 if (!dataMap.deptOptions.ok) { state.deptOptions.error dataMap.deptOptions.error; state.deptOptions.loading false; } }模板里就完全不需要担心全局 loadingdiv v-if!state.deptOptions.loading state.deptOptions.error 部门加载失败a clickreloadDept重试/a /div div v-else-ifstate.deptOptions.loading Skeleton / /div一个接口失败不会影响另一个接口的展示。这就是allSettled对结果处理的核心价值——每个请求的结果被独立消费局部故障不会扩大为全局故障。3.3 封装 useAllSettledFetch 组合式函数把上面的逻辑收拢成一个组合式函数团队里可以直接复用。我经常用的是这个版本import { reactive, readonly } from vue; export function useAllSettledFetch() { const state reactive({ loading: false, dataMap: {}, errorMap: {}, }); async function run(tasks) { state.loading true; state.errorMap {}; const results await Promise.allSettled( tasks.map((task) task.promise()) ); results.forEach((res, index) { const key tasks[index].key; if (res.status fulfilled) { state.dataMap[key] res.value; } else { state.errorMap[key] res.reason; } }); state.loading false; return state.dataMap; } return { state: readonly(state), run, }; }使用的时候传一组 key 和 promise 工厂函数const { state, run } useAllSettledFetch(); async function loadPageData() { await run([ { key: userOptions, promise: () fetchUserOptions() }, { key: deptOptions, promise: () fetchDeptOptions() }, { key: roleOptions, promise: () fetchRoleOptions() }, ]); }注意这里传的是() fetchUserOptions()而不是fetchUserOptions()。promise 工厂比直接传 promise 对象好原因有两个一个是工厂函数在run内部才真正发起请求避免“还没开始用就先发出去”的状态错乱另一个是后面做失败重试时需要新建 promise传工厂函数才能做到“重试时重新请求”。传 promise 对象的话就算重试也是拿同一个已经 settle 的 promise等于白重试。这套组合式函数在 Vue3 里比 Vue2 的mixin干净太多。mixin 的命名冲突、数据来源不明问题组合式函数全部规避了。老项目从 Vue2 转 Vue3 的时候如果把并发请求逻辑从 mixin 拆成这种 hooks那个“局部失败拖垮整页”的 bug 会顺带被消灭掉。4. 进阶实操重试、并发控制与请求取消4.1 失败重试重试不是“多请求一次”那么简单并发请求里如果发现部分失败首先要判断该不该重试。我的判断标准就一条这个请求是幂等的吗查询操作基本可以重试提交操作不要轻易重试否则重复下单、重复提交会出大问题。确定了可以重试还有一个细节不能直接对同一个 promise 再执行一次。因为同一个 promise 已经 settle 了你再怎么调用它都是同一个结果。正确做法是把请求封装成函数每次重试都重新调用一次function sleep(ms) { return new Promise((resolve) setTimeout(resolve, ms)); } async function withRetry(fn, retries 2) { for (let i 0; i retries; i) { try { return await fn(); } catch (err) { if (i retries) throw err; // 指数退避 随机抖动 const delay 300 * Math.pow(2, i) Math.random() * 100; await sleep(delay); } } }为什么要退避 抖动如果你并发请求了 10 个接口其中 5 个失败失败后大家都等 300 毫秒再重试那第 300 毫秒瞬间会有 5 个请求同时打回服务端这就是经典的“惊群效应”。加上一点随机抖动能让重试请求分散开来服务端不会在某一毫秒被集中请求。实际使用时可以这样和allSettled结合const results await Promise.allSettled( tasks.map((task) withRetry(task.promise, 1)) );失败的任务在重试后如果还是失败会以 rejected 的状态留在allSettled结果里这时候再进错误分支就行。重试次数不建议超过 2 次。我见过把重试次数设成 5 的代码一个明显不存在的接口地址被连续轰炸了 6 次日志刷屏不说还会占用大量网络连接。4.2 并发控制别让浏览器网络队列崩溃Promise.allSettled看起来解决了“局部失败”问题但你一次性传入 100 个请求的时候它会在同一瞬间把所有请求全部发出去。浏览器对同一域名的并发连接数有限制大约 6 到 8 个超过的请求会进入排队。如果其中某个接口特别慢排队队列会越来越长页面表现就是转圈、卡死、点哪里都没反应。我是在用压测工具并发跑十个参数不同的 POST 请求时发现这个现象的同一时间发出 10 个请求TCP 连接全被占满后面的请求一直在等待整个页面的网络请求全部堵住。从那以后我只要并发任务超过 8 个就会上 worker 池限流。worker 池的实现不算复杂核心是维护一个固定数量的 worker每个 worker 不断从任务队列里取任务执行async function runWithConcurrency(tasks, limit 4) { const results new Array(tasks.length); let current 0; async function worker() { while (current tasks.length) { const index current; try { const value await tasks[index](); results[index] { status: fulfilled, value }; } catch (err) { results[index] { status: rejected, reason: err }; } } } const workers Array.from( { length: Math.min(limit, tasks.length) }, () worker() ); await Promise.all(workers); return results; }这个函数返回的结构和Promise.allSettled完全一致所以可以直接复用前面写的normalizeResults。并发数我一般取 4 到 6这是实践下来比较稳的值。更低的话任务完成太慢更高的话页面网络调度会肉眼可见地卡顿。要记住并发数是“相对”的还要看每个请求的耗时和服务器承受能力本地自测的结果不能直接套到线上。这种模式在 Electron / CEF 环境里尤其有用。渲染进程的网络资源更受限一个页面同时发出几十个请求整个窗体都会卡住。如果你在 CefSharp 里嵌了 Vue3 页面给所有并发请求加一个 limit 是上线前必做的功课。4.3 请求取消组件卸载了就别再 setStatePromise.allSettled只负责“等到所有结果”并不知道组件是否还活着。如果用户已经离开页面请求这时候才返回再去修改响应式数据就是浪费甚至在控制台会看到“component is unmounted but still updating”之类的警告。Vue3 里可以用AbortController在组件卸载时中断请求import { onBeforeUnmount, ref } from vue; const controller new AbortController(); async function fetchData() { const res await fetch(/api/xxx, { signal: controller.signal, }); // ... } onBeforeUnmount(() { controller.abort(); });fetch 和 axios 都支持 signalaxios 0.22 以上也可以直接传。要注意abort之后对应 promise 会变成 rejectedallSettled里会把它记成 rejected这是正常流程不是错误。我们的处理方式是在组合式函数内部维护一个isUnmounted标志run里在写state之前先判断一下let isUnmounted false; onBeforeUnmount(() { isUnmounted true; controller?.abort(); }); // 在 run 内部写 state 之前 if (!isUnmounted) { state.dataMap[key] res.value; }这样组件卸载后即使请求返回也不会再触发响应式更新内存和性能都不会被浪费。如果用的是 axios 的CancelToken旧写法要注意取消后抛出的错误需要自己处理不然allSettled会把每个被取消的请求都标记成 rejected虽然不影响整体逻辑但错误日志看起来会很奇怪。5. 常见问题与排查技巧实录5.1 问题速查表下面这些坑要么是我自己踩过要么是帮同事排查过列成表方便你快速对照现象常见原因解决思路一个接口超时整页白屏用了 Promise.all换 allSettled结果归一化res.value 是 undefined没判断 rejected 分支先用 status 分支统一 ok 字段并发请求一多页面网络全卡住浏览器并发连接被占满worker 池限流建议 4-6 并发组件卸载后控制台报警告没有取消请求AbortController isUnmounted 标志重试后接口被重复请求多次重试了同一个 promise传 promise 工厂每次重新发起失败瞬间集体重试把服务打崩没有退避和抖动指数退避 随机延迟axios 的 value 拿不到业务数据拦截器没统一返回 response.data在拦截器里统一 transform 后再进业务层5.2 几条血泪经验第一个经验不要在forEach里直接写异步调用。有人把请求放到forEach里然后Promise.allSettled包一个空数组结果返回结果永远为空因为forEach不等待 async 回调完成。正确做法是先用map把每个任务的 promise 工厂变成一个 promise 数组再传给allSettled。第二个经验axios 拦截器的配置会直接影响allSettled里 value 的形态。如果拦截器里已经统一返回了response.data那allSettled里的 value 就是业务数据如果没包装value 是 AxiosResponse 对象需要res.value.data才能拿到业务数据。同一套代码在不同项目里表现不同常见原因就是拦截器差异排查的时候先看这一层。第三个经验Promise.allSettled对传入的非 Promise 值会先做Promise.resolve包装。如果你不小心传了一个普通对象进来它会作为 fulfilled 混在结果里光看返回结果很难发现。排查时如果发现某个“请求”一秒不到就成功了先怀疑是不是传错了参数。第四个经验上完并发控制后一定要在真实网络环境压测而不是只在本地网络自测。本地网络响应快即使并发 20 个也没感觉线上一个接口慢 2 秒连接池立马不够用。我习惯是把并发数调到一个“看起来不够快但足够稳”的值然后在压测工具里跑几轮多个并发不同参数的请求观察整体吞吐和失败率再定最终值。我自己的习惯是把useAllSettledFetch和runWithConcurrency沉淀到团队公共库里每次新项目直接用不重复造轮子。如果你正被并发请求的局部失败问题困扰明天就可以把第一处Promise.all换成allSettled试一下。它不一定是所有场景的最优解但在“互不依赖、允许局部失败”的业务模型里确实能少惹很多麻烦。