Promise.all 是 JS 异步编程里出场频率最高的工具之一,几乎每个面试都会问「能不能手写一下」。
但很多写法看似能跑,一上边界 case 就漏馅 —— 空数组怎么办?顺序怎么保证?传进来的不是 Promise 怎么办?某一个先 reject 了后面还要不要继续?
这一篇从规范角度把 Promise.all 的核心语义拆开,然后从 0 写一版能过所有边界 case 的实现,顺手把 allSettled / race / any 三个兄弟也补完。最后捎带几个面试常被追问的点。
先看原版语义
ECMAScript 规范对 Promise.all 的描述是一长串术语,提炼成 5 条人类能看懂的话:
- 它是
Promise的静态方法,不是实例方法 —— 所以是Promise.all([...]),不是someProm.all(...) - 接收一个可迭代对象(iterable,不只是数组)—— Set、Generator、Map.values() 都可以
- 返回一个新的 Promise
- 结果数组按"输入顺序"排列,跟谁先 resolve 没关系 —— 这个非常关键
- 任意一个 reject 就立刻 reject(短路),但已经在排队的其他 Promise 不会被取消,只是结果被丢弃
- 传入空数组立即 resolve 一个空数组(同步任务里直接微任务 resolve)
第 6 条经常被忽略 ——
Promise.all([])不会一直 pending,它会立刻 resolve[]。
另外一条隐性约束:入参里如果不是 Promise(比如 Promise.all([1, 2, p3])),非 Promise 值会被 Promise.resolve 包装一下。这意味着 thenable(任何带 .then 方法的对象)也能被正确处理。
第一版:朴素实现
按上面 6 条语义,直接照着翻译成代码:
Promise.myAll = function (iterable) {
return new Promise((resolve, reject) => {
const result = []
let resolvedCount = 0
let total = 0
for (const item of iterable) {
const index = total
total++
Promise.resolve(item).then(
(value) => {
result[index] = value
resolvedCount++
if (resolvedCount === total) {
resolve(result)
}
},
(err) => reject(err),
)
}
if (total === 0) resolve([])
})
}几个跟原版「话不多说直接上代码」对比的关键点,逐条说一下:
1. 是 Promise.myAll,不是 Promise.prototype.myall
原版挂在 Promise.prototype 上,意味着调用方式是 somePromise.myall([...]),跟原生 Promise.all([...]) 完全不一样。这不是手写,这是另一个东西。
顺手回顾:
Promise.all / Promise.race / Promise.allSettled / Promise.any全是静态方法;.then / .catch / .finally是实例方法。手写时不要混。
2. 用两个独立计数器,不要一个 i 双重用途
原版那种 i++ 后 i-- 的写法虽然能 work,但读起来很别扭,且容易在改动时引入 bug。两个清晰命名的计数器(total 入队,resolvedCount 完成)更稳。
3. result[index] = value 是顺序保证的核心
这一行是整个实现的"灵魂"—— 它保证了结果数组的顺序跟输入顺序一致,而不是按 resolve 先后顺序。把这行改成 result.push(value),你就写挂了。
4. 空 iterable 的处理
if (total === 0) resolve([]) 必须放在 for 循环之后:循环里如果有任何一个同步遍历进来的元素,total 都会被 ++,这条就不触发。完美兼容 Promise.all([])。
5. reject 短路
任意一个 Promise reject,直接调 reject(err)。Promise 状态一旦确定就不可变,所以后续即使别的也 reject 了,新调用的 reject 会被忽略,不会出错 —— 这是 Promise 状态机的天然特性,不需要额外加 flag。
测试用例:覆盖所有边界
const p1 = Promise.resolve(1)
const p2 = new Promise((res) => setTimeout(() => res(2), 100))
const p3 = new Promise((res) => setTimeout(() => res(3), 50))
Promise.myAll([p1, p2, p3]).then(console.log)
// 100ms 后输出 [1, 2, 3] —— 注意是输入顺序,不是 [1, 3, 2]
Promise.myAll([]).then(console.log)
// 立刻输出 []
Promise.myAll([1, 2, Promise.resolve(3)]).then(console.log)
// 立刻输出 [1, 2, 3] —— 非 Promise 值也能正确处理
Promise.myAll([
Promise.reject('first error'),
new Promise((_, rej) => setTimeout(() => rej('second error'), 100)),
]).catch(console.error)
// 立刻输出 'first error','second error' 被吞
const set = new Set([Promise.resolve(1), Promise.resolve(2)])
Promise.myAll(set).then(console.log)
// 输出 [1, 2] —— iterable 而不仅仅是数组也能 work
function* gen() {
yield Promise.resolve('a')
yield Promise.resolve('b')
}
Promise.myAll(gen()).then(console.log)
// 输出 ['a', 'b'] —— Generator 也能 work把这几个 case 在控制台粘一遍跑一下,如果都通过,这版实现基本就稳了。
顺手补完三个兄弟
Promise.all 一旦写明白,allSettled / race / any 都是简单变体,语义差别一句话就能讲清:
| 方法 | 何时 resolve | 何时 reject | 结果格式 |
|---|---|---|---|
Promise.all |
全部成功 | 任一失败 | [v1, v2, ...] |
Promise.allSettled |
全部结束(无论成败) | 从不 reject | [{ status, value/reason }, ...] |
Promise.race |
第一个结束的(无论成败,赢家通吃) | 第一个失败的 | 单个值 |
Promise.any |
第一个成功的 | 全部失败 | 单个值 / AggregateError |
myAllSettled
Promise.myAllSettled = function (iterable) {
return new Promise((resolve) => {
const result = []
let settledCount = 0
let total = 0
for (const item of iterable) {
const index = total
total++
Promise.resolve(item).then(
(value) => {
result[index] = { status: 'fulfilled', value }
settledCount++
if (settledCount === total) resolve(result)
},
(reason) => {
result[index] = { status: 'rejected', reason }
settledCount++
if (settledCount === total) resolve(result)
},
)
}
if (total === 0) resolve([])
})
}跟 myAll 几乎一样,唯一区别是把每个 Promise 的两路(resolve / reject)都"包装成结果",而不是让 reject 短路整体。
myRace
Promise.myRace = function (iterable) {
return new Promise((resolve, reject) => {
for (const item of iterable) {
Promise.resolve(item).then(resolve, reject)
}
})
}简单到爆 —— 谁先调 resolve 或 reject,后续调用都被 Promise 状态机吞掉。注意:Promise.race([]) 会永远 pending(没人能给信号),这是规范行为,不是 bug,所以不需要写空 iterable 的特判。
myAny
Promise.myAny = function (iterable) {
return new Promise((resolve, reject) => {
const errors = []
let rejectedCount = 0
let total = 0
for (const item of iterable) {
const index = total
total++
Promise.resolve(item).then(
(value) => resolve(value),
(err) => {
errors[index] = err
rejectedCount++
if (rejectedCount === total) {
reject(new AggregateError(errors, 'All promises were rejected'))
}
},
)
}
if (total === 0) {
reject(new AggregateError([], 'All promises were rejected'))
}
})
}是 myAll 的"反向版":任一成功就 resolve,全部失败才 reject(且失败时抛 AggregateError,这是 ES2021 引入的新错误类型)。
几个面试官常追问的点
Q1:Promise.all([1, 2, 3]) 会怎么样?
会立即 resolve [1, 2, 3]。因为非 Promise 值会被 Promise.resolve 包装,而 Promise.resolve(1) 是一个已经 resolved 的 Promise,所以 .then 回调会在下一个微任务立即执行。
Q2:Promise.all 有没有"取消"机制?
没有。如果其中一个 Promise reject,其他还在执行的 Promise 照样会跑到底,只是它们的结果(无论 resolve 还是 reject)都被丢弃。如果它们里面有副作用(发请求、写文件),副作用照常发生。
想取消的话,得自己用
AbortController配合fetch之类的 API,Promise 本身是没有取消语义的。
Q3:为什么 result[index] = value 不会因为稀疏数组报错?
JS 的数组允许"洞"(holes),arr[5] = 'x' 即使 arr.length === 0 之前也不会报错,而是把 length 自动延长到 6,中间补 <empty>。Promise.all 的结果在所有 Promise resolve 之前,中间确实会出现稀疏的中间状态,但最后一个 resolve 时 resolvedCount === total,数组刚好填满,不再稀疏,所以最终 then 拿到的结果数组没有 holes。
Q4:thenable 是怎么被识别的?
Promise.resolve(item) 内部的逻辑是:如果 item 已经是 Promise 实例,就原样返回;如果是带 .then 方法的对象(thenable),会被包装成一个 Promise;其他值直接 resolve(value)。所以你的 myAll 实现不需要单独写 thenable 兼容逻辑,Promise.resolve 帮你搞定了。
Q5:reject 短路的"短路"是怎么做到的?
不是真的取消执行,而是利用 Promise 状态机的只能转换一次特性:第一次 reject(err) 把外层 Promise 状态从 pending 变成 rejected,后续再调 resolve(result) 或 reject(otherErr) 都会被静默忽略。所以代码上看起来"短路"了,本质是状态机吞掉了后续调用。
Q6:for...of 和 forEach 哪个适合实现?
只能用 for...of —— 因为我们要支持 iterable(Set / Generator / Map.values()),而 forEach 是 Array 专属方法,Set 的 forEach 签名又不一样。for...of 是规范级的 iterable 协议,什么 iterable 都吃。
一句话总结
Promise.all 看起来简单,但要写一版能过 6 条规范、3 个边界 case、4 种 iterable 类型的实现,得搞清楚状态机、迭代器协议、微任务调度这几件事。
把这一篇里的 myAll / myAllSettled / myRace / myAny 都默写一遍能过自己写的测试用例,这道题在面试里基本就稳了。
下次见。
-EOF-