手写Promise:从状态机到异步编程的底层逻辑
2026/9/24 21:26:00 网站建设 项目流程

Promise,这个在JavaScript里出现已经超过十年的东西,面试被问烂了,业务代码里也写烂了。但说实话,手写实现过Promise的人,和只是会用Promise的人,对异步编程的理解根本不在一个层次上。我之前带团队做技术面试,问到手写Promise这一题,能把核心逻辑讲清楚的人占比相当低,绝大多数候选人停留在new Promise((resolve, reject) => {})这种用法层面,一旦问到then方法为什么能链式调用、Promise怎么处理微任务调度、resolve一个Promise会发生什么,就开始含糊了。

这篇文章我就是想把手写Promise这件事彻底讲透,不是给你一段能跑过测试的代码就完事,而是把每一个设计决策背后的原理和坑都拆开来看。不管你是准备面试,还是单纯想把异步这块地基打牢,这篇文章都值得你花点时间认真看完。

1. 为什么我们最终绕不开Promise:从回调地狱到状态机

理解Promise的手写实现,首先要理解一个根本问题:Promise到底解决了什么?很多人会说"解决回调地狱",这个答案对,但不完整。回调地狱的本质不是代码缩进难看,而是控制反转后缺乏合理的错误传播和组合机制

1.1 回调时代的三大痛点

在Promise出现之前,JavaScript处理异步全靠回调函数。比如我们要按顺序读取三个文件,然后做合并处理,代码会长这样:

