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的行为了完整代码
image-20260107155452354

输出:

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(...))

放到代码中,含义如下
image-20260107163214213

总结

这两个 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时,这么做的好处

  1. 与原生一致的 adopt job 层级(时序一致、可预测)
  2. 更清晰的 微任务边界/重入控制
  3. 与外部 thenable 处理路径更一致(实现模型更接近规范)

x 是外部 thenable时,这么做的好处

  1. 统一异步语义:即使外部 thenable 的 then 会同步调用,也被强制变成“异步发生”,避免同步/异步混用导致的不可预测顺序(Zalgo)。
  2. 顺序更可控:通过“先入队 job 再调用 then”,让 thenable adopt 的时序与原生 Promise 更一致,减少不同实现/不同 thenable 造成的插队差异。
  3. 隔离异常与多次决议:在受控的 job 中调用 then,配合 called 防止多次 resolve/reject,并把 getter/then 抛错统一转成 reject,行为更稳定。
  4. 更贴合宿主语义:把执行点交给微任务/host job,让宿主在正确的 realm/script 上下文中运行外部 thenable 的代码,跨环境更一致。
posted @ 2026-01-07 17:21  CatCatcher  阅读(13)  评论(0)    收藏  举报
#