Skip to main content
Promise 我手写过几遍才记住:then 为什么强制异步、executor 和 then 回调的执行时机差在哪、thenable 展平和循环引用怎么处理。

Q1: Promise 的历史?为什么要有它?

1

回调地狱时代

早期的 JS 依赖回调函数处理异步。如果多个异步操作有依赖关系,代码会变成不断向右缩进的”回调地狱”,极难维护,且错误处理非常容易遗漏。
2

致命痛点: 控制反转与信任问题

当你把回调函数传给第三方库执行时,你无法确定它会调用几次(可能调多次、可能不调)。你失去了对代码执行权力的控制(控制反转)。
3

ES6 引入 Promise

Promise 提出了一个革命性的思想:不把回调传给第三方,而是让第三方返回一个”承诺”对象。拿到承诺后,我们自己决定在它成功或失败时做什么。
4

核心机制: 状态机模型 + 控制反转的再反转

Promise 是一个单向不可逆的有限状态机(pending → fulfilled/rejected)。状态一旦改变,永远定格。
5

关键洞察: then 回调强制异步

即使 Promise 已经 fulfilled,.then 的回调也会被强制放入微任务队列,保证执行顺序一致性(防 Zalgo 现象)。
Promise 解决的核心问题:
Promise 将异步操作的控制权从第三方接管回了调用者手中,并通过状态机保证了执行的唯一性和可靠性。
Promise 就像一份任务委托书(基于 Dan Abramov 的类比)。以前的回调模式是你把家钥匙给第三方(控制反转),你完全不知道他会不会去、去几次。Dan Abramov 在 overreacted.io 中指出,Promise 的设计哲学和 React 的 setState 如出一辙——它们本身不知道具体要做什么,只是一个通用的协调机制。Promise 就像一份委托书:第三方只负责填写”成功”或”失败”的结果,而你自己决定拿到结果后做什么。主动权从第三方手中夺回来了——这就是”控制反转的再反转”。

🚫 没有 Promise (回调时代)

你去餐厅点餐,点完后你把自己家的钥匙给了服务员,跟他说:“做好了去我家帮我把菜摆好”。如果服务员是个粗心的人,他可能去你家摆了三次,也可能根本没去。

✅ 引入 Promise

你去餐厅点餐,服务员给你一个”取餐器”(Promise)。你拿着取餐器回座位。取餐器只有两种最终状态:响了(做好了)或者显示异常(做砸了)。一旦响了,你自己去取餐。你牢牢掌握着主动权。

Q2: Promise 的本质与核心机制是什么?

Promise 的本质是一个状态机,核心逻辑只有 3 步:
Promises/A+ 规范对状态的表述是:“A promise must be in one of three states: pending, fulfilled, or rejected.” ECMA-262 则用内部槽 [[PromiseState]] 记录同样的三态。
  • 状态只能改变一次:resolve 之后再 resolve 或 reject 绝对无效。
  • then 回调总是异步的:即使 Promise 已经是 fulfilled,.then 的回调也会被强制放入微任务队列。
Promise 的状态就像一张已经签收的快递单。一旦 resolve(签收成功)或者 reject(拒收退回),这张单据就定格了——你再打电话给快递员让他改状态,他也改不了(状态吸收)。Dan Abramov 用 Promise 来类比 React 的 setState,正是因为两者共享同一个设计哲学:状态一旦确定,就不可逆转,后续的修改请求会被静默忽略。

状态机的三态转换规则

Promise 严格遵循有限状态机模型,只有两种转换路径:

thenable 对象与 Promise 的区别

thenable 是任何具有 .then 方法的对象,它不是真正的 Promise,但可以被 Promise.resolve() 识别和展开:

Q3: then 回调为什么必须是异步的?

