← 返回题目列表

如何手写 Promise.all?需要处理哪些边界?

高频 中等 第 9 / 27 题 更新于 2026/07/29
手写代码Promise异步并发

简化版

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 可以抓住四个词:快照、下标、计数、短路。快照用于固定输入和长度;下标保证输出顺序;计数判断是否全部完成;短路表示任一失败时整体立刻失败。它解决的是“多个结果怎么合并”,不解决“最多同时跑几个”,并发限制要另写调度器。