你有过这种瞬间吗console.log一个 Promise打印出来是pending一眨眼又变成fulfilled面试官冷不丁问一句then是怎么实现链式调用的或者线上监控弹出一条uncaught (in promise) error: a listener indicated an asynchronous response b你顺着堆栈追了半天最后发现是某个事件回调里返回的 Promise 没人接住。这些瞬间我都经历过。与其继续靠猜不如自己动手写一个于是有了这个项目myPromise.js。这篇文章记录我手写 Promise 的完整过程。目标不是写一个“能跑就行”的玩具而是尽量贴近 Promises/A 规范在核心行为上和原生 Promise 对齐再补齐all、race、allSettled这些常用 API。适合两类人看一类是想彻底搞懂 then 链和状态流转的开发者另一类是面试前想快速理清 Promise 原理、但又不想背八股答案的同行。1. 为什么要把 Promise 拆开写一遍1.1 用 Promise 的人和写 Promise 的人差在哪日常用then、catch、await其实只接触到 Promise 的“外壳”。状态是怎么切的、回调是什么时候入队的、为什么 then 里返回一个 Promise 还能继续往下链这些全靠运行时帮你扛住了。等你真的去看 V8 或者core-js的实现又会发现代码里满是resolver、capability、thenable这些术语直接劝退。手写的价值在于你可以把 Promise 简化成一个自己能完全掌控的状态机。每一行代码都是自己写出来的pending到fulfilled的路线、then注册回调的时机、微任务执行的顺序全都在脑子里有了明确画面。之后在真实项目里遇到“Promise 没有被接住”、“回调顺序不对”这类问题不需要靠猜直接能推断出是哪一环出了问题。1.2 目标定在 Promises/A 而不是最新规范Promise 相关的规范有好几层。ECMAScript标准包含完整的内置 Promise 对象还有catch、finally、allSettled、any这些方法。而Promises/A是一份只关注“thenable 行为”的精简规范它只定义了状态迁移、then方法、resolvePromise流程不要求实现任何静态方法。我把myPromise.js的主目标定在 Promises/A 兼容上原因很简单这是 Promise 的地基。只要 this 的流转符合规范后续所有人工 API 都是锦上添花。官方也提供了测试套件promises-aplus-tests能跑一千个左右的用例跑通之后再去扩展all和race会非常有底气。1.3 最终交付的文件长什么样我给自己定的清单是这样的一个类六个实例方法四个静态方法全部放入单个src/myPromise.jsclass MyPromise { constructor(executor) {} then(onFulfilled, onRejected) {} catch(onRejected) {} finally(handler) {} static resolve(value) {} static reject(reason) {} static all(iterable) {} static race(iterable) {} static allSettled(iterable) {} }先搭骨架再逐个填肉。下面的每一章就是我当时填充的顺序。2. 先搭状态机把两种结局定死在规则里2.1 三个状态与两个存储位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 []; } }为什么状态要用字符串常量而不是布尔值因为status会被打印、被序列化、被调试工具展示字符串一眼能看出当前状态而两个布尔值互相组合容易产生非法状态。为什么需要value和reason两个字段因为then注册回调的时候Promise 可能已经落定了必须把结果存下来等回调执行的时候再把值传出去。回调数组则是为了pending期间多次调用then的情况比如p.then(a); p.then(b);两个回调都要存起来在状态变化时按注册顺序依次执行。2.2 executor 同步抛错必须在构造阶段吃掉executor是用户传给构造函数的那个函数。原生 Promise 的行为是如果executor内部抛异常这个 Promise 直接被reject。所以构造函数里必须用try/catch包住executor调用constructor(executor) { // ...初始化字段... const resolve (value) { if (this.status ! PENDING) return; this.status FULFILLED; this.value value; this.onFulfilledCallbacks.forEach((cb) cb(this.value)); this.onFulfilledCallbacks []; }; const reject (reason) { if (this.status ! PENDING) return; this.status REJECTED; this.reason reason; this.onRejectedCallbacks.forEach((cb) cb(this.reason)); this.onRejectedCallbacks []; }; try { executor(resolve, reject); } catch (err) { reject(err); } }这里有一个新手极容易踩的坑如果不做this绑定当用户写成executor(resolve, reject)且内部直接调用resolve(1)时没问题但如果用户把resolve拆出来单独调用this会丢失。我在后续版本里统一用箭头函数或bind处理resolve/reject确保它们作为独立函数被传递时依然指向当前实例。2.3 resolve/reject 的幂等保护Promise 状态一旦从pending变成fulfilled或rejected就永久定型。这个特性在代码里体现为每次resolve/reject开头的这道检查if (this.status ! PENDING) return;没有这道检查会发生什么resolve(1); resolve(2);会把后注册的回调用第二个值触发第一个回调已经执行完但状态被覆盖各种诡异问题都会冒出来。加上之后任何第二次resolve、resolve之后再reject、reject之后再resolve全部被静默忽略。2.4 回调数组的收集逻辑onFulfilledCallbacks和onRejectedCallbacks在pending期间收集回调在状态切换时一次性清空。为什么执行完后要重新赋值为空数组因为大概率不会有新的回调再进入这个数组了及时释放引用避免数组越长越大算是一个小优化习惯。3. then 方法才是真正的工程现场3.1 为什么 then 必须返回一个新 Promisethen的核心语义是注册两个回调等当前 Promise 落定后异步执行然后返回一个新 Promise新 Promise 的落定结果由回调的返回值决定。如果你第一版写的是“在then里直接把回调推进this的回调数组”那就丢掉了一样最要命的东西——链式能力。p.then(...).then(...)能继续往下传值是因为第一个then返回了一个新的 Promise第二个then是调用在这个新 Promise 上的。如果then返回this链式就没法串起来状态也被锁死所以规范明确要求返回新 Promise。3.2 resolvePromise 递归普通值、Promise、thenable 三路分发这是整个myPromise.js最核心的函数。它处理“回调返回值x应该如何落到新 Promisepromise2上”的问题function resolvePromise(promise2, x, resolve, reject) { if (promise2 x) { reject(new TypeError(Chaining cycle detected for promise)); return; } if (x instanceof MyPromise) { if (x.status PENDING) { x.then((y) resolvePromise(promise2, y, resolve, reject), reject); } else if (x.status FULFILLED) { resolvePromise(promise2, x.value, resolve, reject); } else { reject(x.reason); } return; } if (x ! null (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 (err) { if (called) return; called true; reject(err); } return; } resolve(x); }几个关键点第一层判断处理“回调传回来的就是promise2自己”的循环引用。比如const p promise.then(() p)如果不去拦截这个 Promise 永远等自己落定陷入死循环所以直接给TypeError。第二层判断处理我自己的MyPromise实例。如果是pending等它落定后再递归展开如果已经fulfilled把它的value再交给resolvePromise因为value本身可能还是 thenable如果已经rejected直接把reason抛出去。第三层判断是 A 规范最核心的thenable吸收逻辑。任何对象或函数只要它有then方法就把它当成 Promise 来处理。这一步是“鸭子类型”的体现也是为什么很多跨库 Promise 能互相resolve的原因。called标志是为了防止 thenable 的回调被多次调用。有些实现不规范的库可能同时调了resolve和reject或者先调了resolve然后又抛异常我们只认第一次结果。3.3 then 主体值穿透与微任务调度有了resolvePromisethen方法就清晰了then(onFulfilled, onRejected) { const realOnFulfilled typeof onFulfilled function ? onFulfilled : (value) value; const realOnRejected typeof onRejected function ? onRejected : (reason) { throw reason; }; const promise2 new MyPromise((resolve, reject) { const handle (callback, value) { queueMicrotask(() { try { const x callback(value); resolvePromise(promise2, x, resolve, reject); } catch (err) { reject(err); } }); }; if (this.status FULFILLED) { handle(realOnFulfilled, this.value); } else if (this.status REJECTED) { handle(realOnRejected, this.reason); } else { this.onFulfilledCallbacks.push((value) handle(realOnFulfilled, value)); this.onRejectedCallbacks.push((reason) handle(realOnRejected, reason)); } }); return promise2; }realOnFulfilled和realOnRejected是在做“值穿透”。如果你写promise.then().then((v) ...)中间的then()没有给onFulfilled那上一个值要原封不动往下传如果省略了onRejected拒绝原因也要原封不动地让下一个then收到否则错误就凭空消失了。这里onRejected的默认实现是throw reason这样拒绝状态才能继续沿着链往下传播。为什么要用queueMicrotask而不是直接同步调用下一章细讲。3.4 一条链是怎么串起来的拿一个最简单的例子走一遍const p new MyPromise((resolve) resolve(1)); p.then((v) { console.log(v); // 1 return Promise.resolve(v 1); }).then((v) { console.log(v); // 2 });第一次then返回的新 Promise 记为promise2。当v 1被回调处理后返回值是一个原生 Promise。resolvePromise看到它是一个 thenable于是调它的then等它落定后把2作为promise2的结果。第二次then注册在promise2上自然拿到2。整条链就是在这样不断的then注册和resolvePromise递归中串起来的。4. 微任务时序setTimeout 的坑和 queueMicrotask 的选择4.1 异步是要求但“异步”不等于宏任务我在第一版里偷懒直接用setTimeout(callback, 0)模拟异步。跑 A 测试能过一大半但行为上和原生 Promise 有明显偏差。Promises/A 规范对回调时机的要求是“onFulfilled或onRejected不能在执行上下文栈只包含平台代码之前被调用。”简单说回调必须等到当前脚本跑完、调用栈清空之后再执行。浏览器里“调用栈清空后最先执行”的就是微任务队列而setTimeout是宏任务排在渲染、IO 等事件的后面。用setTimeout会让回调明显变慢半拍中间还夹着渲染帧造成可感知的卡顿。4.2 我实测两种方案的行为差异写过一个小实验把 1000 个.then串起来分别用queueMicrotask和setTimeout版本跑。queueMicrotask版本所有回调都会在同一个“脚本结束后的微任务清空阶段”连续执行完总耗时在个位数毫秒级setTimeout版本因为每一环都重新排一个宏任务浏览器里最短延迟约 4msNode 里约 1ms总耗时稳定差出几十倍。更明显的差异是顺序。看这段代码const p new MyPromise((resolve) resolve(1)); p.then(() console.log(promise callback)); setTimeout(() console.log(timeout));原生 Promise 里永远先打印promise callback再打印timeout。如果myPromise.js用了setTimeout两个回调就变成了同一级别的宏任务顺序完全依赖谁先注册行为立刻不可控。4.3 老环境的降级方案queueMicrotask是相对较新的 API核心的降级方案是MutationObserver再不行才退到setTimeout。我当时写了个兼容函数const asyncFn typeof queueMicrotask function ? queueMicrotask : typeof MutationObserver function ? (cb) { const node document.createTextNode(); new MutationObserver(() cb()).observe(node, { characterData: true }); node.data x; } : (cb) setTimeout(cb, 0);不过这类兼容代码我个人只在要发布成公共库的时候才会加。自己学习用myPromise.js直接queueMicrotask就好先把核心逻辑搞明白比兼容老环境更有价值。5. 错误传播与 unhandledrejection从控制台报错反推实现5.1 控制台里的 uncaught in promise 是怎么产生的现在再回看热词里那个报错uncaught (in promise) error: a listener indicated an asynchronous response b。这类告警的本质是某个 Promise 被 reject而且一直到微任务队列清空都没有任何一个then或catch接住它。宿主环境检查到“这个 Promise 被拒绝了但没人处理”于是触发全局unhandledrejection事件控制台打出Uncaught (in promise)。在myPromise.js里复现一下new MyPromise((_, reject) reject(oops))然后不调用任何then/catch。浏览器会报告未处理的拒绝Node 里process.on(unhandledRejection)也能收到。这说明拒绝状态的传播不是“发生了就完事”而是必须有人消费否则就是异常。5.2 在 myPromise.js 里主动监控未处理的拒绝调试的时候我习惯在业务入口挂一个全局监听专门看有没有漏接的拒绝window.addEventListener(unhandledrejection, (event) { console.error(未接住的 rejection:, event.reason); debugger; });Node 环境则用process.on(unhandledRejection, (reason) { console.error(未接住的 rejection:, reason); });配合myPromise.js排查时我会先在then的handle里临时加日志打印当前状态和值这样能立刻看出是哪个 Promise 一直保持rejected没人处理。真实项目里这类问题往往是“回调里创建了 Promise 但 return 出去”断言接住和实际接住是两码事。5.3 “listener indicated an asynchronous response b”这类告警与 Promise 的关联如果你用过事件总线、事件监听器或者某种消息订阅框架可能见过类似的告警监听器回调明明没有返回什么东西框架却提示“监听器指示了一个异步响应”。这通常发生在框架约定回调可以返回 Promise、但某个监听函数真的返回了 Promise 的场景。发布端如果不消费这个返回的 Promise一旦它 reject就成了无主拒绝随后被宿主环境上报为unhandledrejection。这和手写 Promise 有什么关系关系在于当你把myPromise.js接到这类事件系统里时可以清晰地看到“谁创建 Promise、谁负责接住 reject”。我会在回调返回 Promise 的地方显式补一个.catch或者await让错误的归属变得明确。手写过一遍之后你再看到这类告警会条件反射地去找“哪个 Promise 没人消费”。5.4 catch 和 finally 的实现catch本质就是then的语法糖catch(onRejected) { return this.then(undefined, onRejected); }finally则有两个注意点无论上一步是成功还是失败它都要执行但它不能吞掉原始结果。我的实现是这样finally(handler) { return this.then( (value) MyPromise.resolve(handler()).then(() value), (reason) MyPromise.resolve(handler()).then(() { throw reason; }) ); }如果handler返回一个 Promise必须等它落定再把原来的value或reason透传下去。这里就能看出resolvePromise的威力MyPromise.resolve(handler())会把 any 值统一成 Promise省掉大量判断。6. 静态方法补齐resolve、reject、all、race 的边界设计6.1 Promise.resolve 的 thenable 归一化静态方法不依赖实例但它们大量复用实例的流转能力。resolve的作用是把任意值变成一个MyPromisestatic resolve(value) { if (value instanceof MyPromise) return value; return new MyPromise((resolve, reject) { if (value ! null (typeof value object || typeof value function)) { try { const then value.then; if (typeof then function) { then.call(value, resolve, reject); return; } } catch (err) { reject(err); return; } } resolve(value); }); }如果传入的已经是MyPromise实例直接返回避免包一层如果传入的是原生 Promise 或任意 thenable则套一个MyPromise并让then驱动resolve/reject。这段逻辑说白了就是把“吸收 thenable”的能力从resolvePromise里复用到了静态方法层面。6.2 Promise.all 的快速失败与顺序保持all要等数组里所有项都落定最终输出一个数组且输出顺序和输入顺序一致。这看起来容易坑也在“顺序一致”上static all(iterable) { return new MyPromise((resolve, reject) { const items Array.from(iterable); if (items.length 0) { resolve([]); return; } const results new Array(items.length); let remaining items.length; items.forEach((item, index) { MyPromise.resolve(item).then( (value) { results[index] value; remaining--; if (remaining 0) resolve(results); }, reject ); }); }); }我见过很多简化版all用results.push(value)这在所有 Promise 同速时看着是对的但只要有一个异步慢一些输出顺序就乱了。用索引直接写入再用一个计数器判断是否完成才能保证结果顺序。快速失败则通过把reject作为每个子项的onRejected直接实现任何一个子项拒绝总 Promise 立刻拒绝不需要等其他人。6.3 Promise.race 的空数组时序race的语义是“谁先落定听谁的”static race(iterable) { return new MyPromise((resolve, reject) { for (const item of iterable) { MyPromise.resolve(item).then(resolve, reject); } }); }很多人以为空数组会resolve([])实际上不会。没有子项就意味着没有赢家这个 Promise 会一直卡在pending。测试时如果遇到race([])卡住不要怀疑代码这是规范行为。allSettled我在项目里也顺手补了它不快速失败而是把每个子项的结果都收集起来成功项给{ status: fulfilled, value }失败项给{ status: rejected, reason }。这一步是在all的基础上把onRejected改成正常返回结构化对象代码很短但设计意图和all的“快速失败”形成了鲜明对照。7. 用 Promises/A 官方测试套件验证 myPromise.js7.1 如何导出 adapter手写代码不能只靠“我觉得对”要跑官方测试。promises-aplus-tests需要一个 adapter也就是一个导出固定格式的对象// adapter.js const MyPromise require(./src/myPromise.js); module.exports { resolved(value) { return MyPromise.resolve(value); }, rejected(reason) { return MyPromise.reject(reason); }, deferred() { let resolve, reject; const promise new MyPromise((res, rej) { resolve res; reject rej; }); return { promise, resolve, reject }; } };然后在package.json里配一行脚本直接跑npx promises-aplus-tests adapter.js如果全部通过终端会输出类似872 passing这样的结果。我第一次跑的时候挂了几十个用例当时的表情基本是“啊这也能挂”。7.2 我跑挂过的三个用例及修复第一个挂点是 A 规范 2.2.4onFulfilled和onRejected必须在当前执行栈清空后才执行。我第一版在resolve里直接同步调用了回调测试断言在then之后立即执行的代码优先于 Promise 回调结果顺序反了。修复方式就是第 4 章讲的把回调放进queueMicrotask。第二个挂点是 2.2.7then的返回值如果是 Promise它要被递归展开。我早期在resolvePromise里看到x instanceof MyPromise就直接x当作结果 resolve忽略了x.value本身可能是 thenable 的情况。修复后我在分支里又做了一层递归。第三个挂点是 2.3.3 系列thenable 的then属性可能在取值时抛异常也可能重复调用回调。这就是called标志存在的原因。没有它某些不守规矩的 thenable 会同时触发resolve和reject导致 Promise 状态被覆盖两次测试立刻报错。7.3 测试通过后的最后一步代码瘦身A 全部通过只代表“规范层面没问题”但代码里全是调试日志。我最后做了三件事删掉所有console.log、统一asyncFn命名、把重复分支收进小型工具函数。测试通过只是开始让代码保持可读、可维护才是长期价值。至于catch、finally、allSettled这些扩展 API官方 A 套件不管我会额外写一组针对业务场景的手工用例比如“finally不吞掉原始失败原因”、“race([])保持 pending”、“all输出顺序与输入一致”等确保扩展部分行为可靠。现在手头这版myPromise.js还留了一个日志开关调试时打开能打印每条 then 的序号、值和耗时。如果你也想写一个我的建议是别直接看源码先自己把then的坑踩一遍然后跑 A 测试看报错、修代码这个过程比读十篇源码解析都有用。等你的版本通过测试之后再回过头看 V8 里原生 Promise 的实现视角会完全不一样。