Promise.all与Promise.race实战:异步并发控制与超时处理全解析
2026/9/14 19:32:43 网站建设 项目流程

不知道怎么的,这两个月后台系统做多了,每天都要处理一堆异步并发的问题。昨天还在改一个批量拉取用户信息的接口,前端一次性要拿到三百个用户的基础资料,后端没提供批量接口,只能前端拿 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 里我见过不少次。核心记忆点就一句话:allrace是构造函数层面的静态方法,接收一组 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 中有一个变成 rejected,Promise.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 分支,代码逻辑上反而更顺畅。

类似地,如果传nullundefined这种不是可迭代对象的东西,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 还是 rejected),race 返回的新 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 失败了,也只是把它自己的错误当做一个成功值返回给 race,race 被 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 都变成 rejected,any 才会变成 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 或分组 all
race 总是走失败分支“第一个结果”是失败用 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 自然就选对了。希望这篇文章能帮你在下一个并发需求里少走点弯路。

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

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

立即咨询