
用 Promise.all 聚合几个上游,绕不开的问题就一个:失败之后会发生什么。答案其实三份文档里都写了,只是分散在不同页,而且写法很克制,容易被读漏。下面把条目逐条抄出来,每条后面跟一段本机实测输出,环境 Node v26.8.1,脚本存档 tools/verify-promise-all.mjs,用定时器和 reject 桩把时间点固定下来。
条目一:失败不会取消其他操作
MDN 在 Promise.all 的描述里写了两句:
Promise.all() will reject immediately upon any of the input promises rejecting.
Rejecting the returned promise does not cancel the remaining operations or
unsubscribe the handlers attached to their promises.
批注:把三个 promise 分别设成 50 ms 成功、20 ms 失败、80 ms 成功,看谁还在跑:
all 抛错 @22ms: B 失败
事件记录: B 抛错 @22ms | A 完成 @53ms | C 完成 @83ms
all() 在 22 ms 就抛出来了,剩下两个照跑到底,53 ms 和 83 ms 各有一次回调记录。也就是说「失败即返回」只影响返回值,不影响已经在飞的活儿。聚合接口里常见一种写法是上游 500 就把响应直接发出去,实际上另外几个上游的请求还占着连接,日志里也会继续出现它们的记录。要真的停,得自己接一个 AbortController 进去,这个动作 Promise.all 不会替你补。
条目二:后续的拒绝不会变成未处理异常
MDN 同一页还有一段:
Like other promise combinators, Promise.all() immediately marks all promises as
"handled" when it is called (by calling their .then() methods). Subsequent
rejections after the first rejection will be ignored, and will not trigger any
unhandledrejection events.
批注:这条能解释一个常见的疑惑,两个 promise 都失败、代码只 catch 了一次,进程却没崩。实测退出码是 0:
--- all 只捕获第一个拒绝 ---
退出码: 0
| 捕获到: 先到的拒绝
| 进程走到结尾
第一个拒绝被 catch 接住,第二个拒绝在 30 ms 之后才发生,那时 all() 已经定型,它不会触发未处理事件,返回值也被丢掉。反过来说,如果迟到的那个失败才是关键的那一个,日志里不会有任何痕迹,只能靠自己的代码在外面记一笔。
条目三:没人接的拒绝,Node 默认直接抛
Node 的 CLI 文档把三种模式列得很清楚:
throw : Emit unhandledRejection . If this hook is not set, raise the unhandled
rejection as an uncaught exception. This is the default.
strict : Raise the unhandled rejection as an uncaught exception.
warn : Always trigger a warning, no matter if the unhandledRejection hook is set
or not but do not print the deprecation warning.
批注:默认模式下,一个没人管的 Promise.reject 会让进程以退出码 1 结束:
--- Promise.reject 之后没人管 ---
退出码: 1
| Error: 没有人接的拒绝
| at file:///private/var/folders/.../verify-promise-J9AVm3/child.mjs:2:16
| at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
| Node.js v26.8.1
同一个脚本里那个 60 ms 的定时器一行都没打印,进程在被判定为未处理的那一刻就走了。忘记 await 的异步函数结果一样,退出码 1。对常驻服务来说这是好事,进程崩掉交给进程管理器拉起来,比带着失效的状态继续跑强。代价是启动脚本、定时任务这类一次性的场景会直接失败。
换成 warn 模式,进程继续跑到结束,退出码 0,stderr 上留一段警告:
退出码: 0
| 进程继续跑到结束
| (node:79606) UnhandledPromiseRejectionWarning: Error: warn 模式下的拒绝
| (Use `node --trace-warnings ...` to show where the warning was created)
| (node:79606) UnhandledPromiseRejectionWarning: Unhandled promise rejection.
| ... (rejection id: 1)
条目四:unhandledRejection 事件是给谁用的
Node 的 process 文档里把这个事件的触发条件写得很具体:
The 'unhandledRejection' event is emitted whenever a Promise is rejected and no
error handler is attached to the promise within a turn of the event loop.
批注:装上监听器之后,同一个没人接的拒绝不再让进程退出,退出码 0:
--- 带监听器的未处理拒绝 ---
退出码: 0
| 收到 unhandledRejection: 被监听的拒绝
| 进程仍然活着
文档里「within a turn of the event loop」这个限定也值得记一笔:同一个 tick 里先 reject 再立刻挂 catch,不算未处理。监听器最合适的位置是服务入口,把这类拒绝写进日志,再自己决定要不要退出,比让它静默崩掉或者静默活着都清楚。
条目五:race 输了的那一个
MDN 那句「Like other promise combinators」也覆盖了 Promise.race。批注:让快的先赢,慢的 60 ms 之后再 reject,退出码 0:
--- Promise.race 的败者拒绝 ---
退出码: 0
| race 结果: 快的赢了
| 进程走到结尾
race 同样在调用时给每个输入挂上了处理函数,所以败者的失败不会变成未处理事件。坑在另一头:用 race 做超时,超时赢了之后原请求还在跑,返回的 promise 被丢弃,里面的失败也就没人知道了。这和条目一的结论是同一件事,这些组合方法只决定返回值的走向,不管已经发出去的活儿。
顺带两条零碎的
空数组和同步值的行为:
Promise.all([]) -> [] (0ms)
Promise.all([1, 2]) -> [1,2]
Promise.all([]) 立刻完成,等待时间是 0 ms,异步函数里不用为「空结果」加特判。allSettled 的返回结构也贴一下,它不抛错,失败信息在 reason 里:
status: ["fulfilled","rejected"]
失败的 reason: x
落到代码上的取舍
聚合接口里,all() 用来表达「全都要成功」,失败就直接结束请求;需要每一个的结果、或者要区分部分失败,就换 allSettled(),再自己统计失败数量。真正容易被忽略的是条目一那种情况:第一个失败返回之后,其他任务继续在后台占资源,如果它们会写库或者发通知,得自己接 AbortController 去停。常驻服务的入口我会挂一个 unhandledRejection 监听器,把原因写进日志,同时保留退出码 1 的默认行为,交给进程管理器重启。