很多人只知道 .then 是微任务,但实际更在意的是:如果 Promise 已经同步 resolve 了,为什么 V8 依然要把 .then 强行扔进微任务队列? 如果 .then 的回调在 Promise 已 fulfilled 时同步执行、在 pending 时异步执行,代码的行为就会因为内部状态的时序不同而产生极其诡异的偏差(Zalgo 现象)。为了保证执行顺序的绝对一致性,ECMA-262 的 PerformPromiseThen 无论 Promise 当前是什么状态,都会先创建一个 reaction 挂到该 Promise 上:若 Promise 已经 settle,则立即由 TriggerPromiseReactions 把对应的 PromiseReactionJob(V8 内部对应 PromiseReactionJobTask)加入微任务队列;若还是 pending,则先把 reaction 挂起,等它 settle 时再由 TriggerPromiseReactions 入队。也就是说,then 回调永远不会同步执行。

Zalgo 现象与一致性保证

“Zalgo”是指异步 API 有时同步执行、有时异步执行,导致代码行为不可预测:
then 回调强制异步就像咖啡店的出杯规则(引用自 Jake Archibald)。不管你点的拿铁咖啡师已经做好了没有(Promise 是否已 fulfilled),他绝对不会在你点单的同一秒就把咖啡递给你。他一定会先把当前手头的活干完(执行完当前同步代码),然后统一把做好的咖啡放到取餐台(清空微任务队列)。这样不管咖啡做得快还是慢,出杯的节奏永远一致,不会出现”有时候同步递给你、有时候又让你等”的混乱。Jake Archibald 在其文章中通过交互式 Demo 证明了这一点:即使 Promise 已经瞬间 resolve,.then 回调也绝不插队,必须等当前同步代码执行完毕。

微任务队列的入队时机

Promise 的 .then 回调在以下时机入队微任务队列:

Q4: Promise API 核心对比

基础梳理

经典场景:如果传入空数组会怎样? Promise.all([]) 会立即 resolve 一个空数组。这是规范行为——“全部成功”在没有任务时自然就是成功的。

Promise.all 的并发控制

Promise.all 没有并发限制,所有 Promise 同时发起。在实际项目中,需要手动实现并发控制:

Promise.any 的 AggregateError

当 Promise.any 的所有 Promise 都 reject 时,会抛出 AggregateError:

Q5: 手写 Promise A+:核心逻辑

手写 Promise 的关键不是背代码,而是理解四件事:状态机、回调队列、异步调度、thenable 展平。
.then 的回调返回值可以是 Promise、thenable 对象或普通值。resolvePromise 递归处理这些情况,确保无论嵌套多少层,最终都会被”展平”为一个普通值。关键:必须检测循环引用 if (promise2 === x),否则会陷入无限递归。

resolvePromise 的四种情况

resolvePromise 需要处理 .then 回调返回值的所有可能情况:

Promise A+ 规范的核心要求

Promise A+ 规范(Promises/A+)定义了 .then 方法的行为标准:
  1. .then 必须返回一个新的 Promise(支持链式调用)
  2. onFulfilled 和 onRejected 必须异步执行(微任务)
  3. onFulfilled 和 onRejected 必须作为函数调用(this 为 undefined)
  4. resolvePromise 必须处理 thenable 展平和循环引用

Q6: async/await:Generator + Promise 的语法糖

async/await 就像坐自动扶梯(基于 Dan Abramov 对 Promise 委托模式的解释)。await 是你踏上扶梯的那一刻——你不需要自己走(手动写 .then 链),扶梯会自动把你送到下一层(Promise resolve 后自动恢复执行)。如果扶梯突然故障(Promise reject),你会被安全弹出到旁边的楼梯间(try/catch 捕获错误)。本质上,async/await 只是给 Generator 这台”手动扶梯”装了个自动驱动马达——正如 Dan Abramov 所说,Promise 本身不知道具体要做什么,它只是一个通用的协调机制,而 async/await 让这个协调过程变得像同步代码一样自然。
await 的本质是 yield + Promise.resolve(value).then()。当 Promise resolve 后,自动执行器调用 gen.next(value) 恢复执行。错误通过 gen.throw(err) 注入到 generator 内部,所以 await 的错误能被 try/catch 捕获。

