返回首页

手写实现 Promise.all、allSettled、race、any:从原理到面试追问

从规范角度拆解 Promise.all 的 5 条核心语义,从 0 写一版能过所有边界 case 的实现,顺手把 allSettled / race / any 三个兄弟一起补完,最后捎带几个面试官常追问的点。

1 约 6 分钟 · 7337 字 前端

Promise.all 是 JS 异步编程里出场频率最高的工具之一,几乎每个面试都会问「能不能手写一下」。

但很多写法看似能跑,一上边界 case 就漏馅 —— 空数组怎么办?顺序怎么保证?传进来的不是 Promise 怎么办?某一个先 reject 了后面还要不要继续?

这一篇从规范角度把 Promise.all 的核心语义拆开,然后从 0 写一版能过所有边界 case 的实现,顺手把 allSettled / race / any 三个兄弟也补完。最后捎带几个面试常被追问的点。

先看原版语义

ECMAScript 规范对 Promise.all 的描述是一长串术语,提炼成 5 条人类能看懂的话:

  1. 它是 Promise 的静态方法,不是实例方法 —— 所以是 Promise.all([...]),不是 someProm.all(...)
  2. 接收一个可迭代对象(iterable,不只是数组)—— Set、Generator、Map.values() 都可以
  3. 返回一个新的 Promise
  4. 结果数组按"输入顺序"排列,跟谁先 resolve 没关系 —— 这个非常关键
  5. 任意一个 reject 就立刻 reject(短路),但已经在排队的其他 Promise 不会被取消,只是结果被丢弃
  6. 传入空数组立即 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)
    }
  })
}

简单到爆 —— 谁先调 resolvereject,后续调用都被 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...offorEach 哪个适合实现?

只能用 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-