fs.readFile('a.txt', 'utf8', (err, dataA) => { if (err) { console.error('读取a失败', err); return; } fs.readFile('b.txt', 'utf8', (err, dataB) => { if (err) { console.error('读取b失败', err); return; } fs.readFile('c.txt', 'utf8', (err, dataC) => { if (err) { console.error('读取c失败', err); return; } // 终于拿到三个文件的内容了 console.log(dataA + dataB + dataC); }); }); });

这段代码有三个明显问题。第一个是错误处理被割裂,每一层回调都要自己捕获错误,漏掉一个就可能导致异常静默吞掉。第二个是流程复用困难,如果我想让三段读取并行执行,或者加一个超时控制,回调写法会指数级复杂化。第三个是最致命的——控制权反转,我们把后续逻辑交给了异步调用方,但完全没有机制保证这个调用方会按约定执行。

1.2 Promise带来的范式转变

Promise的聪明之处,在于它把异步操作封装成了一个状态机。Promise实例内部维护三个状态:pending(等待中)、fulfilled(已成功)、rejected(已失败)。状态转换只有两条路径:pending -> fulfilledpending -> rejected,一旦状态确定就不可再变。

这个不可变性至关重要。它意味着Promise的结果只能被决定一次,后续所有通过then注册的回调,要么拿到成功值,要么拿到失败原因,永远不会出现"先成功后又失败"这种鬼畜行为。这比回调函数可靠多了——回调可以被调用多次,也可以一次都不调用,调用时机也完全不受控制。

// 状态机视角的Promise // pending → fulfilled(带有一个成功值 value) // pending → rejected(带有一个失败原因 reason) // fulfilled → (终态,不可变) // rejected → (终态,不可变)

理解了这个状态机模型,手写Promise就有了骨架。你要做的事情就变得非常明确:封装一个对象,管理它的状态流转,提供方法注册状态变化后的处理逻辑。后面所有复杂的then链、异常透传、值吸收,都是在这个骨架上长出来的肌肉。

2. Promise的底层运转机制:状态、微任务与执行时机

真正手写Promise之前,有两块底层机制必须彻底搞清楚,不然写出来的代码要么行为怪异,要么在边界case上翻车。这两块机制一个是微任务(microtask)调度,一个是**resolve一个Promise对象时的递归展开**。

2.1 微任务队列为什么是Promise的基石

Promise的回调不是同步执行的,也不是普通的异步任务(宏任务),而是被放入微任务队列。微任务和宏任务的核心区别在于执行时机:宏任务等待下一个事件循环周期,而微任务在当前宏任务结束后、渲染之前就会清空。

用生活场景类比:宏任务是一整天的待办清单,微任务是你刚划掉一项就立刻想到的紧急补充事项。紧急事项永远优先于清单上后面的事务,而且必须在今天处理完,不能拖到明天。

手写Promise的时候,我们要模拟这种调度行为。ES6之前没有原生的微任务API,经典方案是用MutationObserverprocess.nextTick模拟,现在我们可以直接用queueMicrotask

// 在Promise实现内部,需要把回调放进微任务队列 queueMicrotask(() => { // 这里执行then回调 });

有人会问,直接把回调同步执行不行吗?不行。Promise规范明确要求then方法的回调必须异步执行,即使Promise已经处于fulfilled状态。这个设计保证了代码执行的确定性——不管你then注册的时候Promise是什么状态,回调都会在后续的微任务中统一执行,不会出现有时同步有时异步的"竞态分裂"。

2.2 resolve一个Promise时的递归展开

还有一个很多人忽略但极其重要的机制:resolve的值本身如果是一个Promise,外层Promise会递归展开,等待内层Promise状态确定后再决定自己的状态。这是Promise"扁平化"能力的核心。

const innerPromise = new Promise((resolve) => { setTimeout(() => resolve('inner result'), 1000); }); const outerPromise = new Promise((resolve) => { resolve(innerPromise); // resolve的值是一个Promise }); outerPromise.then((value) => { console.log(value); // 等内层完成后,这里打印 'inner result' });

这个机制在实现时是关键难点。当resolve(innerPromise)发生时,外层Promise不能直接进入fulfilled状态,因为它的值还没确定,它必须调用innerPromise.then来订阅内层的结果,再根据内层结果决定自己的状态。这就意味着resolve函数的实现必须判断:如果参数是Promise,就走吸收逻辑;否则直接改状态。

这套机制在后端设计里很像"代理"模式——外层Promise成了内层Promise的代理,把自己的状态完全交给内层决定。理解了这一点,后面手写resolvePromise函数的时候就明白它在干什么了。

3. 手写一个能跑的Promise:从骨架到完整实现

接下来进入正题。我会分三步走:先写一个只能处理fulfilled状态的简化版,把核心结构立起来;再加入rejected处理和链式调用,让它真正可用;最后把异常捕获和值吸收补上,得到一个接近完整的实现。

3.1 第一步:搭建状态机和基础构造函数

Promise构造函数接收一个执行器函数executor,执行器接收resolvereject两个函数作为参数。我们的构造函数需要初始化状态为pending,准备一个容器存成功值和失败原因,同时维护一个回调列表。

const PENDING = 'pending'; const FULFILLED = 'fulfilled'; const REJECTED = 'rejected'; class MyPromise { constructor(executor) { // 初始状态 this.state = PENDING; this.value = undefined; this.reason = undefined; // 存储then注册的回调 // 为什么用数组?因为一个Promise可以被then多次: // const p = new MyPromise(...); p.then(fn1); p.then(fn2); this.onFulfilledCallbacks = []; this.onRejectedCallbacks = []; // resolve和reject定义在构造函数内部, // 保证只有executor能调用它们来改变状态 const resolve = (value) => { if (this.state === PENDING) { this.state = FULFILLED; this.value = value; // 触发所有成功回调 this.onFulfilledCallbacks.forEach((fn) => fn()); } }; const reject = (reason) => { if (this.state === PENDING) { this.state = REJECTED; this.reason = reason; this.onRejectedCallbacks.forEach((fn) => fn()); } }; try { executor(resolve, reject); } catch (err) { // executor执行时抛错,直接走reject reject(err); } } }

注意这里回调列表的写法,我先把回调函数存起来,但没有立即执行。这是因为then被调用时Promise可能还处于pending状态,必须等状态确定后再通知这些回调。这就像是你注册了一个订阅,等发布者状态到位了才推送消息。

3.2 第二步:实现基础版then方法

then方法接收两个参数:onFulfilledonRejected,分别处理成功和失败。核心逻辑是:如果当前状态是pending,就把回调存进列表;如果是fulfilledrejected,就把回调放进微任务队列异步执行。

class MyPromise { // ...构造部分略 then(onFulfilled, onRejected) { // 先不管返回新Promise的事,这里只看基本逻辑 if (this.state === FULFILLED) { queueMicrotask(() => { if (typeof onFulfilled === 'function') { onFulfilled(this.value); } }); } if (this.state === REJECTED) { queueMicrotask(() => { if (typeof onRejected === 'function') { onRejected(this.reason); } }); } if (this.state === PENDING) { this.onFulfilledCallbacks.push(() => { queueMicrotask(() => { if (typeof onFulfilled === 'function') { onFulfilled(this.value); } }); }); this.onRejectedCallbacks.push(() => { queueMicrotask(() => { if (typeof onRejected === 'function') { onRejected(this.reason); } }); }); } } }

这个版本能用,能满足基本场景,但有两个明显缺陷。第一个是then返回值是undefined,无法链式调用;第二个是没有异常捕获——如果onFulfilled里面抛错了,异常没有被传递给后续的处理。这两个问题会在第三步解决。

3.3 第三步:补全链式调用、异常捕获与值吸收

链式调用是Promise最核心的语法糖,它要求then方法返回一个新的Promise,并且新Promise的状态由回调函数的执行结果决定。如果回调返回普通值,新Promise直接fulfilled;如果回调抛错,新Promise直接rejected;如果回调返回一个Promise,新Promise递归吸收它的结果。

同时,then没有传对应回调时,需要实现"值穿透"——成功值会一路传递到下一个thenonFulfilled,失败原因会一路传递到下一个thenonRejected

class MyPromise { constructor(executor) { this.state = PENDING; this.value = undefined; this.reason = undefined; this.onFulfilledCallbacks = []; this.onRejectedCallbacks = []; const resolve = (value) => { if (this.state === PENDING) { this.state = FULFILLED; this.value = value; this.onFulfilledCallbacks.forEach((fn) => fn()); } }; const reject = (reason) => { if (this.state === PENDING) { this.state = REJECTED; this.reason = reason; this.onRejectedCallbacks.forEach((fn) => fn()); } }; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { // 参数归一化:非函数回调直接透传value/reason const realOnFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (value) => value; const realOnRejected = typeof onRejected === 'function' ? onRejected : (reason) => { throw reason; }; const promise2 = new MyPromise((resolve, reject) => { // 统一处理:把回调放进微任务,并根据回调执行结果决定promise2的状态 const handleFulfilled = () => { queueMicrotask(() => { try { const result = realOnFulfilled(this.value); resolvePromise(promise2, result, resolve, reject); } catch (err) { reject(err); } }); }; const handleRejected = () => { queueMicrotask(() => { try { const result = realOnRejected(this.reason); resolvePromise(promise2, result, resolve, reject); } catch (err) { reject(err); } }); }; if (this.state === FULFILLED) { handleFulfilled(); } else if (this.state === REJECTED) { handleRejected(); } else { this.onFulfilledCallbacks.push(handleFulfilled); this.onRejectedCallbacks.push(handleRejected); } }); return promise2; } } // 核心辅助函数:处理回调返回值的"吸收"逻辑 function resolvePromise(promise2, result, resolve, reject) { // 防止循环引用:then回调返回了promise2自身 if (promise2 === result) { reject(new TypeError('Chaining cycle detected for promise')); return; } // 如果result是Promise,递归处理,直到结果不是Promise为止 if (result instanceof MyPromise) { result.then( (value) => resolvePromise(promise2, value, resolve, reject), (reason) => reject(reason) ); return; } // 普通值直接resolve resolve(result); }

到这一步,我们的实现已经具备Promise的三大核心能力:状态管理、链式调用、值穿透。这一版代码执行常见的面试测试场景(基本resolve、异常捕获、异步链式调用)已经没有问题。

4. 边界Case与Promise/A+规范细节:哪些地方最容易踩坑

手写Promise能跑通基本场景只是及格线,真正的挑战在边界Case。Promise/A+规范里花了大篇幅描述各种edge case的行为,这些恰恰是面试官喜欢追问的点,也是写业务代码时偶尔会碰到的诡异问题。

4.1 then回调返回自身导致的循环引用

前面代码里已经做了这个判断:if (promise2 === result)。这个Case在规范的2.3.1里有明确说明。实际场景是:

const p = new MyPromise((resolve) => resolve(1)); const q = p.then(() => q); // then回调返回了then方法返回的那个Promise

这会导致一个无限等待:q等p完成,p的回调返回q,q又在等自己完成。JavaScript原生Promise在这种情况下会抛TypeError: Chaining cycle detected for promise。我们的实现必须显式检测这种循环。

4.2 回调函数返回一个"带then方法"的对象

规范里有一个特殊要求:如果resolve的值或回调的返回值是一个对象(非Promise),但这个对象有then方法,会被当作thenable处理,即调用它的then方法,并根据其回调结果决定外层Promise的状态。这就是著名的鸭子类型判断——"长得像Promise的就算Promise"。

const thenable = { then(resolve) { resolve('从thenable中取到的值'); } }; Promise.resolve(thenable).then((value) => { console.log(value); // '从thenable中取到的值' });

为什么要有这个设计?因为Promise.resolve()要能够兼容其他Promise库(比如蓝鸟、Q等)。不同库的Promise实例虽然instanceof判断不通过,但只要它们有规范的then方法,就应该被吸收。这意味着我们前面写的result instanceof MyPromise这种判断在完整实现里是不够的,需要检查result && (typeof result === 'object' || typeof result === 'function') && typeof result.then === 'function'

4.3 executor里调用两次resolve怎么办

Promise规范规定状态只能从pending转换一次,所以resolvereject里都要做状态判断:

new Promise((resolve, reject) => { resolve('first'); resolve('second'); // 无效,状态已变为fulfilled reject('third'); // 无效,状态已确定 });

前面的实现里每个方法都有if (this.state === PENDING)的判断,所以天然支持这个行为。但有一个细节容易忽略:第一次resolve执行完,this.value已经被设置,后面的resolve调用因为状态不是pending直接返回,不会覆盖已有值。这就保证了结果的不可变。

4.4 then回调中抛出异常,但不传onRejected

假设代码是这样的:

Promise.resolve(1).then(() => { throw new Error('boom'); });

如果不给then传第二个参数,异常会"穿透"到下一个thenonRejectedcatch。我们的实现里通过参数归一化保证:realOnRejected即使没有传入,也会被设置为(reason) => { throw reason; },从而异常继续向后传递。但如果整条链都没有捕获呢?最终会抛一个unhandledrejection事件。Node和浏览器都会有对应的全局事件监听,这也是Promise错误处理里必须要注意的点——Promise链上必须有兜底的catch,否则错误会静默消失或变成unhandledrejection

4.5 标准Promise实现中resolve一个Promise时的递归展开时长

这里有一个比较隐蔽的差异:原生Promise在resolve一个已经是fulfilled状态的Promise时,外部onFulfilled仍然要等一轮微任务。而我们在resolvePromise里的递归展开会消耗额外的微任务轮次。这个行为差异一般感知不到,但在极端依赖微任务执行次序的场景(比如框架内部的调度逻辑)里可能出现不易察觉的bug。这也是为什么很多手写Promise教程就停在"功能能跑"的程度,不去追求和原生实现完全一致的调度时序——因为完整模拟规范行为需要的代码复杂度会暴涨。

5. 从手写Promise到工程实践:异步控制的进阶心得

把基础Promise写完之后,我建议再往前走一步,把一些工程上常用的Promise变体和组合函数也手写一遍。这不仅能巩固对异步机制的理解,在你日常用Promise.allPromise.race的时候遇到问题也能更快定位根因。

5.1 Promise.all的完整实现与失败语义

Promise.all接收一个可迭代对象,返回一个Promise,当所有给定的Promise都成功时才成功,返回结果数组;任何一个失败则立即失败,失败原因是第一个失败的Promise的原因。注意,Promise.all对空数组直接返回[]

MyPromise.all = function(promises) { return new MyPromise((resolve, reject) => { if (!Array.isArray(promises)) { return reject(new TypeError('参数必须是数组')); } const results = []; let remaining = promises.length; if (remaining === 0) { return resolve([]); } promises.forEach((promise, index) => { // 注意:每一项都要用resolve包一层,因为数组里可能不是Promise对象 MyPromise.resolve(promise).then((value) => { results[index] = value; remaining--; if (remaining === 0) { resolve(results); } }, reject); }); }); };

这个实现里容易漏掉两个点。一个是MyPromise.resolve(promise)这一层包装,保证传入的不是Promise也能正常处理。另一个是结果存储用results[index]而不是results.push()——因为异步完成顺序不一定和数组顺序一致,如果某个Promise先完成就会导致结果错位。用下标赋值能保证返回结果数组和传入数组顺序一一对应。

5.2 Promise.race、Promise.allSettled这些默认方法要不要手写

我的建议是:raceall值得手写,因为它们涉及到异步竞态控制的本质;allSettledany可以看一眼就过,它们更像是业务工具而不是原理挑战。

MyPromise.race = function(promises) { return new MyPromise((resolve, reject) => { if (!Array.isArray(promises)) { return reject(new TypeError('参数必须是数组')); } promises.forEach((promise) => { MyPromise.resolve(promise).then(resolve, reject); }); }); };

race的实现非常精简,核心思路就是所有Promise共享同一个resolvereject,谁先触发谁决定最终状态。这里有一个面试常问的变型题:Promise.race能实现"请求超时中断"吗?答案是不能真正中断底层请求,只能先返回超时结果,底层Promise的处理结果仍然会在某个时刻被结算但不会再影响race的结果。理解了race共享resolve的机制,这个问题的答案自然就浮现出来了。

5.3 async/await与手写Promise的关系

async/await本质上是Promise的语法糖。async函数返回的本身就是一个Promise,await会把后面的值包成Promise并订阅它的结果。所以当你理解了手写Promise的内部机制,很多async/await使用中的怪现象就能解释通了。

比如这个经典陷阱:

async function test() { try { const result = await somePromiseReject(); } catch (e) { console.log('捕获到了', e); } }

等价于:

function test() { return somePromiseReject().then( (result) => { ... }, (e) => console.log('捕获到了', e) ); }

awaitthen的异常捕获在语义上完全一致,理解了then的reject分支处理逻辑,自然就理解了try/catch为什么能捕获await的异常。

6. 手写Promise的价值边界与学习建议

手写Promise这件事,我在不同阶段有过不同的体会。最开始学习的时候,我觉得这就是个面试表演项目,把代码背下来就行。后来在项目里排查线上问题时,遇到了一个因为Promise回调执行顺序导致的竞态bug,查了很久才发现是对微任务调度理解不透彻。那时候才真正明白,手写Promise的价值不在于能把代码默写出来,而在于通过实现去建立对异步执行机制的空间想象力。

6.1 手写到什么程度算合格

我给自己定的标准有三个层次。第一个层次是能把状态机、then链、值穿透实现出来,能通过最基本的测试场景;第二个层次是能说清楚每个设计决策的理由——为什么then要用微任务而不是同步执行,为什么要递归吸收thenable,为什么Promise的状态不可变;第三个层次是能根据实际场景扩展出Promise.allrace、超时控制、重试机制这些衍生工具。

大部分人的问题在于直接从第一层跳到背代码,跳过了第二层。面试官真正想听的其实不是代码本身,而是那些"为什么"。

6.2 一次解决一类问题:举一反三的学习路径

学完手写Promise后,我强烈建议沿着这条线往下深挖两个方向。一个方向是事件循环机制,把宏任务、微任务、渲染时机放在一起看,这样你就知道setTimeout(fn, 0)queueMicrotask(fn)在任务队列里的位置差距有多大,也就理解了为什么Vue的nextTick要用微任务来实现。另一个方向是异步流程控制模式,手写一个带并发数限制的asyncPool、一个可取消的Promise——这些在真实项目里的出场率远比想象中高。

理解了Promise之后再看这些工程实现,你会发现自己不再需要硬背API,而是能根据底层机制推导出正确行为。这才是手写中间件代码真正的意义。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询