Promise的“二次延迟”探究
问题描述
很多人在面试的时候应该都遇到过这样的Promise面试题目,给出一长串的Promise调用,问打印结果是什么:
new Promise((resolve) => {
resolve();
})
.then(() => {
new Promise((resolve) => {
resolve();
})
.then(() => {
console.log('A1');
})
.then(() => {
console.log('A2');
})
.then(() => {
console.log('A3');
})
.then(() => {
console.log('A4');
})
.then(() => {
console.log('A5');
});
// return new Promise((resolve) => {
// resolve('hello')
// })
})
.then(() => {
console.log('B1', 'ATTENTION!');
})
.then(() => {
console.log('B2');
})
.then(() => {
console.log('B3');
})
.then(() => {
console.log('B4');
});
输出:
A1
B1 ATTENTION!
A2
B2
A3
B3
A4
B4
A5
这个打印输出很好理解,在草稿上模拟一下微任务出对入队就能实现
但是如果将上面注释的地方解开,打印顺序又会是怎样呢?
new Promise((resolve) => {
resolve();
})
.then(() => {
new Promise((resolve) => {
resolve();
})
.then(() => {
console.log('A1');
})
.then(() => {
console.log('A2');
})
.then(() => {
console.log('A3');
})
.then(() => {
console.log('A4');
})
.then(() => {
console.log('A5');
});
return new Promise((resolve) => {
resolve('hello')
})
})
.then(() => {
//这个then的回调是什么时候加入到任务队列的?
console.log('B1', 'ATTENTION!');
})
.then(() => {
console.log('B2');
})
.then(() => {
console.log('B3');
})
.then(() => {
console.log('B4');
});
输出:
A1
A2
A3
B1 ATTENTION!
A4
B2
A5
B3
B4
此时的B1打印延后了两个tick,并不是我预期的一个tick,这是什么原因呢?
分析
之后我做了手写promiseA+规范,我自己的promise也做了这个打印:
输出:
A1
A2
B1 ATTENTION!
A3
B2
A4
B3
A5
B4
这与我预期的相同,只延后了一个tick。而我自己实现的promise是能通过所有promiseA+规范测试用例的。
这说明js的promise的行为并不是promiseA+规范规定的,而是ecma规定的
原因
MyPromise的实现
经过一番查找,我也能在自己的promise中,模拟出js的promise的行为了完整代码:

输出:
A1
A2
A3
B1 ATTENTION!
A4
B2
A5
B3
B4
由图可知,核心原因就是:在处理Promise返回值时,对于promise类型的返回值、thenable对象的返回值,添加额外的微任务包装
规范的说明
ecma规范中有对我实现的resolvePromise2函数有明确说明:即27.2.1.3.2 Promise Resolve Functions
含义如下:
Promise Resolve Function 被调用,参数为 resolution
↓
步骤1-7: 检查已resolved、循环引用等
↓
步骤8: resolution 是 Object 吗?
├─ NO → FulfillPromise(resolution) → Return
└─ YES ↓
步骤9: 获取 resolution.then
↓
步骤10: 获取 then 出错了吗?
├─ YES → RejectPromise → Return
└─ NO ↓
步骤11: thenAction = then 的值
↓
步骤12: thenAction 是可调用的吗?
├─ NO → FulfillPromise(resolution) → Return
└─ YES ↓
步骤13-15:
↓
HostEnqueuePromiseJob(NewPromiseResolveThenableJob(...))
放到代码中,含义如下

总结
这两个 queueMicrotask 是 ECMA-262 规范 27.2.1.3.2 步骤15 的直接要求。规范明确规定:当 resolve 函数的参数是 thenable 对象时,必须通过 HostEnqueuePromiseJob(即 queueMicrotask)将处理任务加入微任务队列异步执行。这是规范强制的行为
ECMA这么规定的原因(猜测)
相关的资料
-
2015 年 5 月 TC39 会议记录(Mark Miller 的发言),表达的核心是不变式:当环境冻结 primordials 且对象来自不受信任方时,与这些对象相关联的代码应当在 “later turn” 执行(即不要在当前同步路径里直接执行)。这与“遇到 thenable 时不要在 resolve 的同步路径里直接调用用户 then,而是交给 job/队列延后执行”的设计方向一致。
-
会议记录 PDF:https://archives.ecma-international.org/2015/TC39/tc39-2015-035.pdf
对原文的解析
你之前看到的那句会议记录原话核心是:
> “我们想维护的不变式是:在一个 realm 里 primordials(内建原型链/内建对象等)被冻结,并且 arb1/arb2/arb3 来自不受信任方时,与这些对象相关联的任何代码,都将在更晚的一轮(later turn)才会执行。”
把这句话拆开解释:
-
invariant(不变式):这是他们希望语言/规范在所有实现、所有边界条件下都成立的一条保证。
-
primordials are frozen:运行环境把关键内建对象/原型“冻结”,目的是让内建语义不被篡改(常见于安全沙箱、隔离环境、SES/Realm 类方案)。
-
objects are from an untrusted party:某些对象(arb1/arb2/arb3)来自不受信任代码(第三方脚本、沙箱外输入、跨 realm 传入等)。
-
any code associated with those objects:只要规范执行流程里需要“运行这些对象关联的用户代码”,例如:
-
读取并调用 thenable.then
-
调用代理(Proxy)trap
-
调用 getter/setter
-
调用用户提供的 handler
-
executed in a later turn:这些“潜在不受信任的代码”不应该在当前同步调用栈里立刻执行,而应该被推迟到后续调度点(从宿主角度就是通过 job/microtask 等机制延后),以维持隔离模型的可控性。
Mark Miller 那句“later turn”不变式,落到 Promise 上就是:当解析结果可能触发不受信任对象的用户代码(尤其是 thenable.then)时,规范倾向于把这段执行推迟到后续调度点;PromiseResolveThenableJob + HostEnqueuePromiseJob 就是实现这一不变式的机制之一。
推测
x 是 Promise时,这么做的好处
- 与原生一致的 adopt job 层级(时序一致、可预测)
- 更清晰的 微任务边界/重入控制
- 与外部 thenable 处理路径更一致(实现模型更接近规范)
x 是外部 thenable时,这么做的好处
- 统一异步语义:即使外部 thenable 的 then 会同步调用,也被强制变成“异步发生”,避免同步/异步混用导致的不可预测顺序(Zalgo)。
- 顺序更可控:通过“先入队 job 再调用 then”,让 thenable adopt 的时序与原生 Promise 更一致,减少不同实现/不同 thenable 造成的插队差异。
- 隔离异常与多次决议:在受控的 job 中调用 then,配合 called 防止多次 resolve/reject,并把 getter/then 抛错统一转成 reject,行为更稳定。
- 更贴合宿主语义:把执行点交给微任务/host job,让宿主在正确的 realm/script 上下文中运行外部 thenable 的代码,跨环境更一致。

浙公网安备 33010602011771号