← 返回文章列表

手搓 Promise(上):Promise/A+ 核心语义

前端
  • # javascript
  • # promise
  • # async

突然想写“手搓 Promise”,是因为看到了这个链接:https://www.bilibili.com/video/BV19SmjY8EFU 。看完之后,我又想把 Promise 这件事从头梳理一遍。

这次不是为了把原生 Promise 的 API 一口气复刻出来,而是想先弄清楚它最核心的语义到底是什么。

如果从 API 表面看,constructorresolverejectcatchfinallyallrace 都很重要。但把它们放在一起看,反而容易分不清哪些是 Promise 的底层规则,哪些是围绕底层规则长出来的使用体验。所以这篇会先回到 Promise/A+图灵社区的中文译文)规范里的 then

原因很直接:Promise/A+ 并不试图规定完整的 JavaScript Promise API。它关注的是一件更底层的事:一个 promise 应该如何通过 then 暴露当前或未来的结果,以及 then 返回的新 promise 应该如何被解析。

换句话说,constructorPromise.resolvePromise.rejectcatchfinallyallrace 这些 API 都不是 A+ 规范的核心内容。它们不是凭空来的,而是围绕 A+ 里定义的状态、then 和 Promise Resolution Procedure 生长出来的更完整体验。

这篇先不写完整实现代码。先把 A+ 核心拆开:状态如何流转,then 为什么是 Promise 链式组合的中心,以及为什么 resolve 不等于直接 fulfilled。


Promise 最小模型

一个 promise 最少需要三样东西:

  • 状态:pendingfulfilledrejected
  • 结果:fulfilled 时的 value,或 rejected 时的 reason。
  • 回调:通过 then 注册的 onFulfilledonRejected

状态只能从 pending 变到 fulfilledrejected。一旦变了,就不能再改。这个“一次性状态机”是 Promise 的底层秩序。

如果状态可以来回变化,then 链就没有稳定语义。前一个回调到底应该收到哪个结果,后注册的回调又应该按什么状态执行,都会变得不可预测。

所以手搓 Promise 的第一步,不是先把所有方法补齐,而是先守住状态流转:

  • 初始状态一定是 pending
  • fulfilled 和 rejected 只能二选一。
  • 状态变更后,结果也随之固定。
  • 后续再尝试改变状态,必须被忽略。

这个规则看起来简单,但后面所有链式调用、错误传播、组合 API 都依赖它。

在真正实现状态机之前,可以先看一个极简骨架:constructor 接收一个执行器 executor,并在内部构造出 resolvereject,再把它们交给执行器调用。

class MyPromise {
  value

  constructor(executor) {
    const resolve = (value) => {
      this.value = value
      console.log('Resolved with value:', this.value)
    }

    const reject = (reason) => {
      this.value = reason
      console.log('Rejected with reason:', this.value)
    }

    executor(resolve, reject)
  }
}

这样写之后,创建实例时就能在执行器里拿到这两个函数:

new MyPromise((resolve, reject) => {
  resolve('hello promise')
})
// Resolved with value: hello promise

new MyPromise((resolve, reject) => {
  reject('something went wrong')
})
// Rejected with reason: something went wrong

这段代码只是在演示 resolvereject 是怎么被构造并传进去的。它还不是一个合格的 Promise,因为它没有记录 pendingfulfilledrejected 状态,也没有保证状态只能改变一次,更没有处理 then 和异步回调。后面真正手搓时,要在这个骨架上把状态流转补上。


then 才是核心

Promise/A+ 的主角是 then

then 做的事情不只是“注册一个回调”。它至少承担三层职责:

  • 根据当前 promise 的最终状态,决定调用 fulfilled 回调还是 rejected 回调。
  • 返回一个新的 promise,让链式调用可以继续。
  • 根据回调的返回结果,决定这个新 promise 的状态。

第三点最关键。then 返回的新 promise,不是简单地等于回调返回值。它需要经过一套解析过程:如果回调返回普通值,新 promise 会 fulfilled;如果回调抛出异常,新 promise 会 rejected;如果回调返回另一个 promise 或 thenable,新 promise 要采用它的最终状态。

这就是为什么 Promise 链可以把同步返回、异步结果、异常和 thenable 统一到同一条链路里。