async/await 的执行顺序

await 下面的代码会被打包成微任务,这是理解 async/await 执行顺序的关键:
关键点:
  1. await 右边的函数立刻同步执行(输出 2)
  2. await 下面的代码被打包进隐形的 Promise.then()(输出 3 是微任务)
  3. 所以 await 下面的代码一定在当前同步代码之后执行(输出 4 在输出 3 之前)

async/await 输出题图解

面试中最经典的 async/await 输出题,逐步拆解:

await 的本质转换公式

记住这个转换公式,所有 async/await 输出题都能秒杀:

错误处理的最佳实践

async/await 的错误处理有两种模式:

Q7: Promise 状态吸收是什么?

Promise 的状态吸收(State Absorption)是指:Promise 一旦从 pending 转变为 fulfilled 或 rejected,后续所有的 resolve 或 reject 调用都会被静默忽略,不会抛出任何错误。 这是 ECMAScript 规范中 NewPromiseResolveThenableJob 与 PromiseResolveFunctions 配合的结果——状态机模型保证了 Promise 的状态只能改变一次,永远不可逆。

状态吸收在实际业务中的影响

状态吸收并非只是”面试冷知识”,它在真实项目中会直接影响你对竞态条件的处理:
在 Promise executor 中,throw 只有在状态仍是 pending 时才会真正触发 reject。一旦 resolve 已经被调用,throw 仍会被构造函数里的 try/catch 捕获并调用 reject,但此时状态已定格为 fulfilled,这次 reject 被静默忽略,不会改变 Promise 的状态。
Promise 的状态吸收就像一张已经盖了”已批准”或”已拒绝”章的审批单。一旦印章落下(resolve 或 reject),这张单据的状态就永久定格了。你再提交多少次修改申请(后续调用 resolve/reject),审批系统都会静默吞掉——不报错、不提醒、不改变。Dan Abramov 在 overreacted.io 中用类似的思路解释了 React 的 setState:状态更新的”知识”不在 setState 本身,而是在框架内部的协调机制中——Promise 也是如此,状态吸收的逻辑隐藏在引擎内部,调用者无感知。

Q8: 如何实现并发任务控制?

Promise.all 没有并发限制,所有 Promise 同时发起。在实际项目中,批量请求 100 个接口会瞬间打满浏览器的同域并发连接数(Chrome 限制 6 个),导致后面的请求排队等待,甚至触发服务端限流。并发任务控制的核心思想是:维护一个固定大小的执行池,池满时用 Promise.race 等待任意一个完成后再加入新任务。

更简洁的实现:parallelLimit 函数

p-limit 的设计思路

社区中最流行的并发控制库 p-limit 的核心原理与此类似,但它使用队列来管理等待中的任务,避免了多余的 Promise.race 微任务:
parallelLimit 用 Promise.race 实现”等待”,p-limit 用队列实现”排队”。两者在功能上等价,但 p-limit 的队列方式不会产生多余的 Promise.race 微任务,性能更优。在实际项目中,推荐直接使用 p-limit 库。
并发任务控制就像餐厅的等位系统。餐厅有 3 张空桌(limit = 3)。前 3 组客人直接入座(任务开始执行)。第 4 组客人来了,必须等其中一桌吃完离开(Promise.race 等待任意一个任务完成),才能入座。餐厅不会因为来了 100 组客人就把所有桌子都摆出来——桌子数(并发数)是固定的,多余的客人只能排队(队列等待)。

🔍 底层源码:V8 是如何实现 Promise 的?

V8 内部使用 Torque 语言实现 Promise(详见 V8 源码:src/builtins/promise-then.tq):
async/await 在 V8 内部被直接编译为 Generator 状态机 + 自动执行器。await expr 被转换为 yield Promise.resolve(expr),自动执行器在 Promise resolve 后调用 gen.next(res) 恢复执行,在 reject 时调用 gen.throw(err) 注入错误。

