“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 或 rejected |
| fulfilled | 已成功,持有终值 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 内部使用的是“微任务”。现代浏览器中可以直接使用queueMicrotask,Node.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变成 fulfilled,value 为 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监听做兜底;链路末尾必须接 catch |
Uncaught (in promise) Error: A listener indicated an asynchronous response | 扩展/浏览器插件事件回调中返回了 Promise,但监听器不认这个异步响应 | 事件监听函数期望同步返回值,你却返回了 Promise | 区分事件类型,不要在非 Promise 兼容的监听器里返回异步对象 |
Uncaught (in promise) NotFoundError: Failed to execute 'insertBefore' on 'Node' | DOM 节点已被移除后,异步回调里又尝试插入或移动该节点 | 异步操作结束前 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 | 加环境能力检测,并在调用位置接好 catch |
4.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.allSettled:
const 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 或者结果被错误地吞掉了。掌握原理,定位起来就是顺手的事。