ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深入理解Promise:从A+规范到手写实现,掌握异步编程核心

深入理解Promise:从A+规范到手写实现,掌握异步编程核心 1. 从“会用”到“懂它”为什么我们要手写Promise如果你已经用JavaScript开发过一段时间尤其是处理过异步操作那你对Promise肯定不陌生。.then()、.catch()、async/await这些语法糖用起来确实顺手让曾经的回调地狱变成了相对清晰的“承诺链”。但不知道你有没有遇到过这样的场景面试时被问到“Promise的原理是什么”或者调试一个复杂的异步流程时面对一个Uncaught (in promise) TypeError: Cannot read properties of undefined的错误一脸茫然只知道有个地方出错了却很难快速定位到是链中的哪一个环节、因为什么原因出了问题。这就是“会用”和“懂它”之间的鸿沟。仅仅会调用API就像只会开车但不懂发动机原理一旦抛锚就只能干瞪眼。而“手写一个符合规范的Promise”正是跨越这道鸿沟最有效、最深刻的实践。这不是为了造轮子而是为了彻底搞懂这个轮子是怎么转起来的。通过亲手实现它的状态机、then方法链式调用、微任务队列调度你才能真正理解为什么.then可以连续调用为什么错误能被.catch捕获以及那些令人头疼的“未捕获的Promise错误”究竟从何而来。当你再看到uncaught (in promise)时你脑子里浮现的将不再是黑盒而是一幅清晰的执行流程图。2. Promise A规范我们手写的蓝图在动手敲代码之前我们必须先搞清楚我们要实现的是什么。ECMAScript规范中的Promise实现非常复杂但有一个更基础、更核心的规范作为所有实现的基石那就是Promise/A规范。我们手写Promise的目标就是实现一个符合Promise/A规范的Promise。这个规范并不长但它精确定义了Promise的行为尤其是then方法的核心逻辑。2.1 核心术语与三种状态首先规范明确定义了Promise的三种状态Pending等待态初始状态既没有被兑现也没有被拒绝。Fulfilled兑现态意味着操作成功完成。此时Promise有一个不可变的值value。Rejected拒绝态意味着操作失败。此时Promise有一个不可变的拒因reason。状态转换是单向且不可逆的只能从Pending变为Fulfilled或Rejected一旦改变就永久保持该状态。这个状态机是Promise可靠性的基础。2.2 Then方法的“圣杯”then方法是Promise的灵魂规范对其行为有极其细致的规定。它的核心可以概括为promise.then(onFulfilled, onRejected)onFulfilled和onRejected都是可选参数。如果它们不是函数必须被忽略注意不是转换成抛错函数而是直接“穿透”。当Promise状态变为Fulfilled时所有相应的onFulfilled回调需要按照注册顺序依次执行并传入value。当Promise状态变为Rejected时所有相应的onRejected回调需要按照注册顺序依次执行并传入reason。then方法必须返回一个新的Promise记作promise2。这是实现链式调用的关键。onFulfilled或onRejected的执行必须是异步的并且应该在一个新的执行栈通常用微任务如queueMicrotask或MutationObserver模拟中调用。这是保证执行顺序一致性的关键。最复杂的部分在于如何根据onFulfilled或onRejected函数的执行结果记作x来决定promise2的最终状态。这个过程被称为“Promise解决过程The Promise Resolution Procedure”规范中用一个抽象的[[Resolve]](promise2, x)算法来描述。简单来说它需要处理各种情况如果promise2和x是同一个对象则以一个TypeError为拒因拒绝promise2避免循环引用。如果x是一个Promise则promise2需要“采纳”x的状态。如果x是一个对象或函数则尝试获取x.then并调用它这处理了thenable对象。如果以上都不是则用x为值兑现promise2。理解这个解决过程是手写Promise最难也最重要的部分。3. 骨架搭建定义MyPromise类有了理论指导我们开始动手。首先搭建MyPromise类的基本骨架包含状态、值和原因以及用于存储回调的队列。// 定义Promise的三种状态用常量避免魔法字符串 const PENDING pending; const FULFILLED fulfilled; const REJECTED rejected; class MyPromise { constructor(executor) { // 初始状态 this.state PENDING; // 兑现的值 this.value undefined; // 拒绝的原因 this.reason undefined; // 成功回调队列因为同一个Promise的then可能被调用多次 this.onFulfilledCallbacks []; // 失败回调队列 this.onRejectedCallbacks []; // 定义resolve和reject函数 const resolve (value) { // 只有pending状态才能转换 if (this.state PENDING) { this.state FULFILLED; this.value value; // 状态改变后异步执行所有成功回调 this.onFulfilledCallbacks.forEach(callback callback()); } }; const reject (reason) { if (this.state PENDING) { this.state REJECTED; this.reason reason; // 状态改变后异步执行所有失败回调 this.onRejectedCallbacks.forEach(callback callback()); } }; // 立即执行执行器函数 try { executor(resolve, reject); } catch (error) { // 如果执行器同步抛出错误直接reject reject(error); } } // then方法骨架 then(onFulfilled, onRejected) { // 规范2.2.1: 参数可选且如果不是函数需忽略实现穿透 onFulfilled typeof onFulfilled function ? onFulfilled : value value; onRejected typeof onRejected function ? onRejected : reason { throw reason; }; // 规范2.2.7: then必须返回一个新的promise (promise2) const promise2 new MyPromise((resolve, reject) { // 根据当前promise的状态进行不同的处理 if (this.state FULFILLED) { // 异步执行 setTimeout(() { try { const x onFulfilled(this.value); // 关键根据x的值决定promise2的状态 resolvePromise(promise2, x, resolve, reject); } catch (error) { reject(error); } }, 0); } else if (this.state REJECTED) { setTimeout(() { try { const x onRejected(this.reason); resolvePromise(promise2, x, resolve, reject); } catch (error) { reject(error); } }, 0); } else if (this.state PENDING) { // 如果当前还是pending将回调函数存入队列 this.onFulfilledCallbacks.push(() { setTimeout(() { try { const x onFulfilled(this.value); resolvePromise(promise2, x, resolve, reject); } catch (error) { reject(error); } }, 0); }); this.onRejectedCallbacks.push(() { setTimeout(() { try { const x onRejected(this.reason); resolvePromise(promise2, x, resolve, reject); } catch (error) { reject(error); } }, 0); }); } }); return promise2; } }这里有几个关键点需要注意状态保护resolve和reject函数内部必须检查状态是否为PENDING确保状态不可逆。回调队列使用数组存储回调因为then可以被多次调用promise.then(...); promise.then(...)每个回调都需要在状态改变后执行。错误捕获执行器executor的调用必须用try...catch包裹同步错误直接导致Promise拒绝。参数透传在then方法中我们对onFulfilled和onRejected做了类型判断。如果不是函数我们将其替换为一个默认函数。对于onFulfilled默认函数将接收到的值原样返回实现值穿透对于onRejected默认函数将接收到的原因原样抛出实现错误穿透。这是实现Promise.resolve(1).then().then(console.log)能输出1的关键。异步调度我们暂时用setTimeout(fn, 0)来模拟微任务的异步执行。在实际的Promise实现或更严谨的polyfill中会使用queueMicrotask或MutationObserver。这里用setTimeout是为了让代码更清晰它创建了一个宏任务虽然与真正的微任务时机不同但保证了异步性。4. 灵魂实现Promise解决过程 resolvePromise现在来到最核心的部分——实现resolvePromise函数它对应规范中的[[Resolve]](promise2, x)算法。这个函数决定了promise2的最终命运。/** * Promise解决过程根据x的值来决定promise2的状态 * param {MyPromise} promise2 then方法返回的新Promise * param {*} x onFulfilled或onRejected的返回值 * param {Function} resolve promise2的resolve函数 * param {Function} reject promise2的reject函数 */ function resolvePromise(promise2, x, resolve, reject) { // 规范2.3.1: 如果promise2和x是同一个对象用TypeError拒绝promise2防止循环引用 if (promise2 x) { return reject(new TypeError(Chaining cycle detected for promise #MyPromise)); } // 规范2.3.2 2.3.3: 如果x是一个Promise实例 if (x instanceof MyPromise) { // 如果x处于pendingpromise2必须保持pending直到x被兑现或拒绝 // 如果x已经被兑现/拒绝用相同的值/原因兑现/拒绝promise2 x.then( value resolvePromise(promise2, value, resolve, reject), reason reject(reason) ); } else if (x ! null (typeof x object || typeof x function)) { // 规范2.3.3: 如果x是对象或函数可能是thenable let then; try { // 规范2.3.3.1: 获取x.then then x.then; } catch (error) { // 规范2.3.3.2: 如果取x.then时抛出错误e则以e为拒因拒绝promise2 return reject(error); } // 规范2.3.3.3: 如果then是一个函数 if (typeof then function) { let called false; // 防止resolve和reject被多次调用 try { // 规范2.3.3.3: 用x作为this调用then函数 then.call( x, // 第一个回调成功回调 y { if (called) return; called true; // 递归调用resolvePromise处理可能嵌套的thenable resolvePromise(promise2, y, resolve, reject); }, // 第二个回调失败回调 r { if (called) return; called true; reject(r); } ); } catch (error) { // 规范2.3.3.3.4: 如果调用then方法抛出异常e if (!called) { // 且resolve/reject未被调用过则用e拒绝promise2 reject(error); } // 如果已经调用过则忽略此错误规范2.3.3.3.4.1 } } else { // 规范2.3.3.4 2.3.4: 如果then不是函数或者x不是对象/函数直接用x兑现promise2 resolve(x); } } else { // 规范2.3.4: 如果x不是对象也不是函数直接用x兑现promise2 resolve(x); } }这个函数逻辑复杂但每一步都对应着Promise/A规范的条款。我强烈建议你对照规范原文阅读这段代码。其中最关键的两个设计是防止循环引用开头检查promise2 x避免了const p new Promise(resolve { setTimeout(() resolve(p), 0) })这种无限循环。called标志位在处理thenable对象时这是保证resolve或reject只被调用一次的关键。有些不符合规范的thenable对象可能会既调用成功回调又调用失败回调或者调用多次这个标志位确保了我们的实现遵循了Promise的状态不可变原则。5. 完善与静态方法一个完整的Promise实现还需要一些静态方法和实例方法。让我们来补全它们。5.1 catch与finally方法catch和finally是基于then的语法糖实现起来相对简单。class MyPromise { // ... 之前的constructor和then方法 ... // catch方法只处理错误的快捷方式 catch(onRejected) { return this.then(null, onRejected); } // finally方法无论成功失败都会执行并返回一个Promise // 该Promise会等待回调执行完毕并继承原Promise的状态和值/原因 finally(callback) { return this.then( value MyPromise.resolve(callback()).then(() value), reason MyPromise.resolve(callback()).then(() { throw reason; }) ); } }finally的实现稍微绕一点。它的核心思想是先执行callback然后返回一个Promise这个Promise会“等待”callback执行完成如果callback返回Promise则等待它解决然后再将原Promise的结果值或错误传递下去。注意finally回调不接收任何参数且它返回的Promise的结果与原Promise一致除非回调本身抛出错误或返回一个被拒绝的Promise。5.2 静态方法resolve, reject, all, race静态方法是工具函数不依赖于实例。class MyPromise { // ... 之前的代码 ... static resolve(value) { // 如果传入的就是一个MyPromise实例直接返回 if (value instanceof MyPromise) { return value; } // 如果传入的是一个thenable对象需要展开它 if (value typeof value.then function) { return new MyPromise((resolve, reject) { value.then(resolve, reject); }); } // 否则包装成一个已兑现的Promise return new MyPromise(resolve { resolve(value); }); } static reject(reason) { // 直接返回一个已拒绝的Promise注意reject不会展开thenable return new MyPromise((_, reject) { reject(reason); }); } static all(promises) { return new MyPromise((resolve, reject) { if (!Array.isArray(promises)) { return reject(new TypeError(Argument must be an array)); } const results []; let completedCount 0; const total promises.length; if (total 0) { return resolve(results); } promises.forEach((promise, index) { // 用MyPromise.resolve包装确保处理的是Promise MyPromise.resolve(promise).then( value { results[index] value; // 按索引存储结果保证顺序 completedCount; if (completedCount total) { resolve(results); } }, reject // 任何一个失败整个all就失败 ); }); }); } static race(promises) { return new MyPromise((resolve, reject) { if (!Array.isArray(promises)) { return reject(new TypeError(Argument must be an array)); } promises.forEach(promise { // 用MyPromise.resolve包装谁先改变状态就采纳谁的状态 MyPromise.resolve(promise).then(resolve, reject); }); }); } // 可选的 allSettled 和 any 方法ES2021引入 static allSettled(promises) { return new MyPromise(resolve { if (!Array.isArray(promises)) { return reject(new TypeError(Argument must be an array)); } const results []; let settledCount 0; const total promises.length; if (total 0) { return resolve(results); } const processResult (index, status, valueOrReason) { results[index] status fulfilled ? { status, value: valueOrReason } : { status, reason: valueOrReason }; settledCount; if (settledCount total) { resolve(results); } }; promises.forEach((promise, index) { MyPromise.resolve(promise).then( value processResult(index, fulfilled, value), reason processResult(index, rejected, reason) ); }); }); } }在实现all和race时一个非常重要的细节是我们需要用MyPromise.resolve()对数组中的每一项进行包装。这是因为用户传入的数组元素可能不是Promise实例而是一个普通值或thenable对象。Promise.resolve()会将其规范化为一个标准的Promise这样我们的逻辑才能一致地处理。6. 测试与常见问题排查代码写完了但怎么知道它是否正确呢Promise/A官方提供了一个测试套件promises-aplus-tests。为了让我们的实现能运行这个测试需要添加一个简单的适配器。// 在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;然后安装测试套件并运行npm install promises-aplus-tests -D创建一个测试文件运行它。通过所有872项测试才能证明你的实现基本符合规范。在实际手写和调试过程中你肯定会遇到各种问题。下面是一些典型问题和排查思路问题现象可能原因排查与解决思路.then链中值没有穿透then方法中未正确处理非函数的onFulfilled/onRejected参数。检查then方法开头是否将非函数参数转换成了value value和reason { throw reason }。循环引用导致无限递归resolvePromise函数未检查promise2 x。确保在resolvePromise函数最开头添加循环引用检查并reject一个TypeError。Promise.all结果顺序错乱在all方法中未按索引存储异步任务的结果。使用results[index] value来存储而不是results.push(value)因为后者会按完成顺序而非原顺序存储。then回调执行时机不对同步执行未将回调函数包装成异步任务。在状态改变后执行回调时必须使用异步机制如setTimeout、queueMicrotask。确保在FULFILLED/REJECTED分支和PENDING的回调队列执行中都进行了异步包装。resolve一个Promise时新Promise状态未正确采纳resolvePromise中处理x instanceof MyPromise的逻辑有误。当x是Promise时应调用x.then并在其状态改变后用改变后的值或原因继续递归调用resolvePromise。错误被“吞掉”没有触发catch在then方法中未对onFulfilled/onRejected的执行进行try...catch。确保在调用用户传入的回调函数时用try...catch包裹并在catch中reject(promise2)。这是Promise内部将同步错误转化为异步拒绝的关键。一个重要的实操心得在实现resolvePromise时called标志位的使用是极易出错的地方。它必须在then.call之前定义并且在两个回调函数成功和失败内部都要检查。我最初实现时曾将called定义在try块内部导致在回调函数中访问不到正确的闭包变量造成了奇怪的多次调用问题。务必确保它的作用域覆盖整个thenable处理流程。7. 从原理回看那些“诡异”的报错现在让我们用刚刚获得的知识重新审视那些常见的、令人困惑的Promise报错信息你会发现它们变得一目了然。Uncaught (in promise) TypeError: Cannot read properties of undefined (reading xxx)这个错误通常发生在你的.then链中。例如getUserData() // 假设这个函数返回一个Promise .then(user user.profile) // 如果user是null或undefined这里就会报错 .then(profile console.log(profile.name));当user为undefined时user.profile会抛出TypeError。由于这个错误是在then的成功回调中同步抛出的根据Promise规范它会自动被捕获并导致当前then返回的那个新Promise被拒绝。如果这个被拒绝的Promise后面没有.catch()处理浏览器就会报告一个“未捕获的in promise”错误。解决方法很简单要么在前一个.then中做好判空要么在链的末尾加上.catch()。Uncaught (in promise) Error: A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received这个错误信息看起来比较特定常见于浏览器扩展Chrome Extension的通信API中。其本质是你调用了一个返回Promise的API例如chrome.runtime.sendMessage并且在其回调中通过return true表明将异步响应但最终你没有调用sendResponse回调函数来resolve或reject那个Promise导致消息通道关闭后Promise永远处于pending状态最终被垃圾回收或超时机制标记为未处理的拒绝。从Promise角度看这就是一个既没有resolve也没有reject最终被底层系统判定为失败的Promise。解决方案是确保异步逻辑中最终调用了sendResponse。Uncaught (in promise) Error: Could not establish connection. Receiving end does not exist.这个错误与上一个类似也常见于扩展通信。它意味着你试图向一个不存在的上下文如已关闭的标签页发送消息导致返回的Promise直接被拒绝。这提醒我们在使用基于Promise的API时一定要处理被拒绝的情况不能假设通信永远成功。理解这些错误的本质后你的调试思路就会从“这行代码为什么报错”转变为“这个Promise链在哪个环节被拒绝了原因是什么”。你会自然而然地想到使用.catch()来捕获并打印错误或者使用async/await配合try...catch来获得更清晰的堆栈信息。手写一遍Promise就像给JavaScript的异步世界画下了一张精确的地图。你再看到then、catch、Promise.all这些API时脑海里浮现的不再是模糊的概念而是清晰的状态转换、回调队列和微任务调度。这份理解能让你在编写复杂异步逻辑时更有底气在调试诡异bug时更快定位。它不仅仅是为了应付一次面试更是为了成为一名真正理解自己所用工具的开发者。
返回列表