1. 项目概述:为什么我们要手写一个Promise?
如果你在前端领域摸爬滚打超过一年,那么“Promise”这个词对你来说,应该像吃饭喝水一样自然。从fetchAPI到async/await,现代JavaScript的异步编程几乎都构建在Promise之上。但不知道你有没有过这样的经历:面试时被问到“能说说Promise的实现原理吗?”,或者遇到一个诡异的异步bug,控制台抛出一个Uncaught (in promise) Error,你却对它的内部流转机制感到模糊,调试起来像在猜谜。
这正是“手写Promise”这个项目的价值所在。它绝不是一个为了面试而准备的“八股文”练习题。通过从零开始,用代码一步步还原Promise/A+规范,你会被迫去思考那些平时被封装好的“黑盒”逻辑:then方法如何实现链式调用?resolve和reject如何改变状态并触发回调?微任务队列(Microtask)在其中扮演了什么角色?当你亲手实现过一遍,再去看uncaught (in promise) error: could not establish connection这类错误,你就能清晰地在大脑中勾勒出错误是从哪个then或catch链中“逃逸”出来的,定位问题的速度会快上几个数量级。
这个项目适合所有希望深入理解JavaScript运行机制的开发者。无论你是想夯实基础的中级工程师,还是准备应对深度技术面试的求职者,亦或是被异步回调地狱困扰的新手,通过“手写Promise”,你都能获得对JavaScript并发模型最直观、最深刻的认识。接下来,我将以一个实践者的角度,带你从设计思路到代码细节,完整地走一遍构建过程,并分享那些在文档里不会写的“坑”和“技巧”。
2. 核心架构与设计思路拆解
在动手写代码之前,我们必须先想清楚要造一个什么样的“轮子”。Promise/A+规范是我们的蓝图,但它只定义了最终的行为,并没有规定具体的实现方式。我们的设计思路需要围绕几个核心问题展开。
2.1 状态机的设计:Promise的三种状态
Promise的本质是一个状态机。它只有三种状态:pending(等待中)、fulfilled(已成功)和rejected(已失败)。状态一旦从pending转变为fulfilled或rejected,就不可再改变。这是Promise可靠性的基石。
在实现时,我们通常会定义三个常量来表示这些状态,并在Promise实例内部维护一个state变量。同时,我们还需要两个变量来存储成功的结果(value)和失败的原因(reason)。为什么要把结果和原因分开存储?因为规范规定,一个Promise要么有一个成功的值,要么有一个失败的原因,不可能同时拥有两者。分开存储能让我们在状态确定后,快速地将正确的数据传递给相应的回调函数。
设计心得:很多初学者会尝试用一个变量同时存储
value和reason,并通过判断状态来区分,这增加了逻辑的复杂性。清晰的数据分离是简洁代码的第一步。
2.2 回调队列的管理:实现异步的核心
这是整个手写过程中最精妙也最容易出错的部分。当我们在pending状态下调用then方法时,传入的成功和失败回调函数并不能立即执行,因为此时Promise的结果还不知道。我们必须将这些回调函数暂时存储起来,等到Promise被resolve或reject时再依次调用。
因此,我们需要两个队列(或数组):onFulfilledCallbacks和onRejectedCallbacks。它们负责收集在pending状态时通过then方法注册的所有回调。当状态改变时,我们就遍历对应的队列,执行里面的每一个回调函数。
这里的关键在于异步执行。根据Promise/A+规范,then方法指定的回调函数,必须在一个新的执行栈中,作为微任务(Microtask)执行。在浏览器环境中,我们通常用queueMicrotask、MutationObserver或者setTimeout(虽然它是宏任务,但可以作为降级方案)来模拟。这确保了回调总是在当前同步代码执行完毕后才被调用,避免了“Zalgo”效应(即一个API有时同步、有时异步,导致不确定性)。
2.3 Then方法的链式调用:Promise的灵魂
then方法是Promise所有魔力的来源。它必须返回一个全新的Promise(记作promise2),这是实现链式调用的基础。它的执行逻辑可以拆解为以下几步:
- 参数处理:
then的两个参数onFulfilled和onRejected必须是函数,如果不是,则需要将它们“透传”。即,如果onFulfilled不是函数,则创建一个默认函数,其作用是将成功值value原封不动地传递给promise2。对onRejected的处理同理。这解释了为什么我们可以写.then().then(),让值在链中传递。 - 状态判断:根据当前Promise(记作promise1)的状态,决定如何执行。
- 如果promise1是
fulfilled,则异步执行onFulfilled,并用其返回值x来决定promise2的状态。 - 如果promise1是
rejected,则异步执行onRejected,并用其返回值x来决定promise2的状态。 - 如果promise1是
pending,则将封装好的回调函数(包含执行和结果处理逻辑)分别推入对应的回调队列。
- 如果promise1是
- 返回值决议:这是最复杂的部分,即所谓的“Promise解决过程”(
[[Resolve]](promise2, x))。我们需要一个独立的resolvePromise函数来处理onFulfilled或onRejected的返回值x。这个函数要处理多种情况:x是普通值、x是另一个Promise、x是一个thenable对象(即拥有then方法的对象),甚至x等于promise2本身(循环引用,必须报错)。
整个then方法的实现,就是围绕这个状态判断和返回值决议过程展开的。设计清晰的状态流转和严谨的resolvePromise函数,是成功的关键。
3. 核心细节解析与实操要点
理解了宏观架构,我们深入到几个最容易让人栽跟头的核心细节。这些细节往往决定了你手写的Promise是“玩具”还是真正符合规范的工业级实现。
3.1 Promise解决过程:resolvePromise的实现
resolvePromise(promise2, x, resolve, reject)这个函数是连接两个Promise的桥梁。它的作用是:根据x的值,来决定promise2最终是成功(调用resolve)还是失败(调用reject)。规范中定义了详尽的步骤,这里提炼出最关键的几个判断:
- 防止循环引用:如果
promise2 === x,那么必须以一个TypeError为原因拒绝promise2。这是为了避免无限递归。if (x === promise2) { return reject(new TypeError('Chaining cycle detected for promise #<MyPromise>')); } - 判断x是否为对象或函数:如果
x不是对象也不是函数(即普通值),那么直接resolve(x),用这个值完成promise2。 - 处理thenable对象:如果
x是对象或函数,尝试取出它的then属性(let then = x.then)。这里有一个坑:取属性可能会抛出异常(例如,x.then是一个getter,内部抛错),我们必须用try...catch包裹,如果出错,就直接reject这个错误。 - 判断then是否为函数:如果
then是一个函数,那么我们就认为x是一个thenable对象。此时,需要用x作为上下文(this)来调用then方法,并传入两个回调函数:一个用于接收成功值y,一个用于接收失败原因r。- 这里又有一个关键点:
then方法可能被多次调用(既调成功回调又调失败回调),或者被调用后再次返回一个thenable。规范要求我们的实现必须“防御”这种不规范的实现。通常的做法是设置一个called标志位,确保resolve或reject只执行一次。
let called = false; try { then.call( x, (y) => { if (called) return; called = true; // 递归解析,因为y可能仍然是thenable resolvePromise(promise2, y, resolve, reject); }, (r) => { if (called) return; called = true; reject(r); } ); } catch (error) { if (!called) { reject(error); } } - 这里又有一个关键点:
- 如果
then不是函数,那么直接把x作为普通对象resolve掉。
这个函数的递归调用(第4步中解析y)保证了无论Promise链多长,嵌套多深,最终都能正确地解析出最底层的那个值。
3.2 错误捕获与冒泡
Promise的错误处理是“冒泡”性质的。如果一个Promise被拒绝(rejected),并且没有在当前的then链中提供onRejected回调,那么这个拒绝原因会一直向下传递,直到被某个catch或onRejected回调捕获。
在我们的实现中,这意味着在then方法内部执行用户传入的回调(onFulfilled或onRejected)时,必须用try...catch严密包裹。因为用户回调是可能抛出同步错误的!根据规范,如果onFulfilled或onRejected抛出一个异常e,那么promise2必须以e为原因被拒绝。
// 在then方法的执行逻辑中 const handleCallback = (callback, value, resolve, reject) => { queueMicrotask(() => { try { const x = callback(value); // 用户回调可能在这里抛错 resolvePromise(promise2, x, resolve, reject); } catch (error) { reject(error); // 捕获同步错误,并拒绝promise2 } }); };正是这个机制,使得throw new Error('xxx')和return Promise.reject('xxx')在效果上等价,都导致了链中下一个Promise的失败。
3.3 静态方法与原型方法的实现要点
一个完整的Promise实现还包括静态方法(如Promise.resolve,Promise.reject,Promise.all,Promise.race等)和原型方法(如catch,finally)。它们都可以基于我们已经实现的核心then方法来构建。
Promise.resolve(value):返回一个Promise。如果value本身就是Promise,则直接返回它;如果value是thenable,则跟随这个thenable的状态;否则,返回一个以该值为成功结果的Promise。其实现本质上就是new Promise(resolve => resolve(value)),但需要复用我们写好的resolvePromise逻辑来处理thenable。Promise.reject(reason):简单直接,返回一个立即被拒绝的Promise:new Promise((_, reject) => reject(reason))。Promise.all(iterable):接收一个可迭代对象(如数组)。它返回的Promise会在所有输入的Promise都成功时才成功,结果是一个数组;如果任何一个输入Promise失败,则立即失败。实现时需要注意计数和结果的顺序对应。Promise.race(iterable):顾名思义,赛跑。返回的Promise的状态与第一个改变状态的输入Promise相同。catch(onRejected):就是then(null, onRejected)的语法糖。finally(onFinally):无论成功失败都会执行的回调。它有几个特殊行为:1) 不接收任何参数;2) 返回一个Promise,该Promise会等待onFinally执行完毕,并且其状态和值与原Promise保持一致(除非onFinally返回了一个被拒绝的Promise或抛出错误)。
实操心得:实现静态方法时,
Promise.all和Promise.race是对异步流程控制的绝佳练习。特别是Promise.all,你需要仔细处理空数组(应同步返回一个已完成的Promise)、非Promise值(需要用Promise.resolve包装)以及结果的收集顺序。一个常见的错误是在for循环中使用let但未正确闭包,导致结果顺序错乱。
4. 手写Promise完整实现与代码逐行解析
理论说得再多,不如一行代码。下面,我将结合注释,展示一个符合Promise/A+规范的核心实现。我们会采用ES6的Class语法,并力求代码清晰,便于理解。
// 首先定义三种状态常量 const PENDING = 'pending'; const FULFILLED = 'fulfilled'; const REJECTED = 'rejected'; class MyPromise { constructor(executor) { this.state = PENDING; // 初始状态 this.value = undefined; // 成功值 this.reason = undefined; // 失败原因 this.onFulfilledCallbacks = []; // 成功回调队列 this.onRejectedCallbacks = []; // 失败回调队列 // 定义resolve和reject函数,它们需要绑定this const resolve = (value) => { // 只有pending状态可以转变 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()); } }; // 立即执行执行器函数,并传入resolve和reject try { executor(resolve, reject); } catch (error) { // 如果执行器同步抛出错误,直接reject reject(error); } } then(onFulfilled, onRejected) { // 参数透传:如果onFulfilled不是函数,则创建一个默认函数,将值传递下去 onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value; // 如果onRejected不是函数,则创建一个默认函数,将错误原因抛出,实现冒泡 onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason; }; // then方法必须返回一个新的Promise const promise2 = new MyPromise((resolve, reject) => { // 封装一个处理fulfilled状态的函数 const handleFulfilled = () => { // 使用queueMicrotask确保异步执行 queueMicrotask(() => { try { const x = onFulfilled(this.value); // 关键:调用Promise解决过程 resolvePromise(promise2, x, resolve, reject); } catch (error) { reject(error); } }); }; // 封装一个处理rejected状态的函数 const handleRejected = () => { queueMicrotask(() => { try { const x = onRejected(this.reason); resolvePromise(promise2, x, resolve, reject); } catch (error) { reject(error); } }); }; // 根据当前Promise的状态,决定如何执行 if (this.state === FULFILLED) { handleFulfilled(); } else if (this.state === REJECTED) { handleRejected(); } else if (this.state === PENDING) { // 如果是pending状态,将回调函数封装后存入队列 this.onFulfilledCallbacks.push(() => handleFulfilled()); this.onRejectedCallbacks.push(() => handleRejected()); } }); return promise2; } catch(onRejected) { return this.then(null, onRejected); } finally(onFinally) { return this.then( value => MyPromise.resolve(onFinally()).then(() => value), reason => MyPromise.resolve(onFinally()).then(() => { throw reason; }) ); } static resolve(value) { // 如果value已经是MyPromise实例,直接返回 if (value instanceof MyPromise) { return value; } // 否则,返回一个新的Promise,并走标准的resolve流程 return new MyPromise((resolve) => { resolve(value); }); } static reject(reason) { return new MyPromise((_, reject) => { reject(reason); }); } static all(promises) { return new MyPromise((resolve, reject) => { if (!Array.isArray(promises)) { return reject(new TypeError('Argument is not iterable')); } if (promises.length === 0) { return resolve([]); } const results = new Array(promises.length); let completedCount = 0; promises.forEach((promise, index) => { // 用MyPromise.resolve包装,确保处理的是Promise MyPromise.resolve(promise).then( value => { results[index] = value; // 按索引存储结果,保证顺序 completedCount++; if (completedCount === promises.length) { resolve(results); } }, reject // 任何一个失败,立即拒绝整个Promise ); }); }); } static race(promises) { return new MyPromise((resolve, reject) => { if (!Array.isArray(promises)) { return reject(new TypeError('Argument is not iterable')); } promises.forEach(promise => { MyPromise.resolve(promise).then(resolve, reject); }); }); } } // 独立的Promise解决过程函数 function resolvePromise(promise2, x, resolve, reject) { // 1. 防止循环引用 if (x === promise2) { return reject(new TypeError('Chaining cycle detected for promise #<MyPromise>')); } // 2. 判断x是否为对象或函数 if ((typeof x === 'object' && x !== null) || typeof x === 'function') { let called = false; // 防止多次调用 try { // 3. 尝试获取x.then const then = x.then; if (typeof then === 'function') { // 4. 认定x为thenable,调用then方法 then.call( x, y => { if (called) return; called = true; // 递归解析 resolvePromise(promise2, y, resolve, reject); }, r => { if (called) return; called = true; reject(r); } ); } else { // 5. then不是函数,将x作为普通值resolve resolve(x); } } catch (error) { if (!called) { reject(error); } } } else { // x是普通值,直接resolve resolve(x); } }代码解析与关键点:
- 构造函数:初始化状态、值和回调队列。
executor被立即执行,并用try...catch包裹,同步错误会被捕获并直接reject。 resolve/reject函数:它们被定义为箭头函数,以绑定正确的this。内部会检查状态是否为PENDING,确保状态不可逆。状态改变后,会清空对应的回调队列。then方法:核心中的核心。它创建了promise2。通过handleFulfilled和handleRejected两个封装函数,统一了异步执行和错误捕获的逻辑。根据当前状态,要么立即安排异步任务执行回调,要么将封装函数推入队列。queueMicrotask:这里使用了现代的queueMicrotaskAPI来模拟微任务。如果你的环境不支持,可以用MutationObserver或setTimeout(宏任务)降级,但严格来说setTimeout不符合规范的微任务要求,一些测试用例可能无法通过。resolvePromise函数:作为独立函数,它清晰地实现了规范的[[Resolve]]过程。called标志位是处理不规范thenable的关键。- 静态方法:
all和race的实现展示了如何基于基本的then来构建更复杂的控制流。注意all中使用了MyPromise.resolve来包装输入,这处理了输入为非Promise值的情况,并且通过索引index来保存结果,保证了输出顺序与输入顺序一致。
5. 测试验证与常见问题排查
代码写完了,但它真的能工作吗?是否符合Promise/A+规范?我们需要一套严格的测试。最权威的测试工具是Promises/A+ Compliance Test Suite。我们可以使用promises-aplus-tests这个npm包来验证我们的实现。
5.1 搭建测试环境与运行合规测试
首先,在项目中安装测试包:
npm install promises-aplus-tests -D然后,在我们的MyPromise实现文件中,添加适配器代码,使我们的类能够被测试套件识别:
// 在MyPromise类定义之后 MyPromise.deferred = function() { const result = {}; result.promise = new MyPromise((resolve, reject) => { result.resolve = resolve; result.reject = reject; }); return result; }; // 如果是Node.js环境,导出模块 module.exports = MyPromise;最后,创建一个测试文件(如test.js):
const MyPromise = require('./my-promise.js'); // 你的实现文件 const adapter = { resolved: MyPromise.resolve, rejected: MyPromise.reject, deferred: MyPromise.deferred }; // 运行测试 const { run } = require('promises-aplus-tests'); run(adapter, (err) => { if (err) { console.error('至少有一个测试用例失败:', err); process.exit(1); } else { console.log('所有测试用例通过!'); } });运行node test.js,如果所有872项测试都通过,那么恭喜你,你的Promise实现基本符合A+规范。
5.2 常见问题与调试技巧实录
在手写和测试过程中,你几乎一定会遇到下面这些问题。这里记录了我的排查思路和解决方案。
问题1:测试套件报错 “TypeError: adapter.deferred is not a function”
- 原因:没有正确导出
deferred方法。测试套件需要通过这个方法来创建待测试的Promise实例。 - 解决:确保在
MyPromise类上添加了静态方法deferred,并且导出正确。
问题2:某些异步测试用例失败,提示“timeout”
- 原因:这通常是因为回调没有正确异步执行。你可能用了同步的方式调用了
onFulfilled或onRejected,或者使用的异步方法(如setTimeout)延迟不对。 - 排查:检查
then方法中,在状态已经是FULFILLED或REJECTED时,是否用queueMicrotask(或等效微任务)包裹了回调执行。在pending状态下推入队列的回调,在resolve/reject执行时,也要确保是异步触发。 - 技巧:可以在
handleFulfilled和handleRejected函数内部加console.log,观察其执行时机是在当前同步代码块中,还是之后。
问题3:链式调用中,后一个then拿到了前一个then返回的Promise,而不是它的解决值
- 原因:
resolvePromise函数没有正确处理x是Promise(或thenable)的情况。可能缺少了递归调用resolvePromise的逻辑。 - 排查:重点检查
resolvePromise函数中,当typeof then === 'function'时,在成功回调y => {...}里,是否递归调用了resolvePromise(promise2, y, resolve, reject)。这是实现“Promise链展开”的关键。
问题4:Promise.all的结果顺序不对
- 原因:在
all方法的forEach循环中,直接使用了promise.then,并将结果push到数组末尾。由于Promise完成顺序不确定,导致结果数组顺序与输入顺序不一致。 - 解决:必须按照索引来存储结果,如上面代码所示:
results[index] = value;。这样无论哪个Promise先完成,结果都会放在正确的位置。
问题5:finally方法行为不符合预期
- 场景:
promise.finally(() => { ... })返回的Promise,其状态和值应该“跟随”原Promise,除非回调函数返回一个被拒绝的Promise或抛出错误。 - 实现要点:
finally的实现需要一点技巧。如上文代码所示,它返回this.then(...),在成功和失败分支里,都先执行onFinally(),并将其结果用Promise.resolve包装(以处理其可能返回Promise的情况),然后.then(() => value)或.then(() => { throw reason; })来“忽略”onFinally的成功值,并传递原Promise的决议。
调试心得:手写Promise时,最有效的调试方法是“人肉单步执行”。用最简单的例子,比如
new MyPromise(resolve => resolve(1)).then(console.log),在脑海中或纸上画出每一步:状态如何变化、回调队列如何操作、微任务何时加入队列、resolvePromise如何递归。配合console.log在关键函数入口打印状态和参数,你能非常直观地看到数据流动,从而定位问题所在。
6. 从手写理解现代异步编程与常见错误
通过完整的手写过程,我们再回头去看日常开发中那些令人头疼的Promise错误,就豁然开朗了。
理解Uncaught (in promise) Error: 这个错误意味着一个Promise被拒绝了,但在整个Promise链上,没有任何一个then的第二个参数(onRejected)或catch方法来捕获这个拒绝。错误会一直“冒泡”到全局,被浏览器或Node.js环境捕获并打印出来。在手写实现中,这就是一个reject(reason)调用后,没有任何注册的onRejected回调函数来处理它。最佳实践是,永远在Promise链的末尾加上一个.catch(),即使你只是打印日志。
理解Uncaught (in promise) TypeError: Cannot read properties of undefined: 这通常发生在then链中。例如:
fetchUser().then(user => user.profile.address.city).then(city => ...)如果user.profile是undefined,那么user.profile.address就会抛出TypeError。这个错误发生在then的回调函数内部。根据我们手写的逻辑,这个同步错误会被try...catch捕获,并导致当前then返回的Promise被拒绝。如果后面没有catch,就会变成“未捕获的错误”。教训是:在then的回调中,对可能为undefined的属性进行安全访问(如使用可选链?.)或做好判空。
理解Promise链中的“静默失败”: 如果你在catch或onRejected回调中处理了错误,但没有重新抛出或返回一个被拒绝的Promise,那么错误就被“消化”了,链会继续向下走。这有时是期望的行为(错误恢复),但有时会导致后续逻辑在不正确的状态下运行。你需要明确:这个错误是可恢复的吗?处理完后,链应该继续成功,还是应该标记为失败?
手写Promise的过程,是一次对JavaScript并发模型的深度之旅。它强迫你去理解事件循环、微任务、状态机这些底层概念。当你再看到async/await语法时,你会明白它只是Promise的语法糖,其本质依然是你手写过的那些状态流转和回调管理。这份理解,能让你在复杂的异步代码面前保持清醒,写出更健壮、更易维护的程序。