拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Promise.all与Promise.race实战:异步并发控制与超时处理全解析

不知道怎么的这两个月后台系统做多了每天都要处理一堆异步并发的问题。昨天还在改一个批量拉取用户信息的接口前端一次性要拿到三百个用户的基础资料后端没提供批量接口只能前端拿 id 列表自己并发请求。最开始偷懒直接 for 循环 一个个串行请求页面卡了七八秒被产品经理盯上后改用 Promise.all 并发结果又踩了“一个请求 404 全部请求跟着失败”的坑。折腾下来发现很多人包括我自己对 Promise.all 和 Promise.race 的理解其实一直停在“能并发”这个表层真正遇到边界情况就容易翻车。这篇文章就围绕 Promise.all 和 Promise.race 这两个静态方法把底层行为、参数细节、失败机制、经典应用场景和常见坑一次性说透。不整那些纯理论的大段概念直接按我实际踩坑、排查、优化过程中的思路来讲。特别适合写了几个月 Promise 但还没系统梳理过这几个静态方法的同学也适合想用 race 做超时控制、用 all 做并发聚合却被各种边界问题卡住的人。看完你能直接照着里面的代码去改自己项目。1. 先把 Promise 的基础机制讲明白1.1 为什么前端异步离不开这三个状态理解 Promise.all 和 Promise.race 之前必须先对 Promise 本身的运行机制有一个非常清晰的认识否则后面讲“为什么 all 这么设计”“为什么 race 会丢请求结果”都会觉得别扭。Promise 本质上是一个状态机它内部维护了三种状态pending进行中、fulfilled已成功、rejected已失败。状态一旦从 pending 变成 fulfilled 或 rejected就不可逆也就是说一个 Promise 不可能同时是成功又是失败也不可能从失败再变回成功。这个状态机模型最核心的特点是一个 Promise 只能被决议一次。回忆一下用 new Promise 封装异步任务时我们调 resolve 或 reject谁先调用谁生效后面再调就无效了这就是状态不可逆在实际代码中的表现。const p new Promise((resolve, reject) { setTimeout(() resolve(第一次决议), 1000) setTimeout(() reject(第二次决议无效), 2000) })上面这段代码输出永远是第一次决议第二个 setTimeout 里的 reject 虽然执行了但它对这个 Promise 没有作用。理解了“决议一次”这个规则你就会发现 Promise.all 和 Promise.race 的处理思路其实很自然它们都是监听一组 Promise等这些 Promise 的状态发生变化。区别只在于 all 关注的是“整组最终结果”race 关注的是“第一个变化的结果”。1.2 静态方法与实例方法的区别很多初学者分不清promise.then()和Promise.all()的区别。then()是挂载在 Promise 实例上的方法也就是说它操作的是“单个 Promise 实例”而Promise.all()和Promise.race()是 Promise 构造函数上的静态方法它们接收的是“一组 Promise”准确说是一个可迭代对象返回的是一个新 Promise。// Promise 实例方法 const p new Promise((resolve) resolve(ok)) p.then((value) console.log(value)) // Promise 构造函数静态方法 Promise.all([p1, p2, p3]).then((values) console.log(values)) Promise.race([p1, p2, p3]).then((value) console.log(value))这个区别看起来很简单但实际编码时经常看到有人混用。比如在类组件或工具函数里把Promise.all直接当作实例方法调用、忘了加Promise前缀这种低级错误在 code review 里我见过不少次。核心记忆点就一句话all和race是构造函数层面的静态方法接收一组 Promise返回一个新 Promise。弄清楚这两个基础点下面就可以进入正题逐个分析 Promise.all 和 Promise.race 的用法细节。2. Promise.all 的真实行为和适用场景2.1 基本语法与返回值结构Promise.all 接收一个可迭代对象最常见的就是数组。它会等数组里所有 Promise 都变成 fulfilled 之后把这些 Promise 的决议值按顺序组成一个新数组传递出去。注意这个顺序不是按完成先后排序的而是按传入顺序。就算第二个请求先返回它也只排在下标 1 的位置上。const requestA new Promise((resolve) setTimeout(() resolve(A数据), 300)) const requestB new Promise((resolve) setTimeout(() resolve(B数据), 100)) Promise.all([requestA, requestB]).then((results) { console.log(results) // [A数据, B数据]顺序与传入顺序一致不是按完成时间 })实操里 results 保留顺序对开发很友好。比如你要用三个接口的数据拼接一个列表接口返回快慢不一样但你按索引 0、1、2 取数据时完全不用担心顺序混乱。再看不传数组、传字符串或其他可迭代对象的情况。Promise.all 内部会用 Promise.resolve 把非 Promise 元素包装成已决议的 Promise。所以下面这种写法也是合法的Promise.all([1, 字符串, Promise.resolve(promise结果)]).then((values) { console.log(values) // [1, 字符串, promise结果] })严谨地说Promise.all 在 ECMAScript 规范里是Promise.all(iterable)不只是数组Set、Map、Generator 返回的迭代器都可以。但实战里 99% 的场景传的是数组知道它支持可迭代对象就够了不用刻意去用 Set 当参数。2.2 失败快速返回机制一个 reject 毁所有Promise.all 最核心的规则是只要传入的 Promise 中有一个变成 rejectedPromise.all 返回的新 Promise 会立刻变成 rejected完全不管剩下那些还没有完成的任务。这个机制常被称为 fail-fast快速失败。const normal new Promise((resolve) setTimeout(() resolve(正常任务完成), 1000)) const failed new Promise((resolve, reject) setTimeout(() reject(new Error(某些任务失败)), 300)) Promise.all([normal, failed]) .then((values) console.log(全部完成, values)) .catch((error) console.log(触发失败回调, error.message))这段代码 300ms 时就进入 catch尽管 normal 会在 1000ms 后才完成。注意normal 本身并没有被取消它还在继续跑只是它的结果对 Promise.all 来说已经不重要了。这个机制对请求失败的处理尤其关键。如果你用 Promise.all 并发请求五个接口其中一个返回 404整个 Promise.all 直接走失败分支其他四个就算成功后数据也被丢弃你在 .then 里拿不到任何结果。针对这个缺陷后面会讲怎么用 Promise.allSettled 兜底或者退一步用“分组并发”的方式降低影响面。2.3 空数组与空迭代器的边界行为如果 Promise.all 传入一个空数组它会立即返回一个已成功fulfilled的 Promise并且结果数组是空数组。Promise.all([]).then((values) { console.log(values) // [] })这个行为初看反直觉但你想象一下如果数组中一个任务都没有那“所有任务都成功”这个条件是自然满足的所以是直接 resolve 空数组。实际开发中如果列表数据是从接口动态获取的某个边界情况下列表是空的你会把这个空数组传给 Promise.all它不会报错而是直接走 then 分支代码逻辑上反而更顺畅。类似地如果传null或undefined这种不是可迭代对象的东西Promise.all 会直接抛 TypeError。所以封装工具函数时记得先做参数校验function safePromiseAll(promises) { if (!Array.isArray(promises) || promises.length 0) { return Promise.resolve([]) } return Promise.all(promises) }2.4 典型场景一多个无依赖接口的并行请求最常见的应用场景是一个页面初始化时同时需要用户信息、菜单权限、系统配置三个接口的数据彼此之间互不依赖。用 Promise.all 并发请求比串行请求三个接口平均能节省 2/3 的等待时间。async function initPage() { const [userInfo, menuList, systemConfig] await Promise.all([ fetch(/api/user/info).then(res res.json()), fetch(/api/menu/list).then(res res.json()), fetch(/api/system/config).then(res res.json()) ]) renderUser(userInfo) renderMenu(menuList) applyConfig(systemConfig) }用async/await配合 Promise.all在语义上非常清晰同时发起三个请求等待三个都成功后才继续。这里有个值得注意的细节fetch 返回的 Promise 一旦 reject比如网络断掉整个 Promise.all 会走 catch。如果需要在部分失败时仍有降级策略就要考虑 allSettled 或者对单个请求做 catch 兜底。2.5 典型场景二多资源预加载与批量写入除了接口请求Promise.all 还经常用于“多个资源的并行预加载”。比如 H5 详情页里有五张大图可以先创建 Image 对象并加载同时用 Promise.all 等所有图片加载完成后再展示页面避免图片一张一张蹦出来影响体验。function preloadImages(urls) { const tasks urls.map((url) { return new Promise((resolve, reject) { const img new Image() img.onload () resolve(url) img.onerror () reject(new Error(图片加载失败: ${url})) img.src url }) }) return Promise.all(tasks) } preloadImages([imgUrl1, imgUrl2, imgUrl3]) .then(() console.log(所有图片准备完毕)) .catch((error) console.error(某张图片挂掉了, error.message))批量写入也是类似思路比如前端把多条 localStorage 记录或 IndexedDB 记录并行写入最后统一确认。虽然写入操作一般不慢但把“全部完成”这个事件交给 Promise.all 管理代码组织上会简洁很多不需要自己维护计数器。3. Promise.allSettled失败不放弃的升级方案3.1 它跟 all 的本质差别Promise.allSettled 是后来新增的静态方法让我先说结论如果你不希望“一个失败导致全部结果丢失”用 allSettled 替代 all。allSettled 会等待所有 Promise 都进入 settled 状态不管成功还是失败然后返回一个数组数组里每一项是一个结果对象。每个结果对象有status字段值是fulfilled或rejected。成功时有value字段失败时有reason字段。const success Promise.resolve(成功数据) const failure Promise.reject(new Error(失败原因)) Promise.allSettled([success, failure]).then((results) { console.log(results) // [ // { status: fulfilled, value: 成功数据 }, // { status: rejected, reason: Error(失败原因) } // ] })它和 Promise.all 最大的不同all 是“一旦一个失败全部结果都不要”而 allSettled 是“无论成败给我所有结果”。实际业务中比如同时调十个上游接口你只关心哪些成功、哪些失败并不希望一个失败就把整个流程中断那 allSettled 就是更合理的选择。3.2 用 allSettled 改造批量数据上报举一个我实际遇到的场景做前端埋点上报一次要上报几十条日志到后端。后端接口是一批接受的但偶发某几条日志格式有问题导致后端返回验证失败。如果用 Promise.all 把这几十个请求聚合会因为其中一两个失败把所有日志都标记为上报失败还要走重试白白增加流量和耗时。改成 allSettled 后每个请求的结果都会被保留。代码里只需要过滤出status rejected的项单独把失败项集中进入降级队列后续可以按策略重试或丢弃。async function reportLogs(logs) { const tasks logs.map((log) fetch(/api/log/report, { method: POST, body: JSON.stringify(log) })) const results await Promise.allSettled(tasks) const failedLogs [] results.forEach((result, index) { if (result.status rejected) { failedLogs.push(logs[index]) } }) // failedLogs 单独走重试逻辑 if (failedLogs.length 0) { queueRetry(failedLogs) } return { total: logs.length, failed: failedLogs.length } }这里值得一提的小技巧是因为 allSettled 返回的数组顺序与传入顺序一致所以可以借助索引把失败项对应回原始日志数据不用额外在 Promise 外再包一层对象。这个细节在写业务代码时经常遇到很多人会额外组织数据结构其实利用好“索引对应”规则可以少写不少代码。3.3 all 和 allSettled 的选型经验项目里遇到一个组 Promise我一般会按以下逻辑判断这几个任务缺一不可任何一个失败整个流程都没有意义用Promise.all任务之间相对独立某一个失败不影响其他任务结果的使用用Promise.allSettled只想尽快拿到第一个成功的结果用Promise.any只想尽快拿到第一个结果不管成功失败用Promise.race具体到代码场景页面首屏必须拿到基础配置才能渲染用 all批量通知下发、埋点上报用 allSettled多个 CDN 地址同时请求谁先成功用谁的用 any请求超时限制用 race。4. Promise.race 的运行机制与实战套路4.1 第一个 settled 的胜出不区分成功失败Promise.race 和 Promise.all 思路完全相反。它会接收一组 Promise然后返回一个新 Promise。这组 Promise 里只要有一个先进入 settled 状态无论 fulfilled 还是 rejectedrace 返回的新 Promise 就会复刻该 Promise 的结果。const fastSuccess new Promise((resolve) setTimeout(() resolve(快成功), 200)) const slowFailure new Promise((resolve, reject) setTimeout(() reject(new Error(慢失败)), 1000)) Promise.race([fastSuccess, slowFailure]) .then((value) console.log(赢家是成功, value)) .catch((error) console.log(赢家是失败, error.message))上面只会打印赢家是成功 快成功因为 fastSuccess 在 200ms 先成功race 直接把这个成功结果作为自己的结果另外那个 slowFailure 后面怎么失败都没关系。race 的名字很直观但它和实际赛跑有一个不准确的地方它不保证“第一个完成的请求是最快结束的”而是“第一个从 pending 切换到 fulfilled 或 rejected 的”。简单理解就是状态变化最快的那个 Promise不是任务开始最早的。4.2 用 race 实现请求超时控制Promise.race 最经典的实战应用是超时控制。比如一个接口可能因为网络原因迟迟不返回你不能让用户一直等着这时候就可以让真正的请求和“一个不会 resolve 的定时器 Promise”赛跑function withTimeout(requestPromise, timeoutMs 5000) { let timer null const timeoutPromise new Promise((resolve, reject) { timer setTimeout(() { reject(new Error(请求超时超过 ${timeoutMs}ms)) }, timeoutMs) }) return Promise.race([requestPromise, timeoutPromise]).finally(() { clearTimeout(timer) }) } // 使用 const request fetch(/api/order/detail).then(res res.json()) withTimeout(request, 3000) .then((data) console.log(请求正常返回, data)) .catch((error) console.log(请求失败或超时, error.message))这里有个细节我刚开始总是漏掉如果不用finally(() clearTimeout(timer))一旦请求本身先返回成功那个定时器还会继续走到点后依然会 reject。虽然 reject 一个已经没有影响的 Promise 不会直接报错但它会导致一个遗留的定时器留在事件循环里。如果这种请求数量多定时器一直残留会白白占用系统资源严重时还会有无意义的 unhandledrejection 警告。加一个finally清理定时器是习惯问题不算复杂但能避免不少隐患。用 race 处理超时的人两行代码能解决的事情。4.3 用 race 做多通道竞速除了超时控制race 还可以用在优雅降级场景。比如同时向主接口和备用接口发起请求谁先返回就用谁的数据这样在主服务变慢时用户体验不会明显变差。function fetchFromPrimary() { return fetch(/api/main/data).then(res res.json()) } function fetchFromBackup() { return fetch(/api/backup/data).then(res res.json()) } Promise.race([fetchFromPrimary(), fetchFromBackup()]) .then((data) console.log(最快返回的数据, data)) .catch((error) console.log(两个都失败了, error.message))多通道竞速在移动端弱网环境下特别好使。比如主域名 CDN 挂了备用域名还能正常服务那就同时请求两个域名race 拿第一个结果。不过要注意race 只看“第一个 settled 的 Promise”如果主接口先返回了一个失败结果那 race 就会走失败分支虽然备用接口可能后续会成功但你拿不到了。所以实际场景里通常会对主通道的失败做一次预过滤否则 race 更像“谁先失败听谁的”而不是“谁先成功听谁的”。我自己写这类逻辑时一般会在竞速之前先给主接口包一层 catch把失败转换成“永不结束的 Promise”确保只有成功的备用通道能最终胜出。这种技巧代码量不大但很多人会忽略。4.4 竞速失败后另一个 Promise 去哪了新手经常会问一个问题race 完成后另一个还没完成的 Promise 是不是被取消了答案是没有。race 内部并不会取消或中断那些未完成的 Promise。它们还会照常在后台运行只是它们的结果没有人监听了。如果这个 Promise 最后是 rejected还可能触发 unhandledrejection 警告。Promise.race([ new Promise((resolve) setTimeout(() resolve(A), 100)), new Promise((resolve, reject) setTimeout(() reject(new Error(B失败)), 1000)) ]) // A 在 100ms 胜出但 B 在 1000ms 时会变成 rejected且没有被任何 catch 捕获 // 浏览器控制台会出现 Unhandled Promise Rejection 警告实践中如果要避免这种“失败无监听”的情况可以在数组里的每个 Promise 上主动附加一个 catch把错误包成“成功但不影响结果”的状态比如Promise.race([promiseA, promiseB.catch(error error)]) .then((result) console.log(结果, result))这样做之后即使 B 失败了也只是把它自己的错误当做一个成功值返回给 racerace 被 B 先触发的话会走 then 分支你可以在结果里判断是不是 Error 实例来区分。这个兜底套路在真实工程里非常常见。5. Promise.any 和四大静态方法的选择清单5.1 Promise.any第一个 fulfilled 的赢家Promise.any 和 race 很容易混淆但它们有一个关键差别any 只关心第一个 fulfilled成功的 Promise。如果某个 Promise 先变成 rejected它不会让 any 立即失败any 会继续等待直到有别的 Promise 成功。const failFast new Promise((resolve, reject) setTimeout(() reject(new Error(快速失败)), 100)) const slowSuccess new Promise((resolve) setTimeout(() resolve(慢成功), 500)) Promise.any([failFast, slowSuccess]) .then((value) console.log(任何一个成功即可, value)) .catch((error) console.log(所有都失败了, error))这段代码不会在 100ms 时进入 catch而是等到 500ms 时输出任何一个成功即可 慢成功。只有当所有 Promise 都变成 rejectedany 才会变成 rejected并且拒绝的原因是一个 AggregateError里面可以拿到所有失败原因。race 和 any 的区别一句话总结race 拿“第一个有结果的”成功失败都行any 拿“第一个成功的”失败不算数。做 CDN 资源竞速时我更推荐用 any因为它天然过滤掉了“某个源快速返回 500”的场景。5.2 一张表看懂四个静态方法很多文章会把 all、allSettled、race、any 分开介绍但真正到了写代码时遇到组合并发问题还是会犹豫。我把这几个方法的核心差异整理成一张表方便直接查用方法关注时机触发成功条件触发失败条件典型场景Promise.all等待所有所有 Promise 都 fulfilled任何一个 rejected快速失败多个接口缺一不可Promise.allSettled等待所有所有 Promise 都 settled永不失败只返回结果数组批量上报、容错收集Promise.race等待第一个任一 Promise 变为 fulfilled任一 Promise 变为 rejected超时控制、主备竞速Promise.any等待第一个成功任一 Promise 变为 fulfilled所有 Promise 都 rejected多源资源加载这张表最关键的信息是触发失败条件这一列。all 是一票否决race 是看谁先变脸allSettled 永远不失败any 是最后失败。5.3 选型建议从业务语义反推 API有时候记不住这堆 API 也没关系你可以从业务语义反推如果需求是“都要成功才继续”那就是 all如果是“结果全告诉我帮我检查哪些成功哪些失败”那就是 allSettled如果是“先到先用”那就是 race如果是“谁成功用谁失败的不算”那就是 any。这个映射关系在需求评审时就能定下来不用写代码前才纠结。我自己接需求时如果产品说要“等待所有接口完成后更新页面”我会追问“如果有一个接口失败怎么办页面还更新吗”这个问题的答案决定了我用 all 还是 allSettled。这种追问看起来很基础但恰恰是避免线上事故的关键。6. 实战进阶手写一个带并发限制的任务调度器6.1 需求拆解与方案设计前面讲的都是直接用静态方法但真实项目里你往往要面对另一个问题并发数量控制。假设你要请求 100 个订单详情Promise.all 一次性发出 100 个请求会把后端压垮用 for 循环串行效率又太低。这时候就需要一个“任务调度器”并发度限制在 5 或 10一批跑完补下一批。思路其实不复杂。先把所有任务按顺序放进一个队列然后一次性启动 N 个“执行器”每个执行器从队列头部拿任务执行执行完继续拿下一个直到队列空了。等所有执行器都忙完再用 Promise.all 聚合整体结果。6.2 代码实现与关键细节下面是一个简化但可直接用于项目的并发控制实现async function runWithConcurrency(tasks, limit 3) { const results new Array(tasks.length) let currentIndex 0 async function worker() { while (currentIndex tasks.length) { const taskIndex currentIndex currentIndex 1 try { results[taskIndex] await tasks[taskIndex]() } catch (error) { results[taskIndex] error } } } const workers [] for (let i 0; i Math.min(limit, tasks.length); i) { workers.push(worker()) } await Promise.all(workers) return results } // 模拟任务 const tasks Array.from({ length: 10 }, (_, index) async () { await new Promise((resolve) setTimeout(resolve, 200)) return 任务${index}完成 }) runWithConcurrency(tasks, 3).then((results) { console.log(results) })这里有一个容易出错的地方worker是一个 async 函数它的每次 while 循环里都会有await所以当并发为 3 时实际上会有 3 个 worker 同时在“抢”队列里的下一个任务。因为currentIndex的读取和递增是同步的不存在数据竞争问题所以不需要加锁。另一个细节是我用results[taskIndex] error来收集失败而不是直接 throw。这样即使某几个任务失败整个调度器也不会因为 Promise.all 的 fail-fast 机制而中断最后能拿到一份包含成功和失败数组的结果列表。如果你希望某个任务一旦失败就立即终止所有执行那可以在 worker 里把错误抛出来让 Promise.all 直接进入失败分支这取决于业务需要。6.3 调度器如何跟 all/race 结合这个调度器最终也用了Promise.all(workers)你会发现一个项目里通常是混合使用这些静态方法的。外层用 all 等所有 worker 结束内层每个 worker 又通过await tasks[taskIndex]()管理单个任务任务内部可能又用 race 做超时控制。runWithConcurrency( tasks.map((task) () withTimeout(task(), 3000)), 5 )这个组合拳在“批量抓取大量 URL 内容”的场景里非常实用。比如爬虫要抓几百个页面并发太高容易被目标站点限流用一个带超时控制的调度器既控制了并发又保证单个任务不至于卡死整个队列。6.4 微信 JS-SDK 配置场景参考就拿微信公众号网页开发举例常见的wx.config需要后端下发签名而页面里可能一次要初始化多个组件数据。如果直接串行先请求签名再请求组件数据首屏时间会被拉长。比较稳妥的做法是先用 fetch 并发请求签名、用户信息、业务数据等多个 Promise再在 Promise.all 的 then 里统一初始化 wx 组件这样整体时间等于其中最慢的接口耗时而不是所有接口耗时的累加。这类场景写起来其实就是前面 2.4 小节的 initPage 模式但要注意wx.config 的签名接口一旦失败整个页面初始化就失去意义这时候很适合用 Promise.all 的 fail-fast 特性快速进入错误页。而如果只是某个业务组件数据失败那用 allSettled 或单个 catch 更容易保证页面不白屏。这和前面的选型逻辑完全一致。7. 常见问题与排查技巧实录7.1 Unhandled Rejection 的烦恼用 Promise.all 或 race 时最常遇到的隐性 bug 就是 unhandledrejection。特别是用 race 做超时时主请求正常返回了超时 Promise 后来才 reject因为没有 catch浏览器控制台会报 Unhandled Promise Rejection。这种警告不影响页面运行但会在排查问题时造成干扰让人误以为有真正的异常。我的习惯是任何 Promise 数组里的元素如果它不是必定会被 catch就先给它补一个 catch 兜底。对于 race确保用finally清理定时器的同时可以在超时 Promise 里 catch 一下const timeoutPromise new Promise((_, reject) { timer setTimeout(() reject(new Error(timeout)), 3000) }).catch((error) { throw error })其实简单起见直接在 race 外层用.catch处理整体错误也能消除未捕获警告。关键是要形成模板习惯而不是每次临时判断。7.2 async/await 下 all 的错误聚合使用 async/await 配合 Promise.all 时有一点体验上的限制如果其中几个 Promise 同时失败await 只能拿到第一个失败的原因剩下的原因拿不到。举个例子async function getData() { const results await Promise.all([ Promise.reject(new Error(错误A)), Promise.reject(new Error(错误B)) ]) return results } getData().catch((error) { console.log(error.message) // 只输出错误A })这时如果你想知道所有失败原因得用 allSettled 配合过滤const results await Promise.allSettled([ Promise.reject(new Error(错误A)), Promise.reject(new Error(错误B)) ]) const errors results.filter(r r.status rejected).map(r r.reason.message) console.log(errors) // [错误A, 错误B]这个技巧在做排查、日志上报时特别有用。如果只靠 Promise.all 的 catch你可能只知道“某个请求失败”但不知道还有哪些请求也失败了需要用户多触发几次才能收集全。改成 allSettled 后一次请求就能把失败项一网打尽。7.3 不要在 for 循环里盲目包裹 Promise.all有一个常见写法让我每次 review 都要画红线// 不推荐循环里每次都创建 Promise.all for (const id of ids) { const result await Promise.all([getInfo(id), getDetail(id)]) // ... }这个写法的问题是循环里的 Promise.all 每次只能处理当前这一组整体上还是串行的没有发挥并发能力。正确做法是先收集所有任务数组再一次性调用 Promise.all// 推荐收集所有任务后一次性 Promise.all const tasks ids.flatMap(id [getInfo(id), getDetail(id)]) const results await Promise.all(tasks)当然如果任务数量过大就需要配合上一节的并发调度器而不是无脑 all。这里的核心原则是Promise.all 是“批量并发”不是“循环内逐个并发”。7.4 一张问题排查速查表现象可能原因处理建议Promise.all 一直不 resolve传入的某个 Promise 从未进入 settled给任务加超时控制Promise.all 某个小失败导致整体失败fail-fast 机制改用 allSettled 或分组 allrace 总是走失败分支“第一个结果”是失败用 any 或过滤失败控制台 Unhandled Promise Rejection某个 Promise rejected 后无监听给每个 Promise 补 catch结果顺序对不上把同步任务直接放在数组里用 Promise.resolve 包装这张表可以当作日常排错的一个起点。大多数 Promise 并发问题答案都在“机制理解”和“选型匹配”这两层上很少是浏览器或语言层面的 bug。8. 根据我经验总结的 3 个核心心法8.1 心态上把所有 Promise 当成“订阅关系”把 Promise 和“订阅”类比很多行为就顺了。Promise.all 相当于你订阅了一个“所有频道都播放完毕再通知我”的频道race 相当于你订阅了一个“哪个频道先播放完就通知我”的频道allSettled 相当于你订阅了一个“无论结果都告诉我”的频道。订阅关系里你不会因为其中一个频道挂掉就去关闭其他频道Promise 也不会。这个比喻帮我绕开了很多关于并发底层实现的纠结。你不需要知道浏览器和 Node 是怎么调度这些异步任务的你只需要知道当前代码订阅了哪些状态变化以及每个变化发生后你会收到什么通知。一旦把“控制流”的执念放下用“发布订阅”的视角看这些静态方法写起来会轻松很多。8.2 习惯上写一个通用 allSettled 工具函数即便浏览器已经支持 Promise.allSettled我还是建议在项目 utils 里封装一个增强版工具统一处理“结果是否成功”的判断和错误信息格式化function settledResults(results) { return results.map((result, index) { if (result.status fulfilled) { return { index, status: success, data: result.value, error: null } } return { index, status: error, data: null, error: result.reason } }) } async function allSettledWithIndex(promises) { const results await Promise.allSettled(promises) return settledResults(results) }有了这个工具业务代码里就不用到处写result.status rejected直接根据自定义的status字段做逻辑判断可读性更强也不容易漏掉处理失败的情况。8.3 性能上并发不是越多越好Promise.all 解决了并发写法的问题但没解决并发量的合理性问题。浏览器对同一域名的并发连接数有限制之前在 HTTP/1.1 时代Chrome 对同一域名的并发连接数大约为 6 个。就算你用 Promise.all 一次发起 20 个请求请求本身也会被排队实际并发并不会因为你代码写得简洁而变高。后来我养成的习惯是在项目里给 Promise.all 加一个分组封装把大数组按每批 5 个或 10 个切片每一批用 Promise.all 并发批与批之间串行或做小并发控制。这也是上面 6.x 调度器的一种简化应用。实际压测下来这种方式对后端更友好前端整体耗时虽然是多批串行的时长但波动更小也不会出现“一次性几十个请求把 Nginx 打冒烟”的事故。如果你做一个简单工具可能体会不到这个区别。但只要你的任务是几百条、上千条数据这块的处理就直接决定了功能能不能稳定上线。宁可多写几行分组逻辑也不要让页面或者服务端莫名其妙挂掉。说起来Promise.all 和 Promise.race 本身并不复杂复杂的是它们跟真实业务一结合就冒出来的各种边界问题。写这篇文章时我特意把“超时控制”“并发限制”“失败聚合”这几个最常组合的场景都过了一遍。踩过几次坑之后我的整体感受是你不需要死记硬背这四个静态方法的特性但一定要在写代码的时候想清楚业务到底关心什么结果。是关心全部完成还是关心先到先用还是关心最后失败没有。想清楚了API 自然就选对了。希望这篇文章能帮你在下一个并发需求里少走点弯路。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门