也正因为 then 会返回新的 promise,错误传播才会变得可组合。某一层回调抛出的错误,不会直接打断整段程序,而是变成下一层 promise 的 rejection。后面的 thencatch 可以继续接住它。


回调必须异步执行

Promise 还有一个容易被低估的规则:then 注册的回调必须异步执行。

即使一个 promise 已经 fulfilled,后面再调用 then,对应的回调也不能立刻在当前调用栈里同步执行。它需要等当前执行栈清空之后再运行。

原生 Promise 使用 microtask。A+ 没有把调度机制绑定到某个具体 API,但要求回调不能在当前执行栈里同步调用。

这个规则让 Promise 的行为更一致。否则一个回调到底是同步执行还是异步执行,会取决于 promise 在注册回调之前是否已经 settle。调用方就必须同时防备两套时序,代码会很难推理。

手搓 Promise 时,异步调度不是锦上添花,而是语义的一部分。


resolve 不是直接 fulfill

手搓 Promise 最容易写错的地方,是把 resolve(value) 理解成“立刻 fulfilled 为 value”。

这只对普通值成立。

如果 resolve 接收到的是另一个 promise,当前 promise 不能直接 fulfilled 为这个 promise 对象,而是要采用它的最终状态。对 thenable 也是一样。thenable 不一定是原生 Promise,也不一定来自我们的实现;只要它暴露了符合约定的 then 方法,就需要被当作可跟随的异步结果。

这件事就是 Promise Resolution Procedure 要解决的问题。

它的核心目标是:无论回调返回什么,都把它解析成当前 promise 可以理解的最终状态。

大致规则可以这样理解:

  • 如果解析目标就是当前 promise 自己,必须拒绝,避免无限递归。
  • 如果目标是普通值,当前 promise fulfilled 为这个值。
  • 如果目标是对象或函数,并且带有可调用的 then,就把它当作 thenable。
  • 如果 thenable 最终 resolve,当前 promise 跟随它的 resolved 结果。
  • 如果 thenable 最终 reject,当前 promise 也 rejected。
  • 如果读取或调用 then 时抛错,当前 promise rejected。
  • 如果 thenable 同时尝试 resolve 和 reject,只认第一次。

这些规则看起来繁琐,但它们换来的是互操作性。不同 Promise 实现之间能够协作,靠的不是它们有同一个类名,而是它们都遵守同一个 then 协议。


为什么要只认第一次

Promise 的状态只能改变一次,这条规则在 resolution procedure 里也必须继续成立。

一个不规范的 thenable 可能先 resolve,又 reject;也可能连续 resolve 多次;甚至可能在 resolve 后继续抛出异常。Promise 不能被这些行为拖着来回变化,只能采用第一次有效结果。

这也是为什么手搓 Promise 时,通常需要在解析 thenable 的过程中额外记录“是否已经调用过”。这个记录不是实现细节洁癖,而是在保护 Promise 的单次状态语义。

只认第一次,才能保证链条后面的每一个节点都面对一个稳定结果。


手搓 Promise 真正要抓住什么

手搓 Promise 不是为了在项目里替换原生实现。真正有价值的是把几个容易混在一起的概念拆开。

resolve 不等于 fulfilledresolve 是一次解析过程,它可能接到普通值,也可能接到 promise 或 thenable。只有解析完成后,promise 才会进入 fulfilled 或 rejected。

then 不只是注册回调。它会返回一个新的 promise,并用回调的返回结果决定这个新 promise 的状态。

A+ 规范不等于完整的 JavaScript Promise API。A+ 规定的是稳定的 then 行为和 resolution procedure。把这层弄清楚之后,再看 catchfinallyallrace 这些 API,就不会觉得它们是另一套东西。

理解到这一层,再写实现代码会顺很多。因为每一段代码都能对应回一个明确问题:是在维护状态,还是在调度回调,还是在解析 thenable。

至于那些不属于 A+ 规范、但围绕它生长出来的常用 API,我会放到下篇单独讲。


配套学习

顺着这个主题,我也整理了一个配套学习地址:https://zxq2023.github.io/promise_learnring/

文章更偏向概念梳理,这个学习页会把实现过程拆成更细的步骤,适合边看边动手验证。