ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写Promise:从状态机到微任务调度,彻底搞懂异步核心

手写Promise:从状态机到微任务调度,彻底搞懂异步核心 先说一个我自己的翻车经历。那天下午告警群突然刷屏日志里全是Uncaught (in promise) error: A listener indicated an asynchronous response。我第一反应是某个第三方 SDK 又出幺蛾子了翻了大半天代码最后发现是我自己在事件监听器里返回了一个 Promise而这个 Promise 在某个分支里直接 reject 了后面又没人兜底。这个报错文案看着很唬人背后其实就一句话Promise 链断了。后来我实在不想每次都被这种报错追着跑就花了一个晚上把 Promise 从零手写了一遍。写完之后我才发现简单手写 Promise这件事远没有字面上那么简单但它又是前端异步体系里最值回票价的一次练习。如果你能把这篇文章里的内容完整过一遍后面再看 async/await、事件循环、微任务队列都会顺畅很多。这篇文章适合几类人准备面试、想把 Promise 原理讲清楚的开发者在项目里被各种异步报错折磨、想彻底搞懂then到底干了什么的人以及想用官方 Promise/A 测试套件验证自己写法的极客。全文从状态机开始一步步拆到resolvePromise递归解析最后给出一份能通过官方测试的完整实现。1. 为什么非要把 Promise 手写一遍很多人觉得 Promise 不就是new Promise加.then吗会调不就行了吗但你会发现一旦问题变成下面这几个会调用和会实现之间立刻拉开差距then返回的是什么为什么可以一直.then().then().then()为什么then里的回调永远不会同步执行resolve一个 Promise 对象时到底发生了什么promise.then().catch()里没有传成功回调为什么原值还能透传下去这些问题靠背 API 是答不出来的但如果你亲手实现一遍每个问题都会变成代码里的一个具体分支。我当时给自己定的目标很简单不参考任何源码先按朴素的思路写一个能用的 Promise再对照 Promise/A 规范一条一条修正最后用官方测试套件验证。这个流程走下来我对异步调度的理解比看十篇源码分析都管用。回调地狱时代的代码长这样A 完成之后调 BB 完成之后调 C层级一深缩进直接把人劝退。Promise 解决的不只是缩进问题它把异步结果变成了一个可以被传递、被组合、被存储的对象。这就是我常说的Promise 本质上是一个带状态机的订阅发布系统——状态机决定结果什么时候确定订阅发布决定回调什么时候触发。所以手写 Promise 的时候只需要聚焦三件事状态机、异步调度器、递归解析器。这三块拼起来Promise 的主体就出来了。2. 拆开 Promise 的结构一个只有三个状态的机器2.1 红绿灯式的状态迁移Promise 内部状态不多只有三个pending、fulfilled、rejected。我用红绿灯来理解它pending是亮黄灯事情还没定论fulfilled是绿灯一切正常rejected是红灯出问题了。最关键的一条规则是黄灯一旦变成绿灯或者红灯就再也不能变回去了。状态迁移只有两条路当前状态触发条件迁移结果pending调用 resolve(value)fulfilledpending调用 reject(reason)rejectedfulfilled / rejected任何再次调用不迁移这个终态不可逆的设计太重要了。它保证了 Promise 的结果一定是稳定的不管代码里调用多少次 resolve外部拿到的永远是第一次的结果。我见过有人把 promise 理解成某种变量但实际上它更像一个一次性开关拨下去就卡死。2.2 resolve 和 reject 只是两个拨动开关的函数实现的第一步是让构造函数能够接收一个executor函数并且在里面同步执行它。注意同步执行这四个字这是新手最容易忽略的点。new Promise(() console.log(executor)); console.log(after)打印出来的顺序永远是executor再after因为 Promise 构造函数内部会立刻调用executor(resolve, reject)。这个设计是为了让 executor 里的同步初始化逻辑能马上执行错误也能同步被捕获。此时代码骨架是这样的function MyPromise(executor) { this.status pending; this.value undefined; this.reason undefined; this.fulfillCallbacks []; this.rejectCallbacks []; const self this; function resolve(value) { if (self.status ! pending) return; // 这里不能直接置为 fulfilled因为 value 可能还是一个 Promise resolvePromise(self, value); } function reject(reason) { if (self.status ! pending) return; self.status rejected; self.reason reason; notifyTasks(self.rejectCallbacks); } try { executor(resolve, reject); } catch (e) { reject(e); } }executor里抛出的同步异常会在构造函数内部被try/catch捕获然后走reject流程。所以我的实现里从来不需要在业务代码里给new Promise(...)额外套 try/catch因为构造函数已经兜住了 executor 的异常这一点和原生 Promise 完全一致。2.3 then 的本质是注册回调当状态还是pending的时候.then(handleFulfilled, handleRejected)不会立刻执行任何回调而是把这两个回调分别塞进fulfillCallbacks和rejectCallbacks队列里。等到某个时刻resolve或reject被调用状态一迁移就立刻通知对应队列里的回调开始工作。这个模型特别像一场抽奖then是在开奖前买彩票resolve/reject是开奖瞬间。买彩票的时机可以在开奖前也可以在开奖后但开奖结果一旦公布状态就锁定。后面我会把then的完整逻辑补上这里先建立意识Promise 的 then 不是调用回调而是登记回调。3. then 的异步调度为什么回调永远不立刻跑3.1 规范里的 2.2.4 到底在要求什么如果你只用过 Promise可能觉得 then 里的回调就是异步执行的。没错Promise/A 规范 2.2.4 明确要求onFulfilled和onRejected只有在执行上下文栈只包含平台代码时才能调用。翻译成人话就是then 里的回调必须延后到当前同步代码跑完之后再执行不能在当前调用栈里同步弹出。为什么必须这么干我一开始也以为是规范闲着没事。后来用同步思路实现了一遍发现两个严重问题。第一执行顺序反直觉。如果 then 回调是同步的那么const p new MyPromise((resolve) resolve(1)); p.then(() console.log(first)); console.log(second);会打出first再second。但原生 Promise 不是这样它打出的是second再first。所有 async/await 之上的代码逻辑都默认注册回调不等于执行回调这个约束一旦打破写出来的异步代码顺序会乱成一锅粥。第二链式递归容易爆栈。如果回调同步执行那么then里的resolve又会触发后续then的回调同步执行一层套一层几百次链式调用就能把调用栈打爆。所以规范把异步执行设为硬性条件这是为了保证 Promise 链的递归是在任务队列里展开而不是在调用栈里展开。3.2 调度器选型queueMicrotask 是首选知道了要异步执行下一步就是选调度方式。我在实现里按优先级使用了三种方案queueMicrotask浏览器和 Node 现代环境都支持是标准微任务和原生 Promise 行为最接近MutationObserver老浏览器环境下的微任务降级方案浏览器渲染前会清空微任务队列setTimeout最后的兜底方案但只能算宏任务时序上和原生 Promise 有明显的偏差。为什么不用setTimeout当主力我实际踩过坑。有一次图省事调度直接用setTimeout(fn, 0)然后拿原生 Promise 和手写 Promise 混合跑结果出现了一个怎么也解释不通的现象原生 Promise 的回调排在了前面手写 Promise 的 then 回调却跑到了后面。原因很直观——原生 Promise 的回调是微任务setTimeout是宏任务如果前面已经堆了一堆微任务那么一个宏任务要等所有微任务清空之后才轮到。当时我盯了好久 Chrome 的火焰图才反应过来这些时序怪癖会成为排查异步 bug 时的隐形炸弹。所以我最终的调度器长这样function schedule(fn) { if (typeof queueMicrotask function) { queueMicrotask(fn); return; } if (typeof MutationObserver function) { const observer new MutationObserver(() { observer.disconnect(); fn(); }); const node document.createTextNode(); observer.observe(node, { characterData: true }); node.data String(Date.now()); return; } setTimeout(fn, 0); }3.3 一个时序实验验证调度对不对写了调度器之后我做了个小实验把手写 Promise 和原生 Promise 混在一起注册回调const p new MyPromise((resolve) resolve(1)); p.then(() console.log(a)); p.then(() console.log(b)); Promise.resolve().then(() console.log(c));原生环境使用微任务调度下输出是a b c因为p.then的注册顺序在前。但如果用setTimeout调度输出很可能会变成c a b因为原生微任务c会插在宏任务a b之前。这个实验背后透露的信息很关键Promise 的实现必须尊重微任务队列的全局顺序否则在复杂异步流程里会引发各种诡异的时序问题。4. 链式调用的心脏resolvePromise 递归解析器4.1 then 必须返回一个全新的 Promise手写 Promise 时最容易让人绕进去的一个点就是then的返回值。规范其实说得很清楚then必须返回一个新的 Promise不能返回当前 promise 本身。我当时不理解为什么要新建一个呢直接返回this不行吗试一下就知道。如果我返回this那链式调用里的状态、值、回调队列全被同一个 promise 共享第一个 then 里的回调函数要驱动后面所有 then 的回调状态机的一对一关系直接被打破。而且 Promise/A 还要求一旦执行了 resolve状态不能再变如果 then 返回自身后续resolve会直接失效整个链式调用没法继续。新建一个 promise 之后每个 then 都有了自己独立的status、value和回调队列。前一个 promise 的回调计算结果会被交给新的 promise 去 resolve这样链条才能一节一节往下走。4.2 解析器要处理四种情况resolve(value)之后不能直接value存起来然后置为 fulfilled。因为 value 可能是普通值也可能是 Promise还可能是带then方法的 thenable 对象。所以 Promise 里有一个核心工具函数resolvePromise(promise, x)它负责把各种形态的x统一解包成最终值。我把它理解成一个翻译官x 的类型处理方式普通值直接作为最终结果promise 置为 fulfilled手写/原生 Promise 实例等待它 settled结果跟随它任意 thenable 对象拿到它的 then 方法调用 then 并递归解析promise 自身直接 reject抛出 Chaining cycle detected 错误这四种情况缺一不可。尤其是 thenable比如{ then(resolve) { resolve(42); } }它虽然不是 Promise 实例但只要长着 then 的外形Promise 也会老老实实等它的结果。这个特性是 Promise 能互操作的基石也是很多库能实现 thenable 协议的原因。4.3 called 标志一次且只一次在解析 thenable 的时候有个非常隐蔽的坑。一个 thenable 的then方法可能是同步调用 resolve也可能是异步调用甚至可能同时调用 resolve 和 reject。规范要求只有第一次调用生效。于是我加了一个局部变量called作为保险丝let called false; try { then.call( x, (y) { if (called) return; called true; resolvePromise(promise, y); }, (r) { if (called) return; called true; promise.status rejected; promise.reason r; notifyTasks(promise.rejectCallbacks); } ); } catch (e) { if (!called) { called true; promise.status rejected; promise.reason e; notifyTasks(promise.rejectCallbacks); } }你可以想象成一把只能扣一次的扳机不管是哪个回调先到达只要已经响过一次后面的统统忽略。再加上外层resolve函数开头就有self.status ! pending的守卫双层保险之下Promise 的状态就不会被重复改写。4.4 循环引用检测还有一个必须处理的边界promise把自己resolve给了自己。比如const p new MyPromise((resolve) { setTimeout(() resolve(p), 0); }); p.catch((e) console.log(e.message));如果不做检测resolvePromise 会一直等待 p 完成而 p 又在等自己完成死等到底。规范要求此时直接 reject 一个 TypeError。检测代码只需要放在解析器最前面if (promise x) { const typeError new TypeError(Chaining cycle detected for promise #Promise); promise.status rejected; promise.reason typeError; notifyTasks(promise.rejectCallbacks); return; }这里我还想提醒一个细节取x.then的时候要用 try/catch 包起来。因为then属性可能是 getter访问它时如果抛出异常整个解析器不能直接崩溃而要把异常变成拒绝原因。这个场景在真实项目里很少见但测试套件专门有对应的用例也解释了为什么规范要这么较真。5. 完整实现与官方测试套件验证5.1 一份能跑通测试的完整实现前面几节拆散了各个模块现在把它们拼成一份完整实现。这份代码不算长但每一步都对着 Promise/A 规范来的use strict; const PENDING pending; const FULFILLED fulfilled; const REJECTED rejected; function schedule(fn) { if (typeof queueMicrotask function) { queueMicrotask(fn); return; } if (typeof MutationObserver function) { const observer new MutationObserver(() { observer.disconnect(); fn(); }); const node document.createTextNode(); observer.observe(node, { characterData: true }); node.data String(Date.now()); return; } setTimeout(fn, 0); } function isFunction(fn) { return typeof fn function; } function notifyTasks(callbacks) { const copy callbacks.slice(); callbacks.length 0; copy.forEach((callback) schedule(callback)); } function resolvePromise(promise, x) { if (promise.status ! PENDING) return; if (promise x) { const typeError new TypeError(Chaining cycle detected for promise #Promise); promise.status REJECTED; promise.reason typeError; notifyTasks(promise.rejectCallbacks); return; } if (x ! null (typeof x object || isFunction(x))) { let then; try { then x.then; } catch (e) { promise.status REJECTED; promise.reason e; notifyTasks(promise.rejectCallbacks); return; } if (isFunction(then)) { let called false; try { then.call( x, (y) { if (called) return; called true; resolvePromise(promise, y); }, (r) { if (called) return; called true; promise.status REJECTED; promise.reason r; notifyTasks(promise.rejectCallbacks); } ); } catch (e) { if (!called) { called true; promise.status REJECTED; promise.reason e; notifyTasks(promise.rejectCallbacks); } } return; } } promise.status FULFILLED; promise.value x; notifyTasks(promise.fulfillCallbacks); } function MyPromise(executor) { if (!isFunction(executor)) { throw new TypeError(Promise resolver executor is not a function); } this.status PENDING; this.value undefined; this.reason undefined; this.fulfillCallbacks []; this.rejectCallbacks []; const self this; function resolve(value) { if (self.status ! PENDING) return; resolvePromise(self, value); } function reject(reason) { if (self.status ! PENDING) return; self.status REJECTED; self.reason reason; notifyTasks(self.rejectCallbacks); } try { executor(resolve, reject); } catch (e) { reject(e); } } MyPromise.prototype.then function (onFulfilled, onRejected) { const self this; onFulfilled isFunction(onFulfilled) ? onFulfilled : (v) v; onRejected isFunction(onRejected) ? onRejected : (e) { throw e; }; const nextPromise new MyPromise((resolve, reject) { function handleFulfilled() { try { const x onFulfilled(self.value); resolvePromise(nextPromise, x); } catch (e) { reject(e); } } function handleRejected() { try { const x onRejected(self.reason); resolvePromise(nextPromise, x); } catch (e) { reject(e); } } if (self.status FULFILLED) { schedule(handleFulfilled); } else if (self.status REJECTED) { schedule(handleRejected); } else { self.fulfillCallbacks.push(handleFulfilled); self.rejectCallbacks.push(handleRejected); } }); return nextPromise; }; MyPromise.prototype.catch function (onRejected) { return this.then(undefined, onRejected); }; MyPromise.resolve function (value) { if (value instanceof MyPromise) { return value; } return new MyPromise((resolve) resolve(value)); }; MyPromise.reject function (reason) { return new MyPromise((resolve, reject) reject(reason)); };这段代码里最关键的其实是then方法中那层看似多余的包装handleFulfilled里拿到了onFulfilled的返回值然后用resolvePromise(nextPromise, x)去决定下一个 promise 的状态。这样一来onFulfilled不管是返回普通值、返回 Promise还是干脆抛错都能被统一接住链式调用才能正确传导。5.2 适配 promises-aplus-tests 并跑测试手写实现不能光靠自己觉得对得让官方测试套件来挑刺。Promise/A 官方测试套件是promises-aplus-tests它要求提供一个 adapter 对象包含三个字段resolved、rejected、deferred。我写了这样一个适配器const MyPromise require(./my-promise); module.exports { resolved: function (value) { return MyPromise.resolve(value); }, rejected: function (reason) { return MyPromise.reject(reason); }, deferred: function () { const dfd {}; dfd.promise new MyPromise((resolve, reject) { dfd.resolve resolve; dfd.reject reject; }); return dfd; } };然后安装并运行npm install promises-aplus-tests --save-dev npx promises-aplus-tests adapter.js我第一次跑的时候输出的是一大片红叉。后来一个用例一个用例地过最后稳定在 872 个用例全绿。看到872 passing的时候我才算真正确信这份手写实现没有在关键路径上偷工减料。官方测试覆盖的维度大致可以分成三块测试类目覆盖点我的实现对应位置2.1 状态机pending 到 fulfilled/rejected终态不可逆status 守卫 resolvePromise 开头判断2.2 then 行为回调类型处理、异步执行、注册顺序、链式返回then 方法 schedule 调度2.3 resolution普通值、Promise 递归、thenable 解析、循环引用resolvePromise 全流程5.3 调试过程中遇到的三个典型失败模式跑测试的过程比最终代码更值得说我遇到的三个坑很有代表性。第一个坑是调度器问题。最初用setTimeout大部分用例能过但一旦测试目的涉及微任务顺序就会出现偶发失败。换成queueMicrotask之后这类失败全部消失。第二个坑是deferred适配器写错。我以为只要返回一个手工构造的 promise 就行却忘了必须暴露resolve和reject两个函数供测试用例调用。测试套件大量用例通过deferred()来手动控制状态这个入口如果写拧了后面会挂一片。第三个坑是循环引用检测。一开始我的resolvePromise没有处理promise x的情况导致 2.3.1 组的用例反复失败。报错信息也很有迷惑性看起来像是栈溢出实际上就是解析器在自我等待。6. 手写之外的实战补刀被 Uncaught (in promise) 支配的瞬间6.1 那些看着像框架 Bug 的报错多半是 Promise 链断了回到开头说的报错Uncaught (in promise) error: A listener indicated an asynchronous response。我在浏览器环境里排查过这类问题常见于事件监听器返回 Promise或者某些框架允许 listener 通过返回 true 表示我要异步响应。这时候如果 listener 内部返回的 Promise 没有 catch一旦 reject就会触发 unhandledrejection表现为控制台里一句刺眼的红字。它的本质不是浏览器随机报错而是 Promise 链条断裂之后没人接住拒绝信号。类似的情况还有async函数里漏了 try/catch、工具库的execute().finally()里又返回了一个 reject 的 Promise、某个中间件的next()前面少写了return。这些问题的共同特征是错误被真实地抛了出来但没有任何.catch在等它。6.2 给手写的及原生的 Promise 装一个安全网排查这类问题时我会先在全局挂一个安全网把没有被捕获的 rejection 统一收集到日志里window.addEventListener(unhandledrejection, (event) { console.error(Unhandled rejection:, event.reason); event.preventDefault(); });Node 环境则用process.on(unhandledRejection, (reason, promise) { console.error(Unhandled rejection at:, promise, reason:, reason); });这个安全网不是用来吞掉错误的它是用来把错误显性化的。以前看不到问题是因为错误被某个库静默消化了挂上监听之后所有漏网的 rejection 都会浮出水面。6.3 排查 Promise 链断裂的标准流程我自己的排查顺序是有套路的翻车几次之后总结出来的先看报错源头是来自原生 Promise、async 函数还是某个返回 Promise 的 SDK。找到 promise 链的起点从第一个 then 开始逐个检查有没有 catch 或通过第二个参数处理 rejected。重点检查返回这个动作then里的回调是否把新 Promisereturn出去了。很多人写promise.then(fn)却忘了 return后续链根本拿不到结果。检查事件监听器、中间件这类特殊入口。如果它们要求返回 Promise 或 true 表示异步响应一定要给内部 Promise 补上 catch或者在函数入口统一 try/catch。在生产环境尽量在 Promise 链的末端挂一个兜底 catch哪怕只是console.error也能避免错误变成孤魂野鬼。6.4 手写实现能不能直接上生产如果问我能不能把手写的 Promise 直接塞进生产环境我的答案是不建议。质量再高的手写实现也未必能长期对抗浏览器和 Node 版本的边界优化而且原生的 Promise 生态里还有finally、allSettled、any等一堆方法手写版很难覆盖完整。真要做兼容直接引进官方维护的 polyfill 是最稳的选择。不过手写一遍的价值从来不在于替换原生实现而在于让你具备读懂 polyfill 源码、定位第三方库异步问题、判断微任务时序的能力。我见过很多人被一句Uncaught (in promise)折磨一整天但如果他写过 Promise看到这个报错的第一反应就会是promise 链哪里断了而不是框架坏了。最后说点个人体会。跑通那 872 个用例的晚上我反复跑了好几次看着终端里全绿的结果整个人有种莫名的踏实感。之后我再去看 async/await脑子里不再是魔法般的语法糖而是很清楚它底层会编译成 Promise 的状态机调度。这个练习还有几个后续可以玩自己实现Promise.all、Promise.race、Promise.allSettled再用原生实现的随机输入对比结果或者把then改成支持中止信号的版本。别把 Promise 当成黑盒它就是一台带状态机的订阅发布机器拆开一次很多异步问题都会变得通透。
返回列表