1. 为什么Promise在异步编程里如此重要
很多同学在刚接触JavaScript时,最先接触到的异步写法是回调函数。回调模式最大的痛点不在“回调”本身,而在于当回调嵌套多层之后,代码的可读性会急剧下降,也正因如此才诞生了Promise。是的,十几年前那场“回调地狱”的集体焦虑,直接推动Promise被纳入ES6规范。今天不论你用的是React、Vue还是Node.js,只要涉及网络请求、文件读写或定时任务,都绕不开Promise。读懂它源码级别的运行机制,并不是为了装高级,而是为了真正理解“异步输入、同步输出”这个看似矛盾的设计,理解为什么它能有效规避回调地狱,也理解它最终把控制权交给谁。
我最初接触Promise源码时也一头雾水:明明规范文档写得清清楚楚,但怎么看都像隔着一层纱。后来我发现,值得研究Promise源码的人并不仅是开源库作者。任何一个在业务中写过复杂异步流程、排查过请求竞态、或者被“Promise不是反转控制而是状态移交”这种抽象概念整得云里雾里的前端开发者,都需要这一层认知。看完这篇解析,你会明白Promise内部状态如何流转、then注册的回调到底存在哪里、微任务队列什么时候消费回调,以及自己写一套极简Promise时应该注意什么。
要强调一点:这里讨论的Promise实现并不要求完全100%符合ES6规范,我们关注的是“正确打开方式”——既能让你跑通业务,又能让你在面试中条理清楚地把底层原理讲明白。我采用的是业界广泛验证过的简化实现逻辑,参考了经典的开源版本(结构与核心思想与主流实现保持一致),又额外加入了一些实际开发中常用的边界处理。
2. 正确打开方式:先搞清楚Promise的基础工作流程
2.1 你每天都在用的Promise,到底是怎么运转的
在拆源码之前,先看三个最基础的使用方式,这决定了后面源码设计的走向。
const promiseA = new Promise((resolve, reject) => { setTimeout(() => { resolve('ok'); }, 1000); }); promiseA.then((value) => { console.log(value); });这是一个最典型的Promise用法:构造函数接收一个执行器函数,执行器函数内部进行异步操作,异步操作完成时调用resolve,把结果交出去。这个流程中其实隐含了Promise最核心的结构——它就是一个状态机。
// 状态迁移 pending -> fulfilled (调用resolve) pending -> rejected (调用reject) // 状态一旦确定,不可再变这个约束太重要了。如果状态可以反复变更,那链式调用的每个then都无法确定自己拿到的到底是不是最终结果。所以看Promise源码,最先要看它如何保存状态、如何保证状态不变。我以前在实际项目里就踩过坑:某个接口在业务上需要同时命中两个条件才能继续,于是想在一个Promise里多次调用resolve去传递不同结果,结果发现第二次resolve完全失效。这就是对Promise“单次决议”特性认识不足。
// 伪代码展示状态保存 const state = { PENDING: 0, FULFILLED: 1, REJECTED: 2 }; let currentState = state.PENDING; let finalValue = undefined; let settledError = undefined;2.2 链式调用核心在哪:then函数到底做了什么
then有两个参数,第一个是成功回调,第二个是失败回调。很多人知道.then后面还能继续.then,但说不清中间发生了什么。其实then的核心行为是“注册回调并返回一个新的Promise”。那这个新Promise又由谁决议?答案:由上一个Promise的执行结果,加上当前then回调的返回值,共同决定。
promiseA .then((value) => { // 返回值将决定下一个then的状态 return value + '!'; }) .then((newValue) => { console.log(newValue); // ok! });这段代码背后的运行逻辑是:第一个then执行完后,内部会拿到回调返回的value + '!'。如果这个返回值是一个普通值,那么新Promise直接进入fulfilled;如果这个返回值“又是一个Promise”,那新Promise的状态就要等待那个Promise决议后才能确定。这一点正是Promise魔法般链式行为的本质。
再看一个经常被忽略的细节:then中的成功回调如果抛错,会怎样?它并不会使全局崩溃,而是让当前then返回的那个新Promise变成rejected状态。这就意味着,错误可以通过链在后续的.catch中被捕获。
promiseA .then(() => { throw new Error('boom'); }) .catch((err) => { console.log(err.message); // boom });很多开发者把catch看作语法糖,实际上它是链式Promise的正式“错误出口”,源码里没有对catch做特殊复杂处理,它本质就是.then(null, errorHandler)。
3. 手写一个极简Promise:从状态存储到回调队列
现在进入核心环节——把Promise重写一遍。这里的主线是在满足规范的基础上尽量简化,重点突出状态管理和微任务调用这两个关键点。
3.1 需要准备哪些前置条件
在开始写代码前,需要先明确构造函数的输入与输出,以及then方法、resolve方法、reject方法、catch方法这四部分的职责。
构造函数接收executor,executor是“立即执行”的,这个立即执行并不在微任务里,而是同步调用。很多人疑惑,为什么Promise构造器里的console.log会先打印?因为executor同步执行,只有内部的异步操作才进入队列。
then方法负责状态订阅。如果当前状态仍是pending,则把回调函数推入相应队列;如果状态已经确定,则把回调包装成异步任务放入微任务队列。这里有一个重要的差异:即使是.then返回的值是立即确定的,回调也不会同步执行。
resolve和reject方法负责决议状态。决议前,必须检查当前状态是否为pending,如果状态已经改变,后续调用直接忽略。这就是Promise单次决议的底层实现。
catch方法不需要额外存储失败回调队列,因为失败状态本质上就是“fulfilled的反面”,所以它直接转发到then的第二个参数即可。
3.2 核心实现:极简版Promise源码
我曾在一个模拟项目X中基于这份极简实现扩展了超时控制、重试机制和并发限制,运行效果稳定,这里直接给出主干源码。
const PENDING = 'pending'; const FULFILLED = 'fulfilled'; const REJECTED = 'rejected'; class SimplePromise { constructor(executor) { this.state = PENDING; this.value = undefined; this.reason = undefined; this.fulfillQueue = []; this.rejectQueue = []; // 确保resolve/reject只执行一次 let settled = false; const resolve = (value) => { if (settled) return; settled = true; this.state = FULFILLED; this.value = value; // 微任务中触发已注册回调 queueMicrotask(() => { this.fulfillQueue.forEach((handler) => handler(this.value)); this.fulfillQueue = []; }); }; const reject = (reason) => { if (settled) return; settled = true; this.state = REJECTED; this.reason = reason; queueMicrotask(() => { this.rejectQueue.forEach((handler) => handler(this.reason)); this.rejectQueue = []; }); }; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { const onFulfilledFn = typeof onFulfilled === 'function' ? onFulfilled : (val) => val; const onRejectedFn = typeof onRejected === 'function' ? onRejected : (err) => { throw err; }; return new SimplePromise((resolve, reject) => { if (this.state === FULFILLED) { queueMicrotask(() => { try { const result = onFulfilledFn(this.value); resolve(result); } catch (err) { reject(err); } }); } else if (this.state === REJECTED) { queueMicrotask(() => { try { const result = onRejectedFn(this.reason); resolve(result); } catch (err) { reject(err); } }); } else { this.fulfillQueue.push((value) => { try { const result = onFulfilledFn(value); resolve(result); } catch (err) { reject(err); } }); this.rejectQueue.push((reason) => { try { const result = onRejectedFn(reason); resolve(result); } catch (err) { reject(err); } }); } }); } catch(onRejected) { return this.then(null, onRejected); } }在这份极简实现中,我用queueMicrotask来模拟真正的微任务机制。在生产环境,V8引擎的做法是把它接入内部微任务队列,执行时机早于定时器,晚于当前同步代码。这也是Promise回调与setTimeout回调在执行顺序上存在差异的根源。
3.3 这份代码与真实Promise的差距在哪
上面这份实现能跑通大部分业务场景,但与真实Promise实现比,还差几个比较重要的东西。
第一,值的展开逻辑。真实Promise中,如果resolve的值本身是Promise,那么内部会继续等待这个Promise决议,而不是直接把它当作最终值。这不是一个可有可无的优化,它是then链能展平嵌套Promise的关键机制。
第二,跨环境兼容。浏览器、Node.js、Deno等环境对于微任务的具体实现不同,真实Promise底层会访问宿主环境提供的能力,而不是直接调用queueMicrotask这么简单。不过在学习和理解阶段,queueMicrotask完全够用。
第三,Promise Resolve Functions的规范化。在ES6规范中,resolve函数要經過Promise Resolve Functions例程处理,确保任何可能传入的类Promise对象都能被正确识别。这份极简实现并未处理。
第四,回调执行的时序细节。真实Promise的then在状态已经确定时,虽然也会把回调放入微任务,但顺序上与pending状态时进入队列有着微妙差异。我们简化后没有完全对齐规范中的EnqueueJob语义,但这不影响理解主流程。
这些差距并不是需要立刻修补的bug,而是在阅读真实源码前需要“预留”的概念空间。
4. 源码级解析:真正Promise的核心机制
4.1 状态机与Promise Capability记录
把视野放回浏览器内部。真正Promise实现中,每个Promise实例内部会有三个私有槽位,分别是[[PromiseState]]、[[PromiseResult]]、[[PromiseFulfillReactions]]和[[PromiseRejectReactions]]。私有槽位意思是外界代码不能直接访问,只能通过内置方法读取或修改。
// 伪代码描述私有槽位 PromiseInstance { [[PromiseState]]: 'fulfilled', [[PromiseResult]]: 'data', [[PromiseFulfillReactions]]: [ reaction1, reaction2 ], [[PromiseRejectReactions]]: [] }这种设计看起来比我们自己写的类属性复杂不少,但它的核心思想完全一致:把对外暴露的方法和对内保存的状态彻底隔离。私有槽位机制保证了状态不会被业务代码篡改,就像保险柜的钥匙只在银行工作人员手里。
Promise Capability记录是另一个重要概念。每次调用new Promise(executor),底层会生成一个Capability记录,里面包含promise实例、resolve函数、reject函数。这种记录结构让Promise的构造函数与执行器之间的通信变得非常标准:无论Promise实例是从构造函数创建的,还是由then返回创建,都遵循同一套流程。
4.2 then方法实现的两个关键处理
规范中的then方法核心步骤是:准备两个新的反应(Reaction),然后调用PerformPromiseThen,把当前Promise的实例、两个反应、以及用于创建新Promise的Capability一起传入。也就是每一层then调用,都会创建出一个新的Promise,并把回调包装成反应对象,放进前面提到过的反应列表里。
// 伪代码描述PerformPromiseThen过程 function performPromiseThen(promise, fulfillReaction, rejectReaction, resultCapability) { if (promise.[[PromiseState]] === 'pending') { // 新增reaction到对应列表 promise.[[PromiseFulfillReactions]].push(fulfillReaction); promise.[[PromiseRejectReactions]].push(rejectReaction); } else if (promise.[[PromiseState]] === 'fulfilled') { // 直接把fulfillReaction放入微任务队列 enqueueJob('PromiseJobs', fulfillReaction, promise.[[PromiseResult]]); } else { // 直接把rejectReaction放入微任务队列 enqueueJob('PromiseJobs', rejectReaction, promise.[[PromiseResult]]); } }这里需要特别留意:当Promise已经处于终态时,then并不会同步执行回调,而是把这个回调再次包装成微任务。所以“then注册的回调都异步执行”这句话是准确的,不管什么时候调用then。
第二个关键处理是回调的返回值。当回调执行完毕后,引擎会拿到返回值,并把它交给当前then对应的新Promise的resolve函数。如果这个返回值再次是一个Promise,resolve函数必须递归展开,直到返回非Promise值为止。这个展平过程,正是号称为“可去除Promise”的解决过程。
4.3 resolvePromise:展开机制到底在做什么
这个函数是Promise源码里最晦涩但最有价值的内容之一。它做的事情在语义上可以概括为:不要急着确定新Promise的终态,先看看“最终要确定的值”有没有特殊行为。
// 简化版resolvePromise逻辑 function resolvePromise(promise, result, resolve, reject) { if (promise === result) { reject(new TypeError('Chaining cycle detected')); return; } if (isPromise(result)) { // 如果result是Promise,则等待其状态 result.then( (value) => resolvePromise(promise, value, resolve, reject), (err) => reject(err) ); return; } // 如果是对象或函数,尝试读取then属性 if (isObjectOrFunction(result)) { let then; try { then = result.then; } catch (err) { reject(err); return; } if (typeof then === 'function') { // 视为thenable,调用它的then方法,并用我们的resolve/reject作为回调 try { then.call( result, (value) => resolvePromise(promise, value, resolve, reject), (err) => reject(err) ); } catch (err) { reject(err); } return; } } // 普通值,直接决议 resolve(result); }从这段逻辑可以看到几点宝贵经验。如果resolve传入的值就来自当前Promise自身,会产生循环引用,规格中会直接抛TypeError。这一判断很多人都没注意,但在复杂异步依赖系统中,一旦出现循环等待,程序会挂死,而Promise通过这个检测将致命问题提早暴露。
如果返回值是普通对象,但它恰好有一个then函数,那么这个对象被称为thenable。Promise并不关心它到底是不是“正式”Promise,而是通过统一读取then方法的方式,使得A+规范的Promise实现之间可以互相协作。这也是为什么很多Promise库能在运行时互相混用而不冲突。
4.4 为什么要同时支持“展开”和“单次决议”
单次决议保证状态确定后不再变更,展开机制保证值可能为Promise时能扁平化处理。两者组合起来,使then链变得极其优雅。举个例子,如果用Promise.resolve把一个被Promise包裹的Promise再包装一次,最终拿到的值仍然是最内层的那个结果,而不是嵌套的Promise对象。
const inner = Promise.resolve('深层结果'); const outer = Promise.resolve(inner); outer.then((value) => { console.log(value); // 深层结果 });这个行为是很多人的知识盲区。日常业务中我们经常会把从函数A获取的Promise直接作为另一个Promise的resolve值,如果你不理解展开机制,就会疑惑为什么没有出现多层嵌套结构。
5. 微任务机制与事件循环之间的微妙关系
5.1 为什么Promise回调比setTimeout先执行
无论手写源码还是阅读真实源码,微任务都绕不开。事件循环机制中,一次宏任务执行完毕之后,会清空全部微任务队列,再进行渲染或执行下一个宏任务。而setTimeout回调属于宏任务,所以即使定时器延迟为0,也会排在所有微任务之后。
这段现象可以这样验证。在浏览器控制台运行这段代码,几乎每次输出都是start、end、promise、timeout:
console.log('start'); Promise.resolve().then(() => { console.log('promise'); }); setTimeout(() => { console.log('timeout'); }, 0); console.log('end');官方规范把这个机制的优先级描述得很明确:在执行JavaScript脚本时,V8引擎在执行完当前同步任务后,会先检查PromiseJobs队列。PromiseJobs队列属于微任务队列的一种,而setTimeout属于宏任务队列。这种设计保证了Promise回调能够尽量快地得到处理,避免不必要的延迟。
5.2 从源码角度看,为什么then回调一定不能同步执行
若then回调同步执行,会出现状态在注册之前就已经确定的问题。例如:
let p = new Promise((resolve) => { resolve(1); }); p.then(() => console.log('then')); // 若同步执行,这里会直接运行看起来这没毛病,真正的问题出在回调函数的执行顺序上:
let p = new Promise((resolve) => { resolve(1); }); console.log('a'); p.then(() => console.log('b')); console.log('c');如果then同步执行,上面打印结果是a、b、c;可实际结果是a、c、b。Promise规范创造微任务机制的核心原则是:永远不要在当前执行栈中调用用户代码。这样既能保证不会出现意外的调用时序,也能让多个then回调保持合理的空间顺序。
5.3 unhandledrejection与微任务的关系
经常有人问,为什么Promise抛错不一定能在控制台立刻显示?这和微任务也有关。如果某个Promise被reject,但没有任何catch处理,浏览器会等待当前微任务队列清空后,再统一检查是否存在未处理的rejection。一旦发现,就触发unhandledrejection事件。
这带来一个重要工程启示:在正式项目中,千万不要依赖控制台默认的未处理错误提示,而是要在全局监听unhandledrejection事件,做好错误上报。
window.addEventListener('unhandledrejection', (event) => { // 统一上报 console.error('未处理Promise错误:', event.reason); event.preventDefault(); // 阻止默认的报错日志 });但需要注意,这个事件监听放在浏览器业务中非常有用,而在Node.js环境中名称与行为略有不同(比如Node里是unhandledRejection事件)。两者本质是同一机制在不同宿主下的呈现。
6. Promise级别工具方法源码逐行拆解
6.1 Promise.resolve与Promise.reject的实现
这两个工具方法的作用很简单:把一个值包装成Promise。但要看清它们背后的行为,仍然有细节。
// 简化的Promise.resolve static resolve(value) { if (value instanceof SimplePromise) { return value; } return new SimplePromise((resolve) => { resolve(value); }); } // 简化的Promise.reject static reject(reason) { return new SimplePromise((_, reject) => { reject(reason); }); }Promise.resolve有个重要特性:如果传入的是一个Promise,它会直接返回该Promise本身,而不是生成新实例。这在某些场景会影响引用相等性判断。如果你写的代码需要判断某个对象是否是同一个Promise实例,这一步就很重要。
Promise.reject则没有类似优化。无论传入什么,都会返回一个新的rejected Promise,包括传入Promise本身时也不会直接返回那个Promise。这个不对称性恰恰来自规范对resolve函数包含展开逻辑、而reject函数就是纯粹reject的设计。
6.2 Promise.all:并发等待的核心实现
Promise.all用于并发等待多个Promise。它接收一个可迭代对象,返回一个新的Promise。关键点是结果顺序与传入顺序保持一致,且只要有一个rejected,整体立即rejected。
static all(iterable) { return new SimplePromise((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) => { // 统一包装,确保非Promise值也能正常参与 SimplePromise.resolve(item).then((value) => { results[index] = value; remaining -= 1; if (remaining === 0) { resolve(results); } }, reject); }); }); }这段代码里容易忽略的一点是,数组的剩余计数remaining必须在每个子Promise完成时同步减一。因为Promise回调本身是异步的,如果这里不小心用results.length === index + 1来判断全部完成,会碰到索引与完成顺序不匹配的问题。用计数器的方式能保证无论子任务完成顺序如何,只有全部完成后才会决议。
在真实源码中,Promise.all在遇到空列表时会直接返回一个已决议为空的Promise,处理方式与我们一致。项目实践中,很多服务端并发请求操作都可以用这个工具方法来简化。
6.3 Promise.allSettled:不因单点失败而整体失败
all有一个明显的缺陷:一个子任务失败,整体就失败,这在某些业务中不能接受。比如批量拉取用户详情,其中一个用户接口报错,难道其他九个用户的数据也不要了吗?这自然引出allSettled。
static allSettled(iterable) { return new SimplePromise((resolve) => { 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) => { SimplePromise.resolve(item).then( (value) => { results[index] = { status: 'fulfilled', value }; remaining -= 1; if (remaining === 0) resolve(results); }, (reason) => { results[index] = { status: 'rejected', reason }; remaining -= 1; if (remaining === 0) resolve(results); } ); }); }); }allSettled的特点是从不走到rejected状态,所有子任务的失败结果都作为数据项保留在结果数组中。而且每个结果对象都带有status字段,取用时要根据status区分value和reason。这个工具的出现时间比all晚,但价值很大。
6.4 Promise.race与Promise.any:竞速与反向容错工具
race接收多个Promise,返回第一个决议(无论成功还是失败)的结果。这适用于超时控制:
static race(iterable) { return new SimplePromise((resolve, reject) => { const items = Array.from(iterable); items.forEach((item) => { SimplePromise.resolve(item).then(resolve, reject); }); }); } // 使用场景:请求超时 const requestPromise = fetchData(); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('timeout')), 3000); }); Promise.race([requestPromise, timeoutPromise]).then((data) => { // 如果3秒内拿到数据,这里会执行 }).catch((err) => { // 如果超时,这里会执行 });race的名字很形象,就是多个请求赛跑,谁先到就听谁的。race的局限也很明显:如果一个Promise很快rejected,那整个race立即变为rejected,其他较慢的成功结果便没有意义。对只关心“先满足”的业务场景,race是对的;可如果业务更希望“只要有一个成功就算成功,全部失败才算失败”,就需要any。
static any(iterable) { return new SimplePromise((resolve, reject) => { const items = Array.from(iterable); if (items.length === 0) { reject(new AggregateError('All promises were rejected')); return; } let rejectedCount = 0; const errors = new Array(items.length); items.forEach((item, index) => { SimplePromise.resolve(item).then( (value) => resolve(value), (reason) => { errors[index] = reason; rejectedCount += 1; if (rejectedCount === items.length) { reject(new AggregateError(errors)); } } ); }); }); }在真实业务中,any特别适合做接口容灾。比如有两条数据链路,一条走主服务,一条走备份服务,只要其中任意一条返回成功,即可继续业务流程。全部失败时才走降级逻辑。这个价值是all和race都替代不了的。
7. 手写Promise易踩的坑与源码级排查实战
7.1 坑点1:thenable的无限递归与循环调用
很多人在手写Promise时容易陷入无限循环。典型场景:resolve的值是一个对象,该对象的then属性访问后会触发另一个resolve,而那个resolve又指向同一个对象。
const circularThenable = { then(resolve) { resolve(circularThenable); } }; new Promise((resolve) => { resolve(circularThenable); }).then((value) => { console.log(value); });一旦进入这种结构,resolvePromise会反复执行自身,导致死循环。真实源码通过限制展开次数或检测相同引用解决这个问题。我们写的简版中没有这个保护,如果实际开发中需要手写类似逻辑,别忘了增加深度限制或引用检测。
7.2 坑点2:executor内抛出异常时没有捕获
执行器内部错误,如果不捕获,整个Promise可能陷入状态不确定。正确的处理是用try/catch包住executor调用。真实Promise规范也要求这一层保护。这种捕获不是你写业务时显式包,而是Promise内部自动完成。
class SimplePromise { constructor(executor) { try { executor(resolve, reject); } catch (err) { reject(err); } } }如果没有这个保护,执行器抛出的错误会变成未捕获异常,直接破坏Promise的“决议后状态不可变”前提。
7.3 坑点3:then回调不是函数时的透传逻辑
如果then的第一个参数不是函数,真实Promise会把它当成(value) => value透传下去;第二个参数不是函数时,则当作(err) => { throw err; }继续向下抛出。这个逻辑在简版源码中已经处理。很多人在面试手写时漏掉这一点,写的then会报“onFulfilled is not a function”。
7.4 实战排查:为什么我的Promise.all没有按预期触发
某次在某跨平台系统中,同事反馈一个并发请求场景:所有接口都成功返回,但Promise.all一直没有resolve。排查后发现,他传给all的数组里混入了一个永远不会resolve的Promise,它被内部某个条件分支漏掉,导致计数器永远无法归零。
这种问题的排查方式很有价值。第一反应不是怀疑Promise.all实现有问题,而是循环遍历子Promise,逐个添加日志,观察哪些回调没有触发。在写复杂并发逻辑时,我习惯在子任务完成时输出标识,避免反复猜。
7.5 实战排查:unhandledrejection没有触发
又一次在某个图像处理Demo中,代码里已经监听了unhandledrejection,但错误仍然没有出现在捕获日志中。后来发现,根因在于某个Promise的错误已经被一个.then(null, handler)处理过了,但handler本身没有返回新Promise,导致后续的异常无法继续传递。很多人以为只有.catch才能消费错误,其实.then的第二个参数同样能消费错误,只是它消费后如果又返回一个新Promise,原来的错误链就断开了。
所以排查未处理错误时,要全链路审阅所有then的第二个参数、catch以及async/await的使用位置,不能只看有没有catch字样。
7.6 用async/await与Promise源码的关系
async/await是Promise的更高级封装,它把Promise的“手动注册回调”换成了“自动展开”。很多人有疑问,是不是学了Promise源码就能完全理解async/await?答案是,理解了Promise源码中的状态、微任务和展开机制后,async/await的行为几乎可以无缝推断。await后面跟一个Promise时,它内部会调用Promise的then并将后续代码包装成微任务,全部行为与手动then一致。
async function loadData() { const data = await fetchData(); // 这里会等待fetchData决议后执行 }这行代码在编译层面等价于fetchData().then(data => { ... })。只是async函数内部还能自动处理异常,并在异常时让外部Promise进入rejected状态。理解了Promise源码,async/await就不再神秘。
8. 用极简Promise解决真实场景中的并发控制
8.1 限制并发请求数量
手写Promise还有一个重要用途:实现并发控制。比如有一百个图片需要上传,总不能一次性全部并行推出,那样服务器压力太大。把Promise与队列结合,就能实现一个稳定的并发限制器。
async function mapWithConcurrency(tasks, limit, handler) { const results = new Array(tasks.length); let index = 0; const workers = new Array(limit).fill(null).map(async () => { while (true) { const current = index; index += 1; if (current >= tasks.length) break; results[current] = await handler(tasks[current]); } }); await Promise.all(workers); return results; } // 使用 const urls = ['/api/1', '/api/2', '/api/3', '/api/4']; const data = await mapWithConcurrency(urls, 2, async (url) => { return await fetch(url); });这段逻辑的本质是基于“任务索引递增”的方式,让固定数量的工作协程不断从任务池中取出新任务执行,直到全部完成。这里的Promise.all只负责汇总所有工作协程的完成情况,它并不知道具体任务数量。实现非常稳。
8.2 给Promise增加超时中断能力
Promise本身没有“叫停”能力,一旦executor执行,外界无法主动终止它。但可以借助race实现超时逻辑。已有客户端请求超时控制的业务,可使用这种方案把超时时间与请求本身代码解耦。团队内部我常用一个封装好的withTimeout:
function withTimeout(promise, ms, timeoutMessage = '请求超时') { let timer; const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(timeoutMessage)), ms); }); const wrapped = Promise.race([promise, timeoutPromise]); // 清理定时器,避免在正常完成时仍挂着定时器 wrapped.finally(() => clearTimeout(timer)); return wrapped; }这里的finally同样是Promise的一个工具方法,无论最终成功还是失败,都会执行清理操作。定时器清理很关键,否则大量超时请求同时发生时,定时器回调虽然不会再resolve,但会一直保留到时间到才被引擎回收,浪费资源。
8.3 自动重试机制中的Promise链
在实际开发中,接口偶发网络抖动不可避免。合理的重试策略会让系统更健壮。基于Promise实现带指数退避的重试逻辑,比用callback实现要清晰得多:
function retry(fn, times = 3, delay = 1000) { return new Promise(async (resolve, reject) => { for (let i = 0; i < times; i += 1) { try { const result = await fn(); resolve(result); return; } catch (err) { if (i === times - 1) { reject(err); return; } await new Promise((r) => setTimeout(r, delay * 2 ** i)); } } }); }这段代码是async/await与手动Promise的混合用法,外层Promise的resolve会在成功时被调用,失败时最后一次异常会作为最终reject理由。退避策略选择了常见的指数退避,即第一次失败等待1秒,第二次等待2秒,第三次等待4秒。这个方案要灵活调整重试次数与延迟,正式项目中还可加入最大重试时间限制。
9. 构建一个配套的异常监控小工具
单纯有Promise与捕获还不够,一个严谨的项目需要把“未处理rejection”以及“请求超时”等信息汇总起来,形成可视化或者日志上报。基于此前分析的unhandledrejection原理,可以快速封装一个极简错误监控工具。相比引入重量级监控SDK,这种自定义方案更轻量、更符合业务裁剪需求。
const errorMonitor = { unhandled: [], init() { if (typeof window !== 'undefined') { window.addEventListener('unhandledrejection', (event) => { this.unhandled.push(event.reason); this.report('unhandledrejection', event.reason); }); window.addEventListener('error', (event) => { this.report('windowError', event.message); }); } else if (typeof process !== 'undefined') { process.on('unhandledRejection', (reason) => { this.unhandled.push(reason); this.report('nodeUnhandledRejection', reason); }); process.on('uncaughtException', (err) => { this.report('nodeUncaughtException', err.message); }); } }, report(type, detail) { // 在这里接入上报接口,可以是sendBeacon或内部日志系统 console.warn('[errorMonitor]', type, detail); } };这个工具在很多中小型项目里非常实用。它利用的正是Promise源码中错误未处理时会触发的宿主事件机制,通过监听它,业务代码不需要在每个调用处写try/catch,就能把全局异常汇聚到一处。但也要注意,它不能替代显式错误处理,它只是兜底保险。
10. 从源码到工具链:周边生态如何利用Promise能力
10.1 Promise与浏览器内置API的绑定
现代浏览器中有大量API天然返回Promise:fetch、navigator.mediaDevices.getUserMedia、navigator.clipboard.writeText、Web Locks API等。理解Promise源码后,当这些API出现预料之外的状态时,可以更快定位是Promise使用姿势问题还是API限制问题。比如fetch不会因为HTTP状态码为404或500而进入rejected状态,只有网络故障才会。这个特性让很多初学者误以为fetch的then拿不到错误信息,实际是应当自行检查响应状态。
const res = await fetch('/api/data'); if (!res.ok) { // 这里处理业务错误 throw new Error(`HTTP error: ${res.status}`); } const data = await res.json();10.2 Promise搭配运行时类型检查
虽然JavaScript本身没有类型系统,但许多项目使用TypeScript。在类型系统中,Promise泛型给予开发者较好的提示。但类型系统不能帮你捕获运行时的rejected原因类型。我见过不少项目因为reject传了一个普通对象,导致catch分支里访问字段时直接报TypeError。这提醒我们,源码层面理解错误传递是一个方向,工程层面还需要约定“reject时统一使用Error对象”,可读性会好很多。
10.3 Promise在自动化测试中的价值
编写单元测试时,经常用Promise控制异步流程。测试框架对Promise的处理机制通常就是一个微任务等待过程。如果测试用例中出现“测试通过但实际代码有未处理rejection”,往往意味着你的异步用法仍然有隐患。这时可以用全局unhandledrejection监听来收集测试期间的未处理错误,作为CI的软性校验条件。
// 测试用例中快速判断是否存在未处理异常 const pendingRejections = []; process.on('unhandledRejection', (reason) => { pendingRejections.push(reason); }); afterEach(() => { if (pendingRejections.length > 0) { throw pendingRejections[0]; } });很多知名的开源项目也会在测试阶段开启这种严格模式,目的就是迫使开发者消除所有未处理的rejection,从源头保证异步代码的健壮性。
11. 个人经验总结与避坑心得
在多次手写Promise源码并把它应用到多个模拟项目后,我的体会有几点。
第一,不要一开始就死磕完整规范。Promise/A+规范原文非常抽象,它更适合作为“验收标准”而不是“入门教程”。先从能跑通的极简版开始,理解状态机,理解微任务,然后再对照规范逐条补齐。
第二,一定要亲自动手写一遍。看源码和写源码的差距非常大。当你自己写出第一个能跑通链式调用的极简Promise时,很多原本觉得玄妙的概念会变得异常清晰。我建议在本地新建一个demo文件,边写边在浏览器控制台或Node观察输出顺序,亲自对比状态变化。
第三,源码解析的落点应该回归业务。手写Promise并不是为了造轮子,而是为了真正理解异步流程。业务中的竞态、超时、重试、并发控制、错误上报,全部都能从Promise源码中找到对应机制。每当遇到一个奇怪的异步问题,先回过来想一想,它是不是回调队列里的顺序问题,是不是状态被单次决议截断,是不是微任务执行时机捣乱。
第四,警惕无脑的Promise链式滥用。有些代码喜欢把所有异步操作都挂在同一个Promise链上,链越来越长,可读性和排错性越来越差。Promise虽然解决了回调地狱,但它自己也可能引发新的可读性灾难。正确方式是结合async/await,并在必要处封装独立函数,及时断开过长的then链。
第五,要关注Promise的向下兼容与标准演进。Promise.allSettled、Promise.any等新方法虽好,但部署到一些老旧环境时需要考虑转换。团队内部可以搭建对应的polyfill策略,理解源码后,你甚至能自己写出合格的polyfill。
个人的实际体会是,Promise的源码并不友好,它充斥着规范的抽象层,但只要坚持“状态、回调队列、微任务展开”这三条主线,再难读的源码也能转化为自己的知识资产。我在处理某个跨平台系统的并发调度时,直接把这段手写Promise的并发控制逻辑嵌入模块中,实测下来逻辑清晰、运行稳定,后续也让同事少踩了很多坑。希望这篇拆解,能成为你打开Promise源码大门的第一把钥匙。