Q9: try/catch 能捕获 Promise 错误吗?什么可以捕获?

这是面试中最高频的 Promise 错误处理问题。很多人以为 try/catch 万能,但它对异步的 Promise rejection 根本无能为力。

try/catch 能捕获 Promise 错误吗?

结论:try/catch 不能捕获异步的 Promise rejection,只能捕获同步错误和 await 的 rejection。 根本原因是 try/catch 是同步的——它在当前调用栈执行完毕后就结束了。而 Promise 的 reject 是异步的(微任务),发生在 try/catch 已经退出之后:
根本原因:

什么可以捕获 Promise 错误?

有四种方式可以捕获 Promise 的 rejection: 方式 1:.catch() 方法
方式 2:async/await + try/catch
方式 3:unhandledrejection 事件(全局兜底)
方式 4:Promise.allSettled vs Promise.all

Promise 错误处理最佳实践

规则 1:永远不要让 Promise rejection 无人处理
规则 2:错误边界模式——局部 catch 做降级,链尾 catch 做兜底
规则 3:统一错误处理模式
try/catch 只能捕获同步错误和 await 的 rejection;异步的 Promise rejection 必须用 .catch() 或 unhandledrejection 事件捕获。最佳实践是:链尾 .catch() 兜底 + 局部 .catch() 做降级。

及格回答 vs 满分回答

问题:“说说 Promise 的核心原理,以及它解决了什么问题?”

及格回答(60-70分):
“Promise 是用来解决回调地狱的。它是状态机,三种状态只能单向转换。then 的回调异步执行(微任务)。支持链式调用。Promise.all 全部成功才 resolve,race 取第一个完成的。”
问题出在:背了规则但不理解设计,缺乏底层深度。 满分回答(90-100分):
“Promise 解决的核心问题不仅是链式调用,更是解决了控制反转与信任问题。作为单向不可逆的有限状态机,它保证了状态一旦变更为兑现(fulfilled)或拒绝(rejected),就只会执行一次。[1]”
“then 回调强制异步执行。根据 ECMA-262 规范,即使 Promise 已经兑现,PerformPromiseThen 也会创建一个 PromiseReactionJob(V8 内部对应 PromiseReactionJobTask)放入微任务队列,绝对保证执行顺序一致性(防 Zalgo 现象)。手写 Promise 时最难点在于 resolvePromise 的 thenable 展平机制——只要对象有 .then 就按 A+ 2.3.3 递归解析,并检测循环引用。[2]”
“async/await 不是什么全新的运行时机制,它在 V8 内部被编译为 Generator 状态机 + 自动执行器。await expr 被转换为 yield Promise.resolve(expr)。错误通过 gen.throw(err) 重新注入到函数体内,这就是为什么 await 的错误能被 try/catch 捕获。[3]“

常见追问

