资讯动态

手写 Promise:从状态机到微任务的底层原理

发布时间:2026/10/1 3:26:00 来源:尧图企业网站定制
1. 这个标题为什么值得写Promise 不是会用就够的Promise 大概是前端领域被误解最深的一个概念。日常开发里人人都在用fetch().then()但你去问then 返回的到底是什么为什么 promise 回调一定在 setTimeout 之前执行unhandled rejection 是怎么被检测出来的能回答清楚的人并不多。我最早意识到 Promise 必须从原理层面吃透是在一次面试中。面试官问如果promise.then()返回的是 this会有什么问题我当时脑子里只有不能链式这个模糊印象却说不出真正的机制。后来花了几个晚上手写了一个完整的 Promise 实现包括静态方法和微任务调度模拟才把这块彻底打通。这篇文章的价值就在这里不教你怎么用 Promise而是带你把它拆开再重装回去。通过手写一个符合 Promise/A 规范核心行为的实现你会看清状态机、回调队列、值穿透、微任务调度这些底层机制到底是怎么配合的。文章面向两类读者一是准备前端面试、需要手写 Promise 的人二是被各种uncaught (in promise)报错折磨、想从根上理解异步执行顺序的工程开发者。先给一个基础认知框架Promise 本质上是一个状态机外加一个发布订阅系统。状态机负责只能从等待到完成/拒绝且不可逆的流转规则发布订阅负责状态变化后如何触发注册好的回调。这两句话看似简单却是整个 Promise 设计的基石。2. 三个前置概念状态机、回调队列、微任务在写代码之前必须先把三个底层概念讲透。不搞懂这三件事手写 Promise 只能停留在背模板的水平。状态机。Promise 的状态只有三个pending等待中、fulfilled已兑现、rejected已拒绝。状态流转只有两条路径pending - fulfilled或者 pending - rejected。一旦进入 fulfilled 或 rejected状态就永久锁定任何操作都无法改回去。这种设计最直接的好处是消除竞态条件——回调函数可能被调用多次promise 的 resolve/reject 却只有第一次有效。这个不可逆特性的实际意义可以想一个例子用户点击按钮发起请求请求回来后要关闭 loading 弹窗。如果用户在请求完成前又点了别的东西导致组件卸载回调方式可能因为多次触发而出现 UI 状态混乱promise 方式则没有这个问题——状态钉死后续操作自动失效。回调队列。当 promise 还在 pending 状态时then注册的回调不能立即执行得先存起来。等状态变为 fulfilled 或 rejected 后再把这些回调取出来执行。这里必须区分两类回调一类是成功回调onFulfilled一类是失败回调onRejected分别存到两个队列里。后面实现时也可以选择单数组用参数区分但在源码层面分开存更清晰也符合多数开源实现的习惯。微任务。Promise 的回调不是同步执行的而是异步派发到微任务队列中。事件循环的规则是先执行当前宏任务里的同步代码接着把微任务队列清空最后才轮到下一个宏任务。所以Promise.resolve().then(...)的回调一定在同步代码之后、下一个 setTimeout 之前运行。这个机制解释了几乎所有的promise 时序题。一个有用的心法记忆方式微任务队列像是一条 VIP 通道宏任务队列是普通通道。每当前面的人处理完不管普通通道排了多少人都要先让 VIP 通道里的人全部走完。promise 回调属于 VIPsetTimeout 属于普通。这个类比在调试异步问题时很管用。理解了这三个概念再去看 Promise 的构造函数和 then 实现会顺畅得多。3. 手写 Promise核心骨架搭建先明确我要实现的 Promise 满足哪些要求。参考 Promise/A 规范最基本的是三个状态pending、fulfilled、rejected、不可逆的状态流转、then 方法的链式调用、异步任务按微任务调度的执行顺序。在此基础上我还会加上 Promise.resolve、Promise.reject、Promise.all、Promise.race 等静态方法最后用自定义的调度器来替代原生微任务方便在浏览器里直观观察任务执行顺序。第一步先定义 Promise 的状态常量和主类结构const PENDING pending const FULFILLED fulfilled const REJECTED rejected class MyPromise { constructor(executor) { this.status PENDING this.value undefined // 兑现值 this.reason undefined // 拒绝原因 this.onFulfilledCallbacks [] // 等待中的成功回调队列 this.onRejectedCallbacks [] // 等待中的失败回调队列 const resolve (value) { if (this.status PENDING) { this.status FULFILLED this.value value this.onFulfilledCallbacks.forEach((fn) fn()) } } const reject (reason) { if (this.status PENDING) { this.status REJECTED this.reason reason this.onRejectedCallbacks.forEach((fn) fn()) } } try { executor(resolve, reject) } catch (e) { reject(e) } } }这里我用两个独立的回调数组而不是单个数组。原因在于同一个 promise 上可以分别调用then注册成功回调和失败回调比如promise.then(onFulfilled)只关心成功promise.catch(onRejected)只关心失败。如果混在一起就难以分别触发。分开放逻辑清晰触发时也互不干扰。constructor 里的 try/catch 也很关键。executor 是同步执行的new Promise 时立即执行如果它在执行过程中抛出了异常promise 应该直接进入 rejected 状态。这个设计不再把异常抛到全局而是封装成 rejection 原因。这就是 promise 在错误处理上优于裸回调的核心体现错误可以被传递、被捕获而不是静默吞掉或直接冒泡到调用栈顶部。接着实现 then 方法。then 是 promise 的核心入口它接受两个参数onFulfilled 和 onRejected。教学版的 then 逻辑如下then(onFulfilled, onRejected) { if (this.status FULFILLED) { onFulfilled(this.value) } if (this.status REJECTED) { onRejected(this.reason) } if (this.status PENDING) { this.onFulfilledCallbacks.push(() onFulfilled(this.value)) this.onRejectedCallbacks.push(() onRejected(this.reason)) } }这个版本能处理最基本的情况但有两个明显缺陷。第一如果onFulfilled内部抛出了异常异常会直接冒泡到调用栈而不是被后续的 catch 捕获第二then 没有返回新 promise没法链式调用。这两个缺陷直接划出了教学玩具版和真正能用版本的分界线。接下来我们逐一解决。4. 链式调用与值穿透Promise 的调度阀先说结论then 必须返回一个新的 promise新 promise 的状态取决于 onFulfilled/onRejected 的返回值而不是原来的 promise。这一步是整个 Promise及其生态如 all/race 等静态方法最核心的设计也是 Promise 替代回调地狱的根本原因。升级后的 then 方法骨架then(onFulfilled, onRejected) { const onFulfilledFn typeof onFulfilled function ? onFulfilled : (value) value const onRejectedFn typeof onRejected function ? onRejected : (reason) { throw reason } const newPromise new MyPromise((resolve, reject) { const handleFulfilled () { queueMicrotask(() { try { const result onFulfilledFn(this.value) this.resolvePromise(newPromise, result, resolve, reject) } catch (e) { reject(e) } }) } const handleRejected () { queueMicrotask(() { try { const result onRejectedFn(this.reason) this.resolvePromise(newPromise, result, resolve, reject) } catch (e) { reject(e) } }) } if (this.status FULFILLED) { handleFulfilled() } else if (this.status REJECTED) { handleRejected() } else { this.onFulfilledCallbacks.push(handleFulfilled) this.onRejectedCallbacks.push(handleRejected) } }) return newPromise }这里有几个细节必须展开。回调的默认值设计。当onFulfilled不是函数时默认实现为(value) value值就能一路向后传递当onRejected不是函数时默认实现是throw reason错误会继续往下抛直到遇到一个能处理它的 catch。这就是值穿透和错误穿透的原理。很多人第一次看到Promise.resolve(1).then().then(v console.log(v))能输出 1 时觉得神奇其实就是默认回调在起作用没有任何魔法。异步派发的位置要选对。我用queueMicrotask包裹回调执行确保 onFulfilled/onRejected 永远在微任务中执行而不是同步执行。即使 promise 已经处于终态时调用 then比如Promise.resolve(1).then(cb)回调也不会同步执行而是在当前调用栈清空后、下一个宏任务开始前执行。这是 Promise/A 规范明确要求的onFulfilled 和 onRejected 必须在执行上下文栈仅包含平台代码时才可被调用。resolvePromise 是链式调用的核心。这一步解决的是回调返回值是普通值、promise 实例、thenable 对象三种情况。如果是普通值直接 resolve如果是 promise要等待它 settle 后再决定如果是 thenable具有 then 方法的对象要当成一种准 promise来跟随状态。一个标准的实现如下resolvePromise(newPromise, result, resolve, reject) { if (newPromise result) { reject(new TypeError(Chaining cycle detected for promise)) return } if (result instanceof MyPromise) { result.then(resolve, reject) } else if (result ! null (typeof result object || typeof result function)) { let then try { then result.then } catch (e) { reject(e) return } if (typeof then function) { try { then.call(result, resolve, reject) } catch (e) { reject(e) } } else { resolve(result) } } else { resolve(result) } }两个边界条件值得注意。第一newPromise result要防止自等待。如果回调内部返回了 then 返回的那个 promise 自身会导致循环等待必须抛 TypeError。这是规范明确要求的行为一眼看上去觉得谁会这么写但在复杂的链式调用中确实可能出现返回了同一个 promise 的引用而导致的循环。第二thenable 的 then 属性在提取时可能抛异常比如 getter 抛出错误所以必须包一层 try/catch。有了链式调用和值穿透Promise 的传动轴就搭起来了。promise.then(...).then(...).catch(...)能一直向后传递数据和错误本质就是每个 then 都返回新 promise每个新 promise 的状态由上一个回调结果决定。5. 实现 Promise.all 与 Promise.race并发组合的配套工具链式调用解决的是串行异步流程实际开发里更常见的需求是并发同时请求多个接口全部完成后统一处理或者多个请求里谁先完成谁先处理。这就是 Promise.all 和 Promise.race 的用武之地。Promise.all 的实现static all(promises) { return new MyPromise((resolve, reject) { const results [] let count 0 for (let i 0; i promises.length; i) { MyPromise.resolve(promises[i]).then((value) { results[i] value count if (count promises.length) { resolve(results) } }, reject) } }) }两个关键点。第一结果数组results必须用下标赋值而不是 push。因为 promise 的 resolve 顺序可能与输入顺序不一致如果使用 push最终数组的顺序就会错乱。我自己第一次实现时就踩过这个坑接口 A 返回得比接口 B 慢push 之后数组顺序就反了排查了很久才发现问题。第二MyPromise.resolve(promises[i])要把每个元素先包装成 promise这样传普通值进来比如[1, Promise.resolve(2)]也能统一处理。all 的失败策略是快速失败一旦任意一个 promise 被拒绝整体立即 reject不会等待其他还在 pending 的 promise。在并发请求场景下这个策略意味着一个失败全部失败。和Promise.allSettled等所有 promise settle 再统一返回不同all 不适合部分成功也想要结果的场景。实际项目里发批量邮件需要统计成功/失败条数时用 allSettled多接口数据必须齐备才能渲染页面时用 all这是最常用的选型依据。Promise.race 的实现static race(promises) { return new MyPromise((resolve, reject) { for (let i 0; i promises.length; i) { MyPromise.resolve(promises[i]).then(resolve, reject) } }) }race 的含义是谁先 settle 谁说了算无论 resolve 还是 reject第一个 settle 的结果就是最终结果。race 最常见的用途是给请求设置超时const timeout (ms) new MyPromise((_, reject) { setTimeout(() reject(new Error(timeout)), ms) }) MyPromise.race([fetchData(), timeout(3000)]).then(handleData, handleTimeout)必须强调一点race 拿到第一个 settle 的 promise 后其他 promise 并不会被取消。它们依旧在后台执行只是结果不再被关心。这个行为经常被误解甚至有人以为 race 可以取消请求。需要真正取消语义时得结合 AbortController 或自定义取消令牌race 本身并不具备取消能力。Promise.allSettled 的补充实现它等所有 promise 都 settle然后返回一个对象数组每个对象包含{ status: fulfilled, value }或{ status: rejected, reason }。实现思路是在内部为每个 promise 都同时挂上 resolve 和 reject 的处理器把结果统一收集后再整体 resolve而不触发整体 reject。在批量任务统计场景里非常实用。6. 用自定义调度器观察微任务执行顺序手写 Promise 的另一个价值点是可以在浏览器里直观地看到微任务到底是怎么排队执行的。原生queueMicrotask是已经封装好的 API但如果自己写一个调度器模拟微任务队列 宏任务队列的协作方式就能真正理解为什么 promise 回调的执行顺序是那样。一个简单但足以演示原理的模拟const microtaskQueue [] const macrotaskQueue [] function microtask(fn) { microtaskQueue.push(fn) drainQueues() } function macrotask(fn) { macrotaskQueue.push(fn) drainQueues() } function drainQueues() { while (microtaskQueue.length 0) { const task microtaskQueue.shift() task() } if (macrotaskQueue.length 0) { const task macrotaskQueue.shift() task() } }这个实现只能说粗糙地演示了核心原则微任务队列总是优先于宏任务队列被清空。真实事件循环的细节远比它复杂但作为教学工具它能帮我们理解一个现象setTimeout 的回调无论设置成 0ms都会排在所有 promise 回调之后执行。试一个经典例子console.log(1 sync) setTimeout(() console.log(2 timeout), 0) MyPromise.resolve() .then(() console.log(3 micro)) console.log(4 sync)执行结果是1 4 3 2。这个结果让很多人困惑过。用我们手写的调度器模拟一下逻辑就非常清晰了同步代码先跑完输出 1 和 4然后 setTimeout 注册的宏任务进入 macrotaskQueuepromise 回调进入 microtaskQueue事件循环先清空微任务队列输出 3最后才轮到宏任务队列输出 2。基于这个观察我总结了一个实际调试中很好用的任务优先级心法同步代码最先执行同步代码里产生的异步任务进入不同队列。当前宏任务完成后事件循环先把微任务队列清空再取下一个宏任务。promise 的 then/catch/finally 都是微任务setTimeout/setInterval/事件回调属于宏任务requestAnimationFrame 属于渲染前任务优先级又不同于这两者。如果微任务里又注册了微任务新微任务会在当前微任务队列里继续执行不会推迟到下一个宏任务。通俗地说微任务队列是加塞优先的队列。只要微任务队列里有东西宏任务就永远排不上。遇到 promise 时序问题先默念这句话再回看代码多数疑惑都能立刻解开。7. 浏览器里的 Promise 报错从原理到排查这部分结合真实报错来讲处理思路完全建立在前面原理的基础之上。很多开发者看到uncaught (in promise)就慌其实这类报错的底层机制非常规律。第一类报错uncaught (in promise) error。这类报错的完整语义是promise 被拒绝但在拒绝发生时没有任何 onRejected 回调来处理它。浏览器会在微任务队列被清空后检查一遍如果存在状态为 rejected 且没有被 catch 链处理的 promise就会向全局抛出错误。处理方式不是想办法屏蔽控制台而是确保所有 promise 链都有 catch 兜底fetchData() .then(processData) .catch((err) { // 处理错误或至少记录日志 })第二类报错unhandled promise rejection typeerror: webassembly.instantiate()。这是异步加载 WebAssembly 模块时的典型错误。WebAssembly.instantiate()返回一个 promise如果资源路径错误、二进制格式不支持、或编译失败这个 promise 就被拒绝。调用方没加 catch就产生了 unhandled rejection。排查思路是先确认 wasm 文件路径和 MIME 类型是否正确再给调用链加上错误处理。同时可以在全局挂一个监听器兜底window.addEventListener(unhandledrejection, (event) { console.error(Unhandled Rejection:, event.reason) })第三类报错failed to execute insertbefore on node。表面上是 DOM 操作错误但根本原因往往是 promise 微任务执行顺序导致的 DOM 状态不一致。典型场景somePromise.then(() { parent.insertBefore(newNode, referenceNode) })如果referenceNode在 promise resolve 之前已经被其他逻辑从 DOM 中移除insertBefore就会抛错。这类问题的根源是异步回调执行时代码不能假定 DOM 中的引用节点仍然存在。解决手段是操作前先检查节点是否存在或者把 DOM 操作纳入统一的数据流管理从源头上避免数据已更新、视图节点已卸载的竞态。第四类报错a listener indicated an asynchronous response b。这是浏览器扩展编程中常见的报错chrome.runtime.onMessage的监听器返回 promise或显式调用 sendResponse时与事件监听器的生命周期管理不匹配导致的。本质上也是 promise 的使用边界问题监听器返回 promise 后必须在 promise 解析前保持消息通道打开否则就会报异步响应错误。这些报错场景其实都可以归结为同一个核心问题promise 的生命周期与调用方其他状态DOM 节点、消息通道、资源加载状态之间没有协调好。理解 promise 的底层机制后看到这类报错就不会再表面修 bug而是直接往根上找我的 promise 链是不是缺 catch我的异步操作是否和别的状态存在竞态我的监听器生命周期是否覆盖了 promise 的执行时间这种思维转变才是真正吃透Promise 的标志。8. 面试答辩大概率被追问的 12 个 Promise 高频问题手写 Promise 常出现在课程答辩或前端面试里这一部分可以直接当题库用。每个问题都不只是记忆题背后是实打实的原理理解。Promise 和回调函数相比到底好在哪三个维度可链式表达、错误捕获统一、状态不可逆减少竞态。但也要承认回调在部分场景仍有优势比如事件多次触发、超时取消promise 是对回调的改良而非全面取代。为什么 then 要返回一个新的 promise 而不是 this因为 promise 状态不可逆。如果返回 thisthen 之后整个链的状态就固定了无法继续派生新状态。返回新 promise 才能实现每一次 then 都开启新的生命周期。resolve 一个 promise 会发生什么当前 promise 会跟随这个 promise等内部 promise settle 后再决定自己的状态。promise 的回调是微任务还是宏任务微任务。具体执行发生在当前宏任务结束后的微任务阶段。为什么 promise 执行顺序看起来不稳定因为回调的注册时机和执行时机是分离的。注册早不代表执行早实际执行还取决于当前事件循环的队列状态。怎么强制一个 promise 进入 rejected调用 reject或在 executor 和回调中抛出异常。all 和 allSettled 的区别all 快速失败allSettled 等待所有 settle。需要部分成功也算数时不要用 all。race 可以取消请求吗不能。它只是决定谁先 settle 谁生效未决 promise 仍会执行结果不再被关心。什么是 thenable不是 promise 实例但具有 then 方法的对象。resolvePromise 会兼容这类对象这也是不同 promise 实现之间能互操作的基础。unhandledrejection 和 rejectionhandled 事件有什么用分别监听未被处理的拒绝和之后才补上的拒绝处理。调试阶段很有用生产环境建议配合自动上报。手写 Promise 最容易忽略什么回调的异步派发用微任务包裹、resolvePromise 的边界情况自等待、thenable 提取异常、then 回调缺失时的默认行为。这三点恰恰是规范测试里最容易挂的用例。Promise 的微任务实现规范和浏览器行为有差异吗Promise/A 规范只要求异步执行没有强制指定微任务还是宏任务。现代浏览器和 Node.js 都统一使用微任务所以行为一致。理解规范未定、引擎自选的关系对分析跨平台差异很有帮助。把这 12 个问题逐一看懂、能用自己的话解释清楚手写 Promise 就不再是背代码而是真正内化了这套机制。我个人的体会是写一遍实现胜过看十遍文档。遇到异步问题时你会比其他同事更快定位到问题层面因为你脑子里有一套完整的状态机 队列调度模型。这也是为什么我一直建议大家即使工作里完全用不上手写 Promise也一定要亲手实现一次这笔投入的回报率高得惊人。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