手写Promise:从状态机到微任务,彻底搞懂异步错误传播

📅 发布时间:2026/10/2 22:59:31
手写Promise:从状态机到微任务,彻底搞懂异步错误传播
“Promise 又报错了”——这大概是前端工位上出现频率最高的一句话。打开控制台Uncaught (in promise) TypeError: Cannot read properties of undefined、Unhandled promise rejection这种红色报错几乎每个用 JavaScript 的人都见过。很多人第一反应是把.catch()补上但补完之后发现有些错误还是抓不住有些报错明明 try/catch 包了却依然冒出来。问题出在哪出在大多数人对 Promise 的理解停留在“会用 API”的层面不知道它内部到底是怎么流转的。花了几个晚上我把社区里各类 Promise 实现翻了一遍从最早的开源实现到浏览器原生的 V8 版本一路跟下来最大的感受是Promise 的实现难度其实不在状态机也不在链式调用而在于“为什么这里有坑”以及“报错到底是从哪一层冒出来的”。这篇文章我不打算只贴一份能跑的MyPromise代码给你抄而是把状态机设计、回调收集、微任务调度、错误传播机制逐层拆开同时把那些常见的uncaught (in promise)报错逐一对照源码排查一遍。每一段实现我都会说清楚“为什么这么写”而不是“这么写能跑就行”。如果你已经会用 Promise 的then/catch/finally但发现自己排查异步报错时经常一头雾水或者想要理解“链式调用”背后的机制那这篇内容应该是你的菜。1. 内容整体设计与思路拆解从“会用”到“能写”先聊一个经常被忽略的问题为什么需要自己实现 Promise很多人的答案是为了面试但真正写完之后你才会发现实现一遍之后你对异步代码的直觉会发生质变——你能一眼看出某个报错为什么成为“unhandled”也能在排查异步链路问题时更快定位到具体是哪一层断掉了。1.1 从回调地狱到状态机Promise 解决了什么问题一段常见的异步嵌套代码大概是这样的getUser((user) { getOrders(user.id, (orders) { getOrderDetail(orders[0].id, (detail) { renderDetail(detail); }); }); });这个三段式嵌套到十段的时候整个代码的阅读和调试难度会直线上升。Promise 最关键的一步改动不是用链式写法替代嵌套而是彻底改变了回调的持有方式。它把“回调函数层层内嵌”改为“回调函数注册到外部对象上由状态变化来触发执行”。为了达到这一点Promise 内部必须设计一套状态机这是整个实现的基石。我自己动手写的时候最深的体会是状态机设计决定了代码的复杂度上限。如果状态设计得不好后面链式调用、错误传播、微任务调度全部会乱成一团。1.2 状态机设计为什么恰好是 Pending、Fulfilled、Rejected 三种状态Promise/A 规范规定一个 Promise 实例只能处于三种状态之一状态含义可迁移方向pending等待中既未成功也未失败可迁移至 fulfilled 或 rejectedfulfilled已成功持有终值 value不可再迁移rejected已失败持有拒因 reason不可再迁移注意一个关键点状态只能从 pending 出发并且一旦离开 pending 就永久锁定。这意味着 Promise 的输出成功值或失败原因是确定且不可逆的。这个设计直接影响了你后面的then注册——回调需要判断当前状态如果已经定下来了就立即执行如果还是 pending 就先存起来。用生活化的方式理解pending 就像快递“运输中”fulfilled 是“已签收”rejected 是“丢件”。快递一旦被签收或者报丢失就不可能再回到“运输中”状态也不会变成另一种结果。Promise 的这种不可逆性让异步操作的结果可以被安全地多次读取。我最初写的时候试图做一个“可重置”的四状态版本结果发现链式调用里状态回溯会引入海量的边界判断最后直接推翻重来。状态越简单实现越稳这一点在 Promise 上体现得淋漓尽致。1.3 技术选型为什么采用发布订阅模式Promise 内部要支持同一个 Promise 注册多个回调还要支持回调的链式串联这本质上就是一个发布订阅系统。then负责订阅状态迁移负责发布。订阅者回调需要有这些能力同一个 Promise 可以多次调用then每次调用互不影响每个订阅者拿到成功值或失败原因后执行自身的回调并把返回值传递给下一个订阅者订阅者内部如果抛出异常需要被捕获并传给下一个订阅者的错误处理函数这些需求综合下来我选择了“一个 Promise 内部维护两个回调队列 状态锁”的方式来实现。一节里我会给出完整的代码先把思路理清楚发布订阅模式 状态机 微任务调度这三个东西合在一起就是 Promise 的全部秘密。2. 核心细节解析与实操要点构造器、executor、回调存储转到代码层面之前先明确一点Promise 的构造函数接收一个函数executor它会被立即执行。这个设计非常反直觉——很多人以为 Promise 是“等到要处理结果时才去执行异步任务”但实际上new Promise(...)的那一刻executor 就已经在跑了。2.1 构造函数为什么 executor 必须同步执行直接看一个例子console.log(start); new Promise((resolve) { console.log(executor); resolve(1); }); console.log(end); // 输出顺序start → executor → end这里的executor是同步执行的。Promise 构造函数内部所做的核心工作就是把 resolve 和 reject 两个函数准备好然后立刻调用 executor(resolve, reject)。为什么不把 executor 变成异步的一个很实际的原因是如果 executor 是异步执行的那么 executor 里同步抛出的异常就无法被 Promise 捕获。看看这段代码const promise new Promise(() { throw new Error(executor error); }); // 如果 executor 是异步执行这个 error 会直接变成未捕获异常 // 实际上它会被 Promise 捕获并变成 rejection所以 Promise 构造器的实现第一原则executor 的调用要放在 try/catch 里一旦同步抛错就调用 reject。这样无论是 executor 主动调用 reject还是里面抛出一个异常Promise 都会进入 rejected 状态。2.2 回调队列与状态互斥必须先锁定再执行构造函数内部需要维护两个数组分别存放 fulfilled 和 rejected 的回调。很多人会问为什么不只维护一个数组每个元素存{ onFulfilled, onRejected }两种都可以但分开两个队列有个好处——当状态已经确定时只需要遍历对应的那个队列不用每次都做条件判断。先给出一份骨架代码后续章节逐步完善成完整实现class MyPromise { constructor(executor) { this._state pending; // pending | fulfilled | rejected this._value undefined; // 成功值 或 失败原因统一存在 _value this._fulfillQueue []; // 成功回调队列 this._rejectQueue []; // 失败回调队列 const resolve (value) this._transition(fulfilled, value); const reject (reason) this._transition(rejected, reason); try { executor(resolve, reject); } catch (err) { this._transition(rejected, err); } } _transition(newState, value) { if (this._state ! pending) return; // 状态锁定不可逆 this._state newState; this._value value; // 状态确定后清空并执行对应队列 this._runCallbacks(); } }这里有一个被很多人忽略的细节_transition要同时处理状态迁移和回调触发。当 executor 里调用resolve(1)时整个 Promise 进入 fulfilled 状态但此时then注册的回调可能还没有来得及放进去也有可能已经在队列里了。_runCallbacks需要区分这两种情况。2.3 回调的执行时机什么时候进队列什么时候立即执行then方法的核心逻辑其实就是在做一件事——判断当前状态如果当前状态是 fulfilled直接把onFulfilled丢进微任务队列去执行如果当前状态是 rejected直接把onRejected丢进微任务队列去执行如果当前状态还是 pending先把回调存到对应的队列里等状态迁移时再取出来执行看一个关键的实现注意点不管是“立即执行”还是“状态迁移后执行”回调都不应该同步执行。这里有一个 Promise/A 规范里的硬性要求——onFulfilled和onRejected必须异步执行。换句话说then注册的回调永远不可能在当前同步代码执行完之前被调用。这是许多人在面试中会被追问的点为什么Promise.resolve(1).then(x console.log(x)); console.log(2)的打印顺序一定是2然后1因为then里的回调永远被推迟到当前同步代码块结束之后。这个“推迟”的实现就需要依赖任务队列机制下一节专门展开。3. 实操过程与核心环节实现手写一个能跑的 Promise这部分我们直接进入完整实现。先声明这段代码的目标不是“1:1 复刻 V8 源码”而是实现一个功能完整、行为符合规范的迷你版。每一段代码后面我都会解释关键设计点。3.1 微任务调度setTimeout 为什么不够好queueMicrotask 为什么是对的选择在前面的骨架里我把回调的触发直接放在了_runCallbacks中同步执行。这样实现会有问题——如果 executor 里同步调用了resolve那then注册的回调在执行时其实还处于 Promise 构造阶段会导致状态混乱。更重要的是同步执行会让代码的行为和原生 Promise 不一致。先看看两种任务调度的差异// setTimeout宏任务 setTimeout(() console.log(timeout), 0); // queueMicrotask微任务 queueMicrotask(() console.log(microtask)); Promise.resolve().then(() console.log(promise)); console.log(sync); // 打印顺序sync → microtask → promise → timeoutsetTimeout属于宏任务它排在所有微任务之后。如果你用setTimeout包装 Promise 的回调整体行为在“慢”这个层面上接近但在任务执行顺序上会和不含 setTimeout 的同步代码产生微妙差异。社区早期确实有人这么干但跑测试用例时经常挂掉。原生 Promise 内部使用的是“微任务”。现代浏览器中可以直接使用queueMicrotaskNode.js 也是全局支持。所以这份实现里我用它来调度所有回调。function enqueueJob(callback) { queueMicrotask(callback); }如果处于较老的运行环境也可以用Promise.resolve().then(callback)来模拟本质一样。3.2 完整版 MiniPromise代码逐行解读下面进入正题这是完整实现。我会尽量精简辅助代码但保证每一个方法都有实际用处。class MiniPromise { constructor(executor) { this._state pending; this._value undefined; this._fulfillQueue []; this._rejectQueue []; const resolve (value) this._transition(fulfilled, value); const reject (reason) this._transition(rejected, reason); try { executor(resolve, reject); } catch (err) { reject(err); } } _transition(newState, value) { if (this._state ! pending) return; this._state newState; this._value value; this._flush(); } _flush() { if (this._state pending) return; const queue this._state fulfilled ? this._fulfillQueue : this._rejectQueue; this._fulfillQueue []; this._rejectQueue []; queue.forEach((handler) { enqueueJob(handler); }); } then(onFulfilled, onRejected) { // 保证两个回调都是函数非函数就用默认透传函数 const handlerOnFulfilled typeof onFulfilled function ? onFulfilled : (value) value; const handlerOnRejected typeof onRejected function ? onRejected : (reason) { throw reason; }; return new MiniPromise((resolve, reject) { const wrap (callback) { enqueueJob(() { try { const result callback(this._value); resolve(result); } catch (err) { reject(err); } }); }; if (this._state pending) { this._fulfillQueue.push(() wrap(handlerOnFulfilled)); this._rejectQueue.push(() wrap(handlerOnRejected)); } else if (this._state fulfilled) { wrap(handlerOnFulfilled); } else { wrap(handlerOnRejected); } }); } catch(onRejected) { return this.then(null, onRejected); } static resolve(value) { if (value instanceof MiniPromise) return value; return new MiniPromise((resolve) resolve(value)); } static reject(reason) { return new MiniPromise((_, reject) reject(reason)); } }这段代码有个很关键的设计then返回了一个新的 MiniPromise并且把handlerOnFulfilled的执行结果通过resolve(result)传递给新 Promise。这一步是链式调用的基础。拿一个场景走一遍流程。假设const p new MiniPromise((resolve) { setTimeout(() resolve(1), 500); }); const p2 p.then((value) { return value 1; }); p2.then((value) console.log(value)); // 1.5 秒后打印 2执行过程是这样p一开始是 pending所以p.then(...)里注册的回调被推入了p的队列。500ms 后resolve(1)触发状态迁移_flush从队列取出回调放入微任务队列执行。回调执行时拿到value 1执行value 1得到2调用resolve(2)——注意这里的resolve是p2的 resolve。于是p2变成 fulfilledvalue 为 2再触发 p2 自己的回调打印2。这个流程里最微妙的地方在于then返回的 Promise 的 resolve 函数被存进了外层闭包所以每个“下一环”都知道自己该把值往哪里传。3.3 关键难点如果 then 回调返回了一个 Promise 怎么办上面的实现里如果callback(this._value)返回的是一个 Promise直接resolve(result)会把整个 Promise 对象作为成功值传给下一个 then。这会导致链式调用不能“扁平化”。来看原生 Promise 的行为Promise.resolve(1) .then((value) { return Promise.resolve(value 1); }) .then((value) { console.log(value); // 2不是 Promise 对象 });第二段then拿到的直接是2而不是一个 Promise。这意味着resolve必须有能力识别传入值是否为 Promise或 thenable 对象如果是就把它的结果继续解开。这就是 People 常说的“resolve 的递归展开逻辑”。标准实现里[[Resolve]](promise, x)函数有很长的流程核心归纳下来分三步如果x就是当前 promise 自己抛 TypeError防止自等待导致死循环如果x是 Promise 实例等价于x.then(resolve, reject)如果x是带then方法的对象/函数取出then并调用它传入resolve和reject作为回调这样可以保证无论中间某环返回多深的嵌套 Promise最终都能被“展开”成一个扁平链。把resolve函数改造成递归展开形式是 MiniPromise 从“玩具”升级成“可用实现”的关键。改造后的代码const resolve (value) { this._resolvePromise(value, this._transition.bind(this)); }; _resolvePromise(value, transition) { if (this._state ! pending) return; if (value this) { transition(rejected, new TypeError(Chaining cycle detected)); return; } if (value instanceof MiniPromise) { value.then( (v) this._resolvePromise(v, transition), (r) transition(rejected, r) ); return; } if ( (typeof value object value ! null) || typeof value function ) { let then; try { then value.then; } catch (err) { transition(rejected, err); return; } if (typeof then function) { let called false; try { then.call( value, (y) { if (called) return; called true; this._resolvePromise(y, transition); }, (r) { if (called) return; called true; transition(rejected, r); } ); } catch (err) { if (!called) transition(rejected, err); } return; } } transition(fulfilled, value); }这里called标志变量的作用很关键它保证then方法只被有效调用一次防止恶意的 thenable 对象既调用了 resolve 又调用 reject。你可以把这理解为“Promise 接种了一次状态不能反悔”。3.4 静态方法补全resolve、reject、all、race 的适配逻辑完成了 then 之后几个静态方法就相对好写了。核心逻辑如下static all(iterable) { return new MiniPromise((resolve, reject) { const list Array.from(iterable); const results []; let remaining list.length; if (remaining 0) { resolve(results); return; } list.forEach((item, index) { MiniPromise.resolve(item).then( (value) { results[index] value; remaining - 1; if (remaining 0) resolve(results); }, reject ); }); }); } static race(iterable) { return new MiniPromise((resolve, reject) { const list Array.from(iterable); list.forEach((item) { MiniPromise.resolve(item).then(resolve, reject); }); }); }注意all里的两个细节。第一结果数组的下标必须和输入项一一对应所以用results[index] value而不是results.push(value)否则异步完成的顺序会导致结果错乱。第二remaining计数器在每一个 item 结束之后减一减到 0 才能触发resolve否则空数组或较晚完成的 Promise 会导致all迟迟不能结束。race的实现用的是“先到先得”的逻辑无论第一个落定的是 fulfilled 还是 rejected整个race都以它为终态。3.5 为什么要用递归展开而不是直接 Promise.resolve(value)在_resolvePromise的实现里我直接判断value instanceof MiniPromise并没有先走MiniPromise.resolve(value)。这里稍微解释下MiniPromise.resolve(value)本身就会判断传入值是否为 Promise 实例如果是就直接返回原实例。所以理论上resolve函数可以直接调用MiniPromise.resolve再接一个 then。但要注意instanceof判断只能识别比较“纯正”的 Promise 实现如果传入的是一个带then方法的普通对象thenable必须走完整展开逻辑这部分无法靠instanceof跳过。我实际测试时碰到过一个场景使用 iframe 里的 Promise。iframe 之间是独立的 JavaScript 上下文value instanceof Promise在父页面里返回false。如果实现只靠instanceof判断会出现“明明返回了 Promise 却解不开”的诡异 bug。所以生产级实现一定不能只判断instanceof而要用 thenable 检测。这也是标准的Promise.resolve行为如此“啰嗦”的原因。3.6 关于 finally 的实现finally的语义是不管成功还是失败都要执行一次回调并且不改变原 Promise 的终态。也就是说Promise.resolve(ok) .finally(() console.log(cleanup)) .then((value) console.log(value)); // 先打印 cleanup再打印 ok如果finally的回调里抛出了异常那么后续链路上的状态会变成 rejected但如果回调只是正常返回原 Promise 的值会继续向下传递。实现方式如下finally(callback) { return this.then( (value) MiniPromise.resolve(callback()).then(() value), (reason) MiniPromise.resolve(callback()).then(() { throw reason; }) ); }注意这里必须用MiniPromise.resolve(callback()).then(...)包一层。如果callback是异步的返回 Promise必须等它执行完再透传 value 或 reason。如果callback抛错.then(() value)内部的 reject 会被触发整个链路的终态就变为 rejected。4. 常见问题与排查技巧实录那些难缠的 uncaught 报错很多人在项目里碰到的 Promise 报错其实并不是 Promise 本身的语法或逻辑问题而是对“错误怎么传播”的理解有盲区。这里我把热搜词里那几条典型报错汇总成一个速查表并逐一说明底层原因和处理思路。4.1 报错速查表对照你的控制台快速定位报错信息关键词典型出现场景根本原因处理方向Uncaught (in promise) TypeError: Cannot read properties of undefined异步接口返回的数据不是预期结构读取了undefined.xxx回调链中某一步拿到undefined后就继续访问属性在链路中增加空值兜底或对接口返回做 schema 校验Unhandled promise rejection某个 Promise 被 rejected但没有任何 catch 链错误被“遗弃”了——没有消费者处理拒绝在全局增加unhandledrejection监听做兜底链路末尾必须接 catchUncaught (in promise) Error: A listener indicated an asynchronous response扩展/浏览器插件事件回调中返回了 Promise但监听器不认这个异步响应事件监听函数期望同步返回值你却返回了 Promise区分事件类型不要在非 Promise 兼容的监听器里返回异步对象Uncaught (in promise) NotFoundError: Failed to execute insertBefore on NodeDOM 节点已被移除后异步回调里又尝试插入或移动该节点异步操作结束前 DOM 已经被更新回调拿到的是“过期的节点引用”在操作前判断节点是否存在或者在异步链路上携带节点 ID 重新获取Uncaught (in promise) Error: Could not establish connection. Receiving end does not exist消息端如扩展的 port已经销毁异步回调里仍尝试发送消息chrome.runtime.sendMessage等通信返回的 Promise 被 rejected但没有被捕获在使用通信 API 的位置加.catch()并处理端口销毁后的降级逻辑Unhandled promise rejection TypeError: WebAssembly.instantiate is not a function在非支持 WebAssembly 的环境调用相关 API或模块加载时机不对环境不支持或引用未加载Promise 以 rejected 告终但没有 catch加环境能力检测并在调用位置接好 catch4.2 为什么 try/catch 包不住异步错误这是很多人踩过最经典的坑。看这段try { Promise.resolve().then(() { throw new Error(async error); }); } catch (err) { console.log(caught!, err); }catch里的代码永远不会执行。原因是throw发生在微任务执行阶段而try/catch包裹的代码已经运行完毕调用栈已经退出了。错误只能在 Promise 链条内部传播能不能被捕获取决于链路末尾有没有.catch()。正确做法Promise.resolve() .then(() { throw new Error(async error); }) .catch((err) { console.log(caught!, err); // 能执行 });进一步说如果需要捕获的是异步链路之外的同步错误比如function risky(fn) { try { return Promise.resolve().then(fn); } catch (err) { return Promise.reject(err); } }用这个包装同步异常和异步异常都能落到返回的 Promise 上链路统一用.catch()处理。4.3 排查技巧三种课堂上不常用的定位方法第一招给每个 then 加上“路标”。排查长链路时可以临时在每个 then 回调里加一个带标识的 catch快速判断是链路上哪一环抛了错getData() .then((res) transform(res)) .catch((err) { // 加个日志标记这里断掉的 console.error([transform] failed, err); });第二招使用全局监听兜底。浏览器端的unhandledrejection事件和 Node.js 的unhandledRejection可以在错误变成“silent failure”之前打日志window.addEventListener(unhandledrejection, (event) { console.error([unhandledrejection], event.reason); event.preventDefault(); });但请注意这只是兜底不解决根本问题。真正要修的是业务链路中缺失的.catch()。第三招写一个 Promise 错误上报工具。在已有的 MiniPromise 里加一个 hook在_transition(rejected)发生时判断队列里是否还有消费者。这其实就是 V8 做 unhandled rejection 检测的思路——当一个 rejected 的 Promise 在“打完一轮微任务”后依然没有注册任何 rejection 处理函数就上报。虽然工程上通常直接依赖宿主环境但理解这个机制后排查起来就有的放矢了。4.4 一个“死链”的真实案例之前排查过一个线上问题用户反馈某个页面偶尔白屏。控制台大致的报错是Unhandled promise rejection。顺着链路看发现代码里有一处画像上报逻辑reportUserAction(action).then(() { someDOMOperation(); // DOM 操作依赖上报成功 });reportUserAction本身是一个 Promise如果上报接口返回了非 2xx网络层产生的 rejection 没有任何 catch那么.then里面的 DOM 操作就不执行了。即使执行了也是接续在 rejection 链上后续错误依然没有归属。修法并不复杂加一个空 catch或者把 DOM 操作放到 finally 里。关键是这类问题很难靠灵光一现发现必须在最初写异步代码时就养成“每条链路都必须有终态处理”的习惯。5. 实践经验与避坑指南我踩过的那些 Promise 深水区代码层面的实现讲完报错定位也说完了这节把实际工程里最容易被翻来覆去踩的点集中讲一下。5.1 永远不要在 Promise 里吞掉 rejection 信号有些代码风格喜欢在接口失败时“假装成功”function fetchUser() { return fetch(/user) .then((res) res.json()) .catch(() null); }这个写法的潜在问题是调用方无法区分“用户不存在”和“网络异常”两种场景被混为一谈。更好的模式是保留 reject让调用方决定function fetchUser() { return fetch(/user) .then((res) { if (!res.ok) throw new Error(HTTP ${res.status}); return res.json(); }); }这里的思路是Promise 链路的成功/失败承载的是业务语义不要把“发生错误”和“返回空值”混为一个终态。如果你需要给用户一个兜底值放在最外层的调用方处理而不是在链路中间吞掉错误。5.2 微任务与事件循环为什么 log 顺序和你预期不一样聊一个实际工程里很常见的小问题const p fetch(/data); p.then(() console.log(3)); console.log(1); setTimeout(() console.log(4), 0); console.log(2);绝大多数情况下输出顺序是1、2、3、4。关键在于p上面的then回调一定在当前同步代码执行完之后才排队。即使/data的请求已经返回也要等当前宏任务结束、微任务清空后才能执行。当你在调试代码时如果发现console.log的顺序乱糟糟多半是因为你把微任务和宏任务混在一起打印了。经验做法在需要严格控制顺序的场景不要依赖隐式时序使用 async/await 来让代码变为顺序式风格。毕竟 async/await 本质上就是 Promise 的语法糖它不改变语义但大幅降低理解成本。5.3 Promise.all 的失败策略全部成功或快速失败Promise.all一旦有一个 Promise rejected整个 Promise 就立即 rejected其他仍在进行中的 Promise 并不会被取消。这是很多新人的认知盲区。const p1 fetch(/first); const p2 fetch(/second); Promise.all([p1, p2]).catch((err) { // 如果 p1 先失败p2 的结果可能已经成功也可能还在请求中 // 但无法通过 Promise.all 的结果拿到 p2 的状态 });如果需要“等待所有请求完成再统一处理失败”应该用Promise.allSettledconst results await Promise.allSettled([p1, p2]); results.forEach((result) { if (result.status fulfilled) { // 处理成功项 } else { // 处理失败项 } });在实现层面allSettled的核心就是让每个子 Promise 先映射成“永不 reject 的结构体”然后再Promise.all。5.4 finally 的陷阱异步清理与返回值finally里如果返回一个 Promise后续代码会等它完成——这个行为符合直觉。但如果finally里返回一个 rejected 的 Promise那么即使原 Promise 是 fulfilled链路最终也会变成 rejected。这通常会弄出很隐蔽的 bug。比如async function cleanup() { throw new Error(cleanup failed); } Promise.resolve(data) .finally(cleanup) .then( (value) console.log(成功, value), (err) console.log(失败, err) // 这里会走失败分支 );如果你希望“清理失败不影响业务结果”就要先把这个 reject 消化掉Promise.resolve(data) .finally(() cleanup().catch(() { /* 忽略清理错误 */ })) .then((value) console.log(成功, value));5.5 async/await 时代还需要理解底层吗很多开发者在项目里已经全面转向 async/await但这不意味着 Promise 原理就不重要了。async/await 只是 Promise 的语法糖await本身就是在调用then的逻辑。当你在 async 函数里捕获异常时你仍然需要知道“这个 Promise 是不是被 await 了”“有没有挂着 unhandled rejection 的尾巴”。最常见的例子async function main() { try { await someTask(); } catch (err) { // 能捕获到 } } // 但这样写就不行 async function main() { try { someTask(); // 忘记 await } catch (err) { // 永远不会捕获到 someTask 的错误 } }如果someTask()在被调用后抛出的 rejection 没有任何人处理就会产生 unhandled rejection。所以我的建议是框架可以用语法糖可以用但底层机制不能不懂。毕竟线上定位问题时没人会给你罩上“async/await 友好模式”。6. 可以继续扩展的方向手写 MiniPromise 这件事我个人的最大体会是它不像背概念那样死板也不像读文档那样枯燥。每写一轮你对“异步世界里错误往哪走、数据往哪流”的直觉就更强一些。如果你顺着这条线继续深入有几个方向值得继续折腾给 MiniPromise 跑一遍 Promises/A 官方的 872 个测试用例把所有边界情况都补掉实现 Promise 的取消机制比如 AbortController 与 fetch 的结合看看“可中断的 Promise”应该怎么设计把 MiniPromise 移植到 TypeScript用类型系统把 thenable 的展开逻辑约束起来尝试自己实现一个 async/await 的转译器看看生成代码是什么样的最后分享一个实战小技巧如果你负责的模块经常出现unhandledrejection建议在开发环境里提前开启“把未处理 rejection 当作构建错误”的规则。另外在 Node.js 里process.on(unhandledRejection, (err) { throw err; })可以让你在测试阶段立刻看到问题而不是等用户反馈。踩过几次坑之后你会发现大部分 Promise 报错都不是“玄学”而是链路里少了一个 catch 或者结果被错误地吞掉了。掌握原理定位起来就是顺手的事。