1. 从一个噩梦般的bug说起:为什么要研究Promise A+
如果你写前端写过一两年,大概率遇到过我这样的经历:用axios请求一个接口,然后 .then(res => ...) 回调里数据拿不到,页面白屏一片;或者两个 Promise.all 套着用,某个分支里抛了个异常,整个链路都不走了,但控制台只给了一句泛泛的 "Unhandled promise rejection"。
最坑的一次,我排查了整整一天,最后发现是引用的两个第三方库各自实现了Promise,一个用原生Promise,另一个用的是老版本的 bluebird,这两个实例的 then 互不认账,回调链直接断掉。那种感觉就像两个人的方言都说的是"吃饭",但一个说普通话一个说客家话,谁也听不懂谁。
Promise的互操作性问题,就是这个叫 Promise A+ 的规范想解决的事情。它不规定Promise有什么用、能不能取消、怎么去调度微任务——这些全是后来的Promise标准去补充的。A+做的只有一件事:把 then 方法的行为规则抠到极致,抠到任何一套Promise实现只要遵守这套规则,就能跟其他任意遵守规则的实现完美协作。
这篇文章,我把这套规范和它背后的核心思想,用最直白的方式拆开来讲。你不需要预先精通任何Promise源码,只要写过 .then(),有一点点"回调"的概念,就能看懂。适合谁看:被Promise链搞到怀疑人生的前端;面试前想彻底搞懂Promise底层机制的求职者;以及想尝试手写Promise源码、但被各种版本的实现绕晕的开发者。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Promise A+到底在规范什么:一份只谈then的互操作协议
先别急着钻进代码。理解任何标准,第一步是理解它的边界:它管什么,不管什么。Promise A+最反直觉的地方就在于——它的全部内容几乎都在说 then。
2.1 规范的全名里藏着它的野心
Promise A+的完整名称是 "Promise/A+ Specification",它是在早期Promise/A、Promise/B等提案的基础上修订出来的一个开放标准。为什么叫"A+"?因为在它之前有一个"Promise/A"规范,那套规范设计得比较粗略,不同实现之间对 then 的语义理解不一致,导致同一个Promise代码,换一个库跑起来结果就不一样。A+就是要做那个"加强修正版"。
这个规范的制定过程非常公开,它其实是社区里一批工程师从各自Promise库的实践中提炼出来的共识。jQuery的Deferred、Q、When.js、Bluebird、原生Promise,后来的实现基本都向A+靠拢。这意味着你现在写的原生Promise代码,拿给任何一个遵守A+的库去消费,行为都是可预期的。
2.2 A+不管的那些事,为什么不管
A+管得极其克制。它不规定:
- Promise怎么创建(是
new Promise(...)还是别的构造方式) - 怎么实现
.catch、.finally、.all、.race这些方法 - 回调是异步执行还是同步执行(虽然它要求必须异步,但它没规定用宏任务还是微任务)
- 未捕获的rejection怎么上报
- 怎么取消Promise
你可能会问:这也不管那也不管,那要它干嘛?
这里的关键认知是:A+的目的是 标准化then方法,让不同的Promise实现可以互相操作。所有的Promise实例,无论来自哪个库,最终都靠 then 来传递值和状态。只要 then 的行为统一,上层的一切方法(catch、all、race)都是个人自由。原生Promise比A+多了很多方法,但这些方法内部的基石,依然是严格遵循A+规则的 then。
打个比方:A+就像统一了电源插头的规格。至于插头上接的是电饭煲还是洗衣机,标准不关心,但你只要用这个规格的插头,任何标准的插座都能插。
3. 状态机模型:Promise为何必须是"单向流动"的水
Promise的核心是一个极其简单的状态机。三个状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。规则也只有两条:
- 状态只能从 pending 变到 fulfilled 或 rejected
- 状态一旦改变,就永远不可逆
这个看起来人畜无害的规则,是整个Promise设计哲学的地基。
3.1 为什么要"单项流动"而不是"可逆"
很容易忽略的是,这个"不可逆"特征,恰好是Promise能避免回调地狱最关键的机制。
想象一下,如果Promise状态可以来回切换,会是什么场景?你在 .then 里拿到一个值,正准备处理,结果Promise从 fulfilled 又跳回 pending,重新发起一次运算,那你上一次拿到的值还算数吗?代码的确定性就没了。而回调地狱之所以可怕,就是因为在回调嵌套中,状态和时序是纠缠在一起的,你根本说不清某个数据是哪个时刻的。
Promise用"状态一旦定型就永久固定"这种粗暴但无比有效的方式,把时间问题转化为了空间问题。一个 fulfilled 的Promise,你任何时候去 then 它,拿到的都是同一个值,顺序无关、时间无关。这就像一份公证过的合同,内容一旦盖章,什么时候查阅都一样。
3.2 一个 fulfilled 的Promise,then 注册的时机没有任何影响
这里有一个常见的误解:必须先有结果,然后再 then 才有意义。实际上,A+规定 then 可以在Promise处于任何状态时调用:
- pending 时调用:回调会排队,等状态落定后执行
- fulfilled 时调用:当前轮直接执行成功回调
- rejected 时调用:当前轮直接执行失败回调
有意思的场景是,一个已经 fulfilled 的Promise,你调用 then,回调并不立刻同步执行,而是异步调度。A+的2.2.4条要求:onFulfilled 和 onRejected 必须"直到执行上下文栈只包含平台代码时"才被调用。翻译成人话:then里面的回调永远不可能是同步的。你写 Promise.resolve(1).then(console.log); console.log(2),输出顺序一定是 2 然后 1。
这条规则为的就是消除"有时同步有时异步"的时序不确定性。如果 then 的回调有时同步有时异步,程序执行的先后顺序就依赖状态,这会让代码的行为在并发和嵌套场景下变得完全不可预测。
3.3 解释Promise A+中的术语:value、reason、thenable
在深入细节之前,先把规范里几个高频词统一口径:
- value:成功状态下的值,可以是任意合法的JavaScript值,包括
undefined、对象,甚至另一个Promise - reason:失败状态下的原因,通常是一个Error对象,但理论上可以是任何值
- thenable:任何带有
then方法的对象或函数
A+真正的精妙之处都在处理"value是一个thenable"时该怎么办——这也是下一节的核心。记住这个词:"thenable"。
4. then方法的完整规则:Promise A+的主战场
A+文档里,关于 then 的规则占了几乎一大半的篇幅。这里的每一条,拆开看都能发现它是在回答一个具体场景下的问题。
4.1 then 必须返回一个Promise,这是Promise链得以存在的基石
A+的2.2.7条,在我看来是整个规范中最具分量的一条:
then必须返回一个Promise。
为什么这条至关重要?因为如果 then 不返回新的Promise,你就没法链式调用,代码会退回回调嵌套的老路。但仅仅是"返回一个Promise",远没有听起来那么简单——关键在于,新Promise的状态要取决于回调的返回值。
规则是这样的:
- 如果
onFulfilled或onRejected返回一个普通值x,那么新Promise用x去resolve - 如果回调本身抛出了异常
e,那么新Promise以e为reason去reject - 如果回调返回的是
x,而这个x是一个Promise或thenable,那么新Promise要"吸收"x,最终状态由x的状态决定
正是这"返回值会被resolve"的设定,让你可以在 .then 里直接返回一个Promise来摊平嵌套,而不用手动再包一层。
4.2 值穿透:为什么 Promise.resolve(1).then(() => {}) 拿到了 undefined
一个让新手费解的现象:.then 回调里没有 return,那么链下一步拿到的就是 undefined。因为回调返回了 undefined,它被自动作为下一个Promise的value去resolve,"穿透"给了下一环。
把这条规则和A+的所谓"Promise Resolution Procedure"(后面会讲)放在一起看,你会发现Promise链本质上就是一条值流水线:每个回调函数像一个处理器,把上一步的输出转换为下一步的输入;如果处理器没有显式返回任何东西,流水线默认传一个 undefined 过去。这个"不返回就传undefined"的规则,让链式调用变得非常规整——你漏了 return,它不会报错,但值就丢了,这种bug在真实的业务代码里非常常见,排查起来也经常让人抓狂。
4.3 关于 onFulfilled 和 onRejected 的参数
A+对参数的规则很简单:
onFulfilled和onRejected都是可选参数- 如果它们不是函数,会被忽略
这引出两个实践中的行为:
promise.then()不传任何函数,返回的新Promise会沿用原Promise的状态和值(这就是我们常说的"透传")- 如果传了非函数(比如
then(1)),这个值会被忽略,相当于传了个空函数
正因如此,很多polyfill在实现 promise.then(null, rejectHandler) 时,会把 null 当作"忽略成功回调"处理。这也是A+规范中"onFulfilled和onRejected必须是函数"这个约束的实际意义。
4.4 同一个Promise的 then 可以被调用多次,且回调执行顺序必须遵循注册顺序
规范明确:同一个Promise可以多次调用 then,每次调用都会注册一个新的观察者;当Promise状态落定时,所有对应状态的回调必须以它们被注册的顺序依次执行。
这条规则的工程价值非常大。想想看,一个共享的Promise可能被多个模块同时消费:A模块注册成功回调,B模块注册成功回调,两个模块互不干扰,各自拿到同一个value。如果没有"按序执行"这个保证,回调的执行顺序就可能和注册顺序不一致,导致某些依赖"先注册先执行"的业务逻辑错乱。
4.5 异常的处理边界:回调里抛错,自动转成rejection
onFulfilled 或 onRejected 如果抛出异常,这个异常会被捕获,并作为新Promise的reason。这意味着,你在 .then 链中任意一环抛错,都会被后续的 .catch 捕获。这个行为初看是"贴心",细想却是"必然"——如果异常在回调里被吞掉,那整个链就不声不响地断掉了,排查起来远比直接报错要痛苦。
一个小细节:规范中的 "throw" 指的是任何通过 throw 语句抛出的值,不限于 Error 对象。你可以 throw "字符串错误"、throw { code: 500 },这些都是合法的,只是实践中没人这么干。
5. Promise Resolution Procedure:resolve一个值,到底在resolve什么
这是A+最核心、最抽象、也最容易被忽略的一部分。看规范你会发现它叫 Promise Resolution Procedure,缩写为 [[Resolve]](promise, x),它的作用是决定:当我们要resolve一个Promise时,遇到任意值 x,该怎么处理。
5.1 两种截然不同的分支:x是Promise/thenable,还是普通值
这个程序的核心逻辑是一个递归的判断过程:
- 如果
promise和x指向同一个对象(即promise === x),则抛出一个TypeError作为rejection reason - 如果
x是一个Promise,则采用x的状态(不管x最终是 fulfilled 还是 rejected,结果一并"继承") - 如果
x是一个对象或函数,且带有then方法,则把这个then方法取出来调用,并将promise自身作为参数传进去 - 否则,
x就是一个普通值,直接让promise进入 fulfilled 状态
每一层分支看着不难,但组合出来的行为,恰恰是各种Promise实现最容易出bug的地方。
5.2 resolve(promise, promise):自引用导致的死循环保护
先看第1步:如果 x === promise,直接reject。这个边界处理是为了防止一种极其隐蔽的死循环场景。
想象一下:你在回调里返回了同一个Promise对象本身。
javascript复制const p = new Promise((resolve) => {
resolve(p);
});
如果规范不拦这一步,会发生什么?尝试用 p 去resolve p,意味着 p 的状态要等待 p 的结果,而 p 的结果又依赖 p 本身——无限循环,程序直接崩溃。A+的这条规则一锤定音:遇到这种情况,直接抛TypeError。这也是为什么我们手写实现时,这一步要最先判断。
5.3 吸收thenable:为什么A+能让任何Promise实现互相协作
再来看第3步,这是A+实现互操作性的灵魂。所谓"吸收thenable",意思是:如果 x 恰好是个带有 then 方法的对象,我们不去想"它到底是不是正规Promise",而是直接把它的 then 方法取出来,调用它,并把resolve和reject两个控制器传进去。
javascript复制// 当你 resolve 一个 thenable 时,实际发生的事
const x = {
then(resolve, reject) {
resolve(42);
}
};
Promise.resolve(x).then(console.log); // 最终输出 42
x 可能来自某个老旧的Promise库,可能根本不是Promise,只是个普通对象带着 then 方法。关键在规范中这句话:"如果取 then 属性时抛错,用这个错误作为reason reject;如果调用 then 方法时抛错,也以此为reason reject;如果 then 被调用了,那么后续再取得的状态就被锁定,后续再调用的resolve/reject将被忽略"。
这里头有个细节很容易被实现者漏掉:then 方法只能被调用一次。因为Promise Resolution Procedure允许 then 方法内部对resolve和reject都进行调用,后续调用会被直接忽略。这个"只认第一次"的规则,是为了防止 then 内部恶意或意外地多次改变状态。这也是手写Promise时,我们经常用一个 called 标志位来保护的 resolve/reject 函数的原因。
5.4 为什么取 then 方法时也要做异常处理
规范里有一条容易被忽略的规则:在获取 x.then 这个属性时,如果发生异常(比如 then 是一个 getter,抛错了),也要用该异常作为reject理由。这是防御性设计的典范——一个看起来无害的属性访问,也可能在执行任意用户代码时出错。
从这也能看出A+的设计哲学:任何一步都可能抛异常,异常之后必须走reject分支,而不是让错误悄无声息地消失。真正理解这条,你再看那些手写Promise的源码,就会明白为什么每一层都在try/catch。
5.5 但Promise Resolution Procedure不关心"值"是什么样的、合法不合法
值得注意的是,A+对"value"本身并不挑拣。布尔值、数字、字符串、对象、函数、undefined,甚至getter会抛错的对象,统统会走不同的处理路径,但规范从不定义"什么是合法的业务值"。这种宽容是有意为之——A+只管状态流转的机制,业务值的校验是使用者的事。
6. 手写一个"够用且规范"的迷你Promise:从零到能跑通核心场景
理论说了一堆,最终还是要落到代码。这里我按A+的核心要求,从零手写一个极简的实现。它不含 .catch、.finally、.all 这些方法,但 then 的行为是严格贴近A+的,足够让你跑通90%的场景,也能帮你理解上述所有规则是如何被"实现"出来的。
6.1 第一步:构造函数和状态管理
javascript复制class PromiseAPlus {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state !== 'pending') return;
this.state = 'fulfilled';
this.value = value;
this.onFulfilledCallbacks.forEach(fn => fn());
};
const reject = (reason) => {
if (this.state !== 'pending') return;
this.state = 'rejected';
this.reason = reason;
this.onRejectedCallbacks.forEach(fn => fn());
};
try {
executor(resolve, reject);
} catch (e) {
reject(e);
}
}
}
核心注意点:
- 状态锁定的判断用的是
if (this.state !== 'pending') return;,这保证一旦状态落定,后续的resolve/reject调用全部失效 executor执行时如果抛错,直接转入reject,这也是Promise构造器最基本的防护
6.2 第二步:实现then,并处理"回调必须异步执行"
javascript复制 then(onFulfilled, onRejected) {
const promise2 = new PromiseAPlus((resolve, reject) => {
if (this.state === 'fulfilled') {
queueMicrotask(() => {
try {
const x = onFulfilled ? onFulfilled(this.value) : this.value;
resolvePromise(promise2, x, resolve, reject);
} catch (e) {
reject(e);
}
});
}
if (this.state === 'rejected') {
queueMicrotask(() => {
try {
const x = onRejected ? onRejected(this.reason) : this.reason;
resolvePromise(promise2, x, resolve, reject);
} catch (e) {
reject(e);
}
});
}
if (this.state === 'pending') {
this.onFulfilledCallbacks.push(() => {
queueMicrotask(() => {
try {
const x = onFulfilled ? onFulfilled(this.value) : this.value;
resolvePromise(promise2, x, resolve, reject);
} catch (e) {
reject(e);
}
});
});
this.onRejectedCallbacks.push(() => {
queueMicrotask(() => {
try {
const x = onRejected ? onRejected(this.reason) : this.reason;
resolvePromise(promise2, x, resolve, reject);
} catch (e) {
reject(e);
}
});
});
}
});
return promise2;
}
这里值得解释的三个实现选择:
第一,我用了 queueMicrotask 而不是 setTimeout 来异步执行回调。A+规定要异步,但没规定用什么异步机制;原生Promise在绝大多数环境里是用微任务来调度回调的(V8里具体是 promiseReactionJob),所以用 queueMicrotask 更贴近真实表现。
第二,每个回调都套了一层try/catch,并且把回调的返回值 x 传给了一个名叫 resolvePromise 的函数。这正是Promise Resolution Procedure的入口:我们不能简单地 resolve(x),因为如果 x 是一个thenable,还要走吸收分支。
第三,同一个Promise被多次 then,回调数组会按序累积,状态落定时按序执行。
6.3 第三步:resolvePromise——本实现里最有含金量的函数
javascript复制function resolvePromise(promise2, x, resolve, reject) {
if (promise2 === x) {
reject(new TypeError('Chaining cycle detected for promise'));
return;
}
if (x instanceof PromiseAPlus) {
x.then(
value => resolve(value),
reason => reject(reason)
);
return;
}
if (x && (typeof x === 'object' || typeof x === 'function')) {
let called = false;
try {
const then = x.then;
if (typeof then === 'function') {
then.call(
x,
(y) => {
if (called) return;
called = true;
resolvePromise(promise2, y, resolve, reject);
},
(r) => {
if (called) return;
called = true;
reject(r);
}
);
} else {
resolve(x);
}
} catch (e) {
if (called) return;
called = true;
reject(e);
}
return;
}
resolve(x);
}
这个函数严格对应A+的 [[Resolve]]:
- 自引用检测(第1步)
- 如果
x是本实现自己的Promise实例,直接透传状态 - 如果
x是对象或函数,尝试取它的then,如果是函数就调用它,吸收thenable - 否则按普通值处理,直接resolve
called 标志是必须的。它保证了 then 内部无论怎么反复调用resolve/reject,最终只有第一次生效。这模拟了真实Promise对"多重settle"的免疫能力。
6.4 测试一下:这个迷你实现能跑通什么
用A+规范提供的最常用测试场景来验证:
javascript复制const p = new PromiseAPlus((resolve) => {
setTimeout(() => resolve(1), 100);
});
p.then((v) => {
console.log('第一步拿到:', v);
return new PromiseAPlus((resolve) => resolve(v + 1));
}).then((v) => {
console.log('第二步拿到:', v);
}).then(() => {
throw new Error('故意抛错');
}).then(
() => console.log('不会执行'),
(e) => console.log('捕获到错误:', e.message)
);
如果上面的代码按预期输出 第一步拿到: 1、第二步拿到: 2、捕获到错误: 故意抛错,说明我的实现基本满足A+的核心要求。有兴趣的读者可以去 npm 上搜 promises-aplus-tests,这是规范官方用于验证实现的测试套件,里面覆盖了几百个边界用例。我第一次跑这个测试套件时,瞬间塌了十几个用例,全是resolvePromise的处理细节没到位。其中比较典型的几个失败场景如下表:
| 测试场景 | 常见失败原因 |
|---|---|
| resolve自身引用 | 没有先判断 promise2 === x,导致递归爆栈 |
| thenable的then是getter且抛错 | 没有在 const then = x.then 处做try/catch |
| thenable内先调resolve再调reject | 没有用 called 标志锁定"只认第一次" |
| 回调返回thenable,且thenable内再返回thenable | resolvePromise递归分支处理不完整 |
| 回调返回Promise,且Promise被reject | 没有把rejection传给promise2,漏掉了 x.then 的失败分支 |
7. A+没提到、但实践中躲不开的问题:微任务、未捕获rejection与调度
A+标准本身极其克制,但它没有覆盖的灰色地带,在实际运行中是躲不开的。这些地方恰恰是面试官最爱问,也是真实bug的高发区。
7.1 为什么原生Promise不等同于"await了两下"——微任务的调度边界
A+说 then 的回调必须异步,但它没说用宏任务还是微任务。原生V8实现里,Promise的回调是在微任务队列中执行的。这意味着:
javascript复制Promise.resolve().then(() => console.log('微任务1'));
setTimeout(() => console.log('宏任务'), 0);
Promise.resolve().then(() => console.log('微任务2'));
输出顺序是:微任务1、微任务2、宏任务。微任务队列会在当前宏任务结束前全部清空,而 setTimeout 是下一个宏任务。这类调度细节,A+完全不关心,但它直接影响你在业务里能不能"精确地"依赖某个时序。
7.2 未捕获的rejection:A+不管,浏览器管
A+规定:如果一个Promise被reject,但没有任何 then 或 catch 去消费这个rejection,规范不要求做任何事。但现代浏览器(以及Node.js)会在事件循环结束时发现"存在未处理的rejection",抛出 UnhandledPromiseRejection 警告。
这里有个很有意思的行为差异。看这段代码:
javascript复制const p = Promise.reject(new Error('bad'));
setTimeout(() => {
p.catch(() => {});
}, 100);
在很多环境里,即使你之后补上了 .catch,浏览器依然可能报未捕获rejection警告。因为浏览器在"这一轮事件循环"结束时检查时,p 还没有被处理。所以实践上有一条铁律:promise创建后,要在同一个事件循环内尽快挂上处理逻辑,不要放到定时器里。这条铁律A+没写,但生产环境排查问题时天天见。
7.3 await 与 then:同一套底层,不同的语法糖
async/await 在底层依然是Promise和 then,但从A+的角度看,await 对Promise的"消费"方式有一点点不同。await p 在大多数引擎中的实现,可以理解为:注册一个 then 回调来恢复当前async函数协程,并处理状态传递。
正因为 await 只是语法糖,它不会改变Promise本身的行为。一个被 await 的Promise,如果处于rejected状态,异常在try/catch里能被捕获;如果没有try/catch,就会变成未捕获的rejection。这个行为和我们直接写 .catch 是完全一样的。
7.4 关于 resolve 一个thenable时,then 的调用时机
A+的Promise Resolution Procedure有一个隐含的微妙之处:当你 resolve(thenable) 时,解析程序会在当前微任务中调用 thenable.then。这造成了一个行为差异:如果你 resolve 的值是个thenable,它的 then 方法被调用的时间点,比普通值被resolve要晚一步(多了一轮微任务)。这个微小的时序差异,在某些对性能极端敏感的调度场景下会造成可观测的差别。
8. 一套关于Promise A+的常见误解与澄清对照表
随着我接触相关代码越来越多,发现有些误解几乎是新人必踩的,这里整理成一张对照表,方便你在面试或自查时快速对照。
| 误解 | 真相 | 说明 |
|---|---|---|
| Promise的then回调一定在微任务中执行 | A+没规定,但原生Promise确实用微任务 | 手写Promise时用setTimeout也能通过部分测试,但不是浏览器原生行为 |
| 同一个Promise then两次,两个回调会并行执行 | 不会,它们顺序按注册顺序执行 | 见A+ 2.2.6 |
| then里return一个Promise时,外层promise2会直接变成那个Promise | 不完全是;promise2会"解析"这个Promise并继承其最终状态 | 这就是resolvePromise里的 x instanceof Promise 分支 |
| promise2 === x 的检测只发生在最外层 | 错了;resolvePromise递归处理的每一层都要检测 | 递归最深处的thenable也可能引用promise2 |
| rejected的Promise不处理就会静默失败 | 不处理会在事件循环结束后触发UnhandledPromiseRejection警告 | 浏览器和Node中行为略有差异 |
| catch就是then的语法糖 | 对,也不对;p.catch(fn) 等价于 p.then(undefined, fn),但catch本身不强制返回Promise |
但从A+的角度看,catch是基于then派生出来的,不在此规范范围内 |
| Promise构造器里同步抛出的异常会被resolve接住 | 不会,会被reject接住 | executor里的try/catch,是异常转reject的关键 |
这些误解中,最危险的是第一条。很多手写Promise教程为了省事,用 setTimeout 实现异步调度,严格来说模拟出来的行为偏差很大,在特殊时序下会和原生Promise大相径庭。如果只是练习可以理解,但生产环境绝不能这么干。
9. 从A+看真实的JavaScript生态:兼容、框架和面试
最后,从规范跳出来,聊几个和技术现实相关的点。
9.1 为什么很多Promise库已不再需要"安装"
原生Promise成为JavaScript标准之后,曾经红极一时的Q、When.js、Bluebird等库在日常业务里的使用率大幅下降。但在某些偏底层或跨环境的场景中,你依然会看到它们的影子:比如一些老的Node.js项目、或是对微任务调度有特殊要求的场景(Bluebird提供了 setScheduler 让你自定义调度方式),还有老版本浏览器兼容场景下使用polyfill。
A+在这个演进过程中的角色是:它作为一个统一标准,保证了Promise库之间可以混用而不会互相打架。当年我在老项目中用axios(它内部用原生Promise),返回的Promise传给一个习惯用Bluebird的工具库,行为依然完全正常,靠的就是A+这套规则。
9.2 面试中最常见的"A+深层问题",怎么答才加分
面试官问Promise A+,未必会让你背规范原文。更常见的是一系列围绕行为细节的追问:
Promise.resolve(1).then(() => {}).then(v => console.log(v))输出什么?(答案是undefined,因为没return就透传undefined)Promise.resolve().then(() => { throw new Error('bad') }).catch(e => console.log(e.message))能捕获吗?(能)resolve一个Promise对象时会怎样?(继承它的状态)- 两个Promise循环引用会怎样?(TypeError)
- then回调里返回同一个Promise对象会发生什么?(TypeError)
这些问题的答案,全部能从A+的规则里推出来。理解原理比背答案重要得多。
9.3 从A+到现代异步编程的思维迁移
A+表面上讲的是Promise的状态机规则,但它背后真正训练的是一种思维方式:如何设计一个行为完全可预测的异步原语。
状态不可逆、回调必异步、resolve程序递归吸收thenable、异常永不静默——这几条规则,其实可以迁移到很多异步设计场景中:事件总线、状态管理、任务队列、流式处理。你理解了"因为状态不可逆,所以多个消费者看到的是同一个结果",就能理解为什么Redux的state要保持不可变;理解了"回调必须异步",就能理解为什么很多框架把数据变更后的通知放到微任务里。
所以我的建议是:不要只把A+当成一个考题去记,而是把它当成一份异步语义的参考教科书去读。读懂了它,你打交道的很多库和框架的内部行为,在你的眼里都会变得透明很多。
10. 我在实际项目中总结的几条Promise使用习惯
规范和技术细节讲完了,最后分享几条我实际写业务代码时沉淀下来的习惯,算不上普适真理,但确实帮我少踩了很多坑。
其一,所有Promise链一定要有末端“出口”或错误兜底。哪怕你知道不可能出错,也永远给链尾挂一个 .catch 或者 catch 后重新抛出,避免UnhandledRejection。这个习惯在一次线上事故中救过我:某个后端接口偶尔返回了一个非200状态码,前端用一个Promise链去解析数据,链尾没有兜底,结果错误被吞掉,用户看到了一个空白页,而控制台静悄悄的。
其二,构造器里不要写耗时长的同步计算。Promise构造器的executor是同步执行的,你在里面同步跑一个100ms的计算,会阻塞主线程。真有耗时任务,放在异步回调里再触发resolve/reject。
其三,对于那种"多个模块共享同一个Promise"的场景,如果状态已经落定,后续 then 注册的回调也会被异步调用。这意味着哪怕数据已经就绪,注册回调仍然不会同步拿到值。如果业务代码依赖这个顺序,建议手动缓存值而不是依赖Promise的注册时序。
其四,尽量不写深度嵌套的 then,而是在中间用 async/await 摊平。回想一下A+里的Promise Resolution Procedure:每次 then 返回的 promise2,都可能在resolve回调返回值时经历一次完整的 resolvePromise 过程,这个过程的性能开销虽然不是巨大,但链条一长,代码可读性和调试难度都会急剧上升。写成 await 形式,异常处理会更直白,每个 await 后面加try/catch也更容易定位问题。
最后,如果你打算在项目里手写一个迷你Promise,我的建议是:直接用 promises-aplus-tests 来验证,不要凭感觉。它有两百多个用例,覆盖了大量边界场景,让你“以为写完了”的结果在几分钟内就露馅。真正跑完全部用例的那一刻,你对A+每条规则的理解会深刻很多。
