如何手写 Promise.all?需要处理哪些边界?
简化版
Promise.all 接收一个可迭代对象,按输入顺序收集所有完成值;只要有一个失败,就立即用该原因拒绝。手写重点是空数组直接完成、普通值也要包成 Promise、结果顺序不能按完成先后乱掉。
详细版
核心实现思路:把输入转成数组,长度为 0 时 resolve [];遍历每一项,用 Promise.resolve(item) 兼容普通值和 thenable;每个任务成功后把值写入对应下标,并统计完成数量;任意任务失败就 reject。
function promiseAll(iterable) {
return new Promise((resolve, reject) => {
const items = Array.from(iterable)
const results = new Array(items.length)
let fulfilledCount = 0
if (items.length === 0) {
resolve([])
return
}
items.forEach((item, index) => {
Promise.resolve(item).then(
value => {
results[index] = value
fulfilledCount += 1
if (fulfilledCount === items.length) resolve(results)
},
reject,
)
})
})
}
面试追问通常会看三个点:为什么输出要按输入顺序,为什么不能只接收 Promise 数组,以及失败后是否会取消其他请求。原生 Promise.all 不会自动取消已经发出的异步任务,只是返回的聚合 Promise 先进入 rejected。
完整版教学
一、先把语义拆成两个规则
Promise.all 的名字容易让人误以为它只负责“并发执行”。更准确地说,它做的是“聚合等待”:输入里的异步任务通常已经开始了,all 只是观察它们的结果,并把多个结果合成一个 Promise。
| 规则 | 含义 | 手写落点 |
|---|---|---|
| 全部成功才成功 | 每一项都 fulfilled 后,整体 fulfilled | 需要计数器 |
| 任一失败就失败 | 第一项 rejected 后,整体 rejected | 失败分支直接 reject |
| 顺序按输入 | 输出数组下标与输入下标一致 | 写入 results[index] |
| 兼容非 Promise | 普通值也算成功项 | 使用 Promise.resolve |
记忆钩子:
all不是“谁先回来谁排前面”,而是“谁在哪个输入位置,就写回哪个输出位置”。
二、为什么要先转成数组
输入参数是 iterable,不一定是数组。Set、生成器、字符串都可以被遍历,所以教学版先用 Array.from(iterable) 固定住输入快照,后续才能知道长度、创建等长结果数组并保留下标。
promiseAll(new Set([Promise.resolve('a'), 'b']))
.then(console.log) // ['a', 'b']
如果不先确定长度,空 iterable 的处理就会变得混乱。空输入没有任何异步任务需要等待,整体应完成为 [],而不是永远 pending。
三、结果顺序为什么不能按完成顺序
看一个具体时间线,A 需要 100ms,B 需要 10ms:
输入: [A, B]
10ms: B fulfilled -> results[1] = B
100ms: A fulfilled -> results[0] = A
输出: [A, B]
如果用 results.push(value),最终很容易得到 [B, A],这与规范语义不符。面试官常用“第二个请求先返回”来验证候选人是否真的理解聚合顺序。
四、失败之后发生了什么
const slow = new Promise(resolve => setTimeout(() => resolve('slow'), 1000))
const fastError = Promise.reject(new Error('boom'))
promiseAll([slow, fastError]).catch(error => console.log(error.message))
整体 Promise 会很快拒绝,但 slow 对应的定时器仍会继续运行。Promise.all 没有取消协议,也不知道底层任务是不是网络请求、定时器或文件读写;若业务需要取消,要配合 AbortController 或任务自己的取消方法。
五、为什么使用 Promise.resolve
Promise.resolve(item) 同时处理三类输入:普通值、原生 Promise、thenable。这样 promiseAll([1, Promise.resolve(2)]) 能得到 [1, 2],而不是因为第一项没有 .then 报错。
const thenable = {
then(resolve) {
resolve(3)
},
}
promiseAll([1, thenable]).then(console.log) // [1, 3]
这也让手写实现复用原生 Promise 的 thenable 决议能力,避免自己再处理 getter 抛错、多次回调等复杂边界。
六、复杂度和内存占用
假设输入有 n = 1000 个任务,结果数组需要 O(n) 空间,遍历注册回调需要 O(n) 时间。它不会把任务分批执行,所以 1000 个已经创建的 fetch 仍可能同时打出去。
| 需求 | 是否适合 Promise.all | 原因 |
|---|---|---|
| 并行请求 5 个接口并汇总 | 适合 | 数量有限,失败即可整体失败 |
| 10000 张图片限速上传 | 不适合单独使用 | 需要并发限制 |
| 希望拿到成功和失败明细 | 不适合直接用 | 应使用 allSettled 或自行包装 |
因此 all 是聚合器,不是限流器。面试中把它和并发控制混为一谈,是很常见的失分点。
七、常见误区与追问
- 误区:
Promise.all会启动异步任务。 多数任务在创建 Promise 或调用请求函数时已经开始,all只是注册观察。 - 误区:结果数组按完成顺序排列。 它按输入顺序排列,必须写入对应下标。
- 误区:失败后其他请求会自动取消。 聚合 Promise 拒绝不等于底层任务取消。
- 追问:空数组返回什么? 返回一个 fulfilled 状态的 Promise,值是空数组。
- 追问:普通值要怎么处理? 用
Promise.resolve包装,普通值会被当作已成功项。 - 追问:为什么不用
for...of直接 push? 可以遍历,但必须额外维护 index,否则顺序容易错。
八、加强记忆
记 Promise.all 可以抓住四个词:快照、下标、计数、短路。快照用于固定输入和长度;下标保证输出顺序;计数判断是否全部完成;短路表示任一失败时整体立刻失败。它解决的是“多个结果怎么合并”,不解决“最多同时跑几个”,并发限制要另写调度器。