答题要点: 点出核心细节:如何保证返回顺序、如何判断结束。高分回答: “核心步骤有三:1) 遍历 promises 数组,用 Promise.resolve() 包裹每一个元素以兼容非 Promise 值。2) 使用 index 将结果存入 results 数组,而不是使用 push,这是为了强行保证返回结果的顺序与传入顺序一致。3) 用一个独立的 count 变量跟踪完成数,当 count === len 时执行最终的 resolve(results)。任何一个拒绝则直接触发外层的 reject(fail-fast)。”
答题要点: 体现对规范安全性的理解。高分回答: “如果 .then 的回调返回了 promise 自身(比如 const p = p1.then(() => p)),如果没有循环引用检测,程序就会陷入无限递归死等自己的状态改变。规范明确要求,在 resolvePromise 内部必须检测 if (promise2 === x),一旦命中必须立刻拒绝,并抛出一个 TypeError: Chaining cycle detected。”
答题要点: 不要只答”等待”,要说出暂停和微任务包转。高分回答: “await 右侧调用的函数会立刻同步执行。而 await 下面的所有代码,都会被打包塞进一个隐形的 Promise.then() 里,变成一个微任务去排队!所以在 Event Loop 中,遇到 await 并不是线程卡住了,而是当前协程让出控制权,将后续代码作为微任务注册到该 Promise 上。”
答题要点: 用代码推演三个经典陷阱。高分回答: “三个经典陷阱:
  1. 值穿透:.then 接收非函数时发生值穿透。Promise.resolve(1).then(2).then(console.log) 输出 1。
  2. 状态不可逆:resolve('success'); throw new Error('error') ——throw 被忽略,因为状态已经 fulfilled。
  3. 隐式包装:.then(() => new Error('error')) 不会触发 catch,因为 return Error 被包装为成功的 Promise。只有 throw 或 return rejectedPromise 才会触发 catch。”
答题要点: 点出 fail-fast 行为和容错能力的差异。高分回答: “Promise.all 是 fail-fast 策略——任何一个 Promise reject,整个 Promise.all 立刻 reject,其他 Promise 的结果被忽略。这在批量请求场景下会导致”一个失败全部失败”的问题。Promise.allSettled 是 ES2020 引入的,它会等待所有 Promise 都敲定(fulfilled 或 rejected),然后返回一个包含每个结果状态的数组。适合”不放弃任何一个结果”的场景,比如批量上传文件,即使部分失败也要知道哪些成功哪些失败。
答题要点: 手写一个带并发限制的 Promise 调度器。高分回答: “Promise.all 没有并发限制,所有 Promise 同时发起。如果一次请求 100 个接口,会瞬间打满浏览器的并发连接数(Chrome 同域限制 6 个),导致后面的请求排队等待。实现思路:维护一个执行池,大小为 limit。当池满时,用 Promise.race 等待任意一个完成后,再加入新的任务。
更优雅的方案是使用 p-limit 这类库,或使用 ES2024 的 Promise.withResolvers() 简化实现。”
答题要点: 说明 Promise 本身不可取消,需要借助 AbortController。高分回答: “Promise 本身设计上没有取消机制——状态一旦确定就不可逆。但可以通过以下方式实现’取消’效果:方案 1:AbortController(推荐)
方案 2:竞态取消
方案 3:Promise.race 与哨兵值
答题要点: 说明 .catch() 的位置对错误捕获范围的影响。高分回答: “Promise 链式调用中,.catch() 的位置决定了它能捕获哪些错误:位置 1:链尾(捕获整个链的错误)
位置 2:中间(局部错误处理)
最佳实践:
  1. 链尾 .catch() 兜底,防止未捕获的 Promise rejection
  2. 中间 .catch() 做降级处理,保证链继续执行
  3. 避免在 .then() 中嵌套 Promise,保持链式调用的扁平化”
答题要点: Promise 的状态一旦确定就不可变;resolve 一个 Promise 时会发生状态传递,外层 Promise 采纳 then 回调返回的那个 Promise 的状态,而不是由”最后一个 settle 的 Promise”决定。高分回答: “Promise 的状态吸收是指:当 resolve() 接收到一个 Promise(或 thenable)时,新 Promise 的状态会完全’吸收’该 Promise 的状态——它不会立即 fulfilled,而是等到被吸收的 Promise 也 settle 后,才以相同的值或原因变成对应的状态。这是 resolvePromise 内部机制的核心体现:
状态吸收在链式调用中的关键影响:
与状态不可逆的区别:状态不可逆强调的是’同一个 Promise 不能 settle 两次’;状态吸收强调的是’resolve(p) 时,p 的状态会传递给外层 Promise,外层不会立即 fulfilled’。两者结合保证了 Promise 链中值和状态传递的确定性。“

延伸阅读与引用参考