尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

深入理解JavaScript异步编程:手写Promise核心原理与实现

深入理解JavaScript异步编程:手写Promise核心原理与实现 1. 项目概述为什么面试官总爱考手写Promise如果你是一名前端开发者或者正在准备前端相关的面试那么“手写一个符合Promises/A规范的Promise”这道题你大概率是绕不过去的。它就像一道经典的门槛题频繁出现在从初级到高级的面试中。很多朋友第一次听到这个要求时心里可能会犯嘀咕“现在各种库和原生Promise那么好用为什么非要我手写一遍这不是在考八股文吗”其实不然。面试官让你手写Promise其核心目的绝不是为了让你去重复造轮子而是想通过这个精巧的“麻雀”来解剖你对JavaScript异步编程、事件循环、函数式编程乃至软件设计原则的深刻理解。Promise是现代JavaScript异步处理的基石从async/await的语法糖到fetchAPI再到各种前端框架的状态管理底层都离不开Promise的思想。能清晰、健壮地实现一个Promise意味着你真正吃透了单线程JavaScript处理并发任务的精髓理解了什么是“状态不可逆”、什么是“值的穿透与传递”、什么是“微任务的调度”。我自己在带团队和面试时也常把这道题作为必考题。一个候选人如果能流畅地写出核心逻辑并清晰地解释then方法的链式调用、错误冒泡以及resolvePromise函数中那些复杂的递归处理那么他对前端核心机制的掌握程度通常不会差。这篇文章我就结合自己多年的开发和面试经验带你从零开始手把手实现一个工业级强度、通过Promises/A所有测试的Promise。我们不仅会写出代码更会深入每一个设计选择背后的“为什么”并分享实际编码中那些容易翻车的“坑”。准备好了吗让我们开始这场深入核心的旅程。2. 深入Promise核心状态机与基础架构设计在动手写代码之前我们必须像建筑师看蓝图一样先彻底理解Promise的核心规范。Promises/A规范并不长但它用精炼的语言定义了一个Promise对象必须遵循的行为。我们的实现将严格遵循此规范。2.1 Promise的三大状态与不可变性这是Promise最核心的概念也是其名字“承诺”的由来——一个承诺一旦落定fulfilled或rejected就不可更改。pending等待中初始状态。既没有被兑现也没有被拒绝。fulfilled已兑现意味着操作成功完成。此时Promise拥有一个不可变的值value。rejected已拒绝意味着操作失败。此时Promise拥有一个不可变的理由reason。状态的不可变性immutability是Promise可靠性的基石。想象一下如果你发起一个网络请求基于返回的Promise更新了UI但这个Promise的状态之后还能被其他代码偷偷改变那整个应用的状态将变得完全不可预测调试将是噩梦。因此在我们的实现中状态this.state和结果this.value或this.reason一旦从pending转变就必须被锁定任何后续的resolve或reject调用都应当被忽略。2.2 构造函数与执行器Executor的职责我们通过new Promise(executor)来创建一个Promise实例。这里的executor是一个函数它会被立即同步执行。这个设计体现了“承诺的开始是主动的”这一思想。executor接收两个函数作为参数resolve和reject。我们的实现需要将这两个函数暴露给使用者。class MyPromise { constructor(executor) { // 初始化状态和结果 this.state pending; this.value undefined; this.reason undefined; // 定义resolve和reject函数 const resolve (value) { // 状态锁只有pending状态能转变 if (this.state pending) { this.state fulfilled; this.value value; // 这里未来需要触发所有已注册的onFulfilled回调暂未实现 } }; const reject (reason) { if (this.state pending) { this.state rejected; this.reason reason; // 这里未来需要触发所有已注册的onRejected回调暂未实现 } }; // 关键立即执行executor并做好错误捕获 try { executor(resolve, reject); } catch (error) { // 如果executor执行时抛出同步错误直接reject这个Promise reject(error); } } }这里有一个非常重要的实操心得必须在executor的执行外面包裹try...catch。因为规范规定如果executor内部抛出同步错误那么这个Promise必须被拒绝rejected并且错误原因就是抛出的那个值。很多初级实现会漏掉这一步导致同步错误无法被Promise捕获从而破坏整个错误处理链条。2.3 异步回调的存储队列实现 then 的基石then方法是Promise的灵魂它负责订阅状态变化。但这里有一个关键问题then方法可以被调用多次也可以在Promise状态已经改变之后再被调用。为了处理这两种情况我们需要引入回调队列。当Promise还处于pending状态时调用then方法我们并不能立即执行回调因为结果还不知道。我们必须先把用户传入的onFulfilled和onRejected回调函数存起来。等到未来某个时刻resolve或reject被调用时再依次执行这些存储起来的回调。因此我们需要在构造函数中初始化两个队列constructor(executor) { this.state pending; this.value this.reason undefined; // 新增用于存储pending状态下订阅的回调 this.onFulfilledCallbacks []; this.onRejectedCallbacks []; // ... 其余代码 }相应地resolve和reject函数也需要更新在状态改变后异步地清空对应的回调队列。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()); } };注意这里我们只是把回调函数推入微任务队列通过queueMicrotask或setTimeout(fn, 0)模拟但还没有真正执行它们。真正的执行逻辑包括复杂的返回值处理、新的Promise生成等将在then方法中实现。这里先建立“存储-触发”的机制。3. 实现 then 方法链式调用的魔法then方法是Promise中最复杂、最核心的部分。它需要处理链式调用、值穿透、错误冒泡以及返回新Promise等复杂逻辑。我们一步步来拆解。3.1 then 方法的基本形态与返回值规范规定then方法接收两个可选参数onFulfilled和onRejected都是函数。它必须返回一个新的Promise对象我们称之为promise2。这是实现链式调用p.then(...).then(...)的基础。then方法的核心逻辑是一个状态机如果当前Promise是fulfilled状态则异步执行onFulfilled(this.value)。如果当前Promise是rejected状态则异步执行onRejected(this.reason)。如果当前Promise是pending状态则将onFulfilled和onRejected包装后分别存入对应的回调队列。首先我们处理参数的可选性。规范说如果onFulfilled不是函数它必须被忽略并且需要实现“值穿透”——即上一个Promise的value会直接传递给下一个Promise。then(onFulfilled, onRejected) { // 参数校验与默认值设置实现值穿透 onFulfilled typeof onFulfilled function ? onFulfilled : value value; onRejected typeof onRejected function ? onRejected : reason { throw reason; }; // 返回新的Promise (promise2) const promise2 new MyPromise((resolve, reject) { // 封装一个通用的处理函数 const handleCallback (callback, valueOrReason, isFulfilled) { // 使用queueMicrotask模拟微任务异步执行 queueMicrotask(() { try { const x callback(valueOrReason); // 执行用户回调 // 关键处理回调的返回值x它可能是一个值也可能是一个Promise resolvePromise(promise2, x, resolve, reject); } catch (error) { // 如果回调执行抛出同步错误则reject promise2 reject(error); } }); }; if (this.state fulfilled) { // 当前Promise已成功异步执行onFulfilled handleCallback(onFulfilled, this.value, true); } else if (this.state rejected) { // 当前Promise已失败异步执行onRejected handleCallback(onRejected, this.reason, false); } else if (this.state pending) { // 当前Promise未决订阅回调 this.onFulfilledCallbacks.push(() handleCallback(onFulfilled, this.value, true)); this.onRejectedCallbacks.push(() handleCallback(onRejected, this.reason, false)); } }); return promise2; }这里有几个关键点异步执行无论当前Promise状态如何onFulfilled或onRejected的回调都必须在新一轮的微任务或任务中执行。我们用queueMicrotask来模拟在不支持的环境下可用setTimeout(fn, 0)替代但严格来说setTimeout属于宏任务。这保证了then回调总是在当前同步代码执行完毕后才运行符合事件循环机制。错误捕获回调函数本身的执行必须被try...catch包裹任何同步错误都会导致返回的promise2被拒绝。resolvePromise函数这是整个实现中最复杂的部分用于处理回调返回值x。x可以是一个普通值也可以是一个thenable对象比如另一个Promise。我们需要根据Promises/A规范第2.3条递归地解析它。3.2 灵魂函数resolvePromise 的实现resolvePromise(promise2, x, resolve, reject)函数是连接两个Promise的桥梁。它的职责是根据x的值决定如何“解决”promise2。规范第2.3条详细定义了这个过程其核心目的是为了兼容各种Promise实现以及处理循环引用。我们来一步步实现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 如果 x 是一个 Promise 实例则采用它的状态 if (x instanceof MyPromise) { // 如果 x 处于 pendingpromise2 必须保持 pending 直到 x 被解决 // 如果 x 已经被解决/拒绝则用相同的值/理由解决/拒绝 promise2 x.then( value resolvePromise(promise2, value, resolve, reject), reason reject(reason) ); return; } // 2.3.3 如果 x 是一个对象或者函数 if ((typeof x object x ! null) || typeof x function) { 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 { // 用 x 作为 this 调用 then 函数 then.call( x, // 第一个回调成功回调 y { if (called) return; called true; // 递归解析 y resolvePromise(promise2, y, resolve, reject); }, // 第二个回调失败回调 r { if (called) return; called true; reject(r); } ); } catch (error) { // 2.3.3.3.4 如果调用 then 方法抛出异常 e if (!called) { reject(error); } // 如果已经调用过则忽略此错误 (2.3.3.3.4.1) } } else { // 2.3.3.4 如果 then 不是函数用 x 解决 promise2 resolve(x); } } else { // 2.3.4 如果 x 不是对象或函数用 x 解决 promise2 resolve(x); } }这个函数是面试中的绝对重点也是手写Promise的难点所在。面试官可能会追问为什么要有循环引用检测避免promise.then(() promise)这种无限等待的死锁情况。called标志位是干什么的这是一个安全锁。规范要求对于thenable对象其then方法可能既调用resolve又调用reject或者被调用多次。我们的实现必须保证promise2的状态只改变一次。called标志位确保了这一点。为什么解析y时要递归调用resolvePromise因为y可能仍然是一个thenable或Promise需要继续解析直到得到一个非thenable的最终值。把这个函数理解透彻你对Promise“解决过程”的理解就超过了90%的开发者。4. 完善静态方法与实例方法一个完整的Promise实现除了构造函数和then还需要提供一些常用的静态方法如Promise.resolve,Promise.reject,Promise.all,Promise.race和实例方法如catch,finally。这些方法都可以基于我们已经实现的then方法来构建。4.1 静态方法实现静态方法是直接挂在类上的工具函数。MyPromise.resolve: 快速创建一个已解决的Promise。如果参数是Promise则返回它如果是thenable则跟随其状态否则创建一个以该值为结果的fulfilled Promise。static resolve(value) { // 如果value已经是MyPromise实例直接返回 if (value instanceof MyPromise) { return value; } // 否则包装成一个新的fulfilled Promise return new MyPromise((resolve) { resolve(value); }); }MyPromise.reject: 快速创建一个已拒绝的Promise。它不区分参数类型总是创建一个以该参数为拒绝理由的Promise。static reject(reason) { return new MyPromise((_, reject) { reject(reason); }); }MyPromise.all: 接收一个Promise可迭代对象返回一个新的Promise。当所有输入的Promise都成功时它才成功结果是一个数组如果任何一个输入Promise失败它立即失败。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).then( value { results[index] value; // 按顺序保存结果 completedCount; if (completedCount total) { resolve(results); } }, reason { // 任何一个失败立即拒绝整个Promise reject(reason); } ); }); }); }注意事项Promise.all的实现有几个细节1. 参数校验。2. 处理空数组应立即解决。3. 使用Promise.resolve包装每个元素确保处理的是Promise。4. 结果数组必须按传入顺序保存而不是按完成顺序。MyPromise.race: 接收一个Promise可迭代对象返回一个新的Promise。其状态由第一个“落定”无论成功失败的输入Promise决定。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(promise).then(resolve, reject); }); }); }4.2 实例方法实现实例方法是基于then的语法糖让代码更简洁。catch: 只处理拒绝情况的简写。promise.catch(onRejected)等价于promise.then(null, onRejected)。catch(onRejected) { return this.then(null, onRejected); }finally: ES2018引入的方法。无论Promise最终是成功还是失败finally的回调函数都会被执行。并且它返回一个Promise这个Promise会“跟随”原Promise的状态和值。finally(callback) { return this.then( value MyPromise.resolve(callback()).then(() value), reason MyPromise.resolve(callback()).then(() { throw reason; }) ); }finally的实现比较巧妙它先执行回调callback并确保callback返回的Promise被解决用Promise.resolve包装然后再将原Promise的值或错误原因传递下去。如果callback返回一个被拒绝的Promise或抛出错误那么这个错误会覆盖原Promise的结果。5. 测试与验证确保你的Promise坚如磐石代码写完了但你怎么知道它是对的尤其是resolvePromise那种复杂的逻辑靠人眼检查很容易出错。这里我们必须借助自动化测试。5.1 使用Promises/A官方测试套件Promises/A组织提供了一个官方的测试套件promises-aplus-tests。这是检验你的Promise实现是否合规的“金标准”。首先安装测试包npm install promises-aplus-tests -D然后在你的MyPromise实现文件中需要暴露一个deferred方法或适配器供测试套件调用。// 在MyPromise类定义之后 MyPromise.deferred function() { const result {}; result.promise new MyPromise((resolve, reject) { result.resolve resolve; result.reject reject; }); return result; }; // 如果是CommonJS环境导出供测试 module.exports MyPromise;最后创建一个测试文件如test.jsconst MyPromise require(./my-promise.js); // 你的实现文件 const adapter { resolved: MyPromise.resolve, rejected: MyPromise.reject, deferred: MyPromise.deferred, }; // 运行所有测试 const promisesAplusTests require(promises-aplus-tests); promisesAplusTests(adapter, function (err) { if (err) { console.error(测试失败:, err); process.exit(1); } else { console.log(所有测试通过); } });运行node test.js如果看到几百个测试用例一个个通过最终显示“所有测试通过”那么恭喜你你的Promise实现已经达到了工业级标准。5.2 常见边界案例自测除了官方测试自己也要思考一些边界情况这往往是面试官追问的地方穿透测试MyPromise.resolve(1) .then(2) .then(console.log); // 应该输出 1而不是 2循环引用测试const p MyPromise.resolve().then(() p); p.catch(e console.log(e.toString())); // 应该捕获到 TypeError: Chaining cycle detectedthenable对象测试const thenable { then: function(resolve, reject) { resolve(from thenable); } }; MyPromise.resolve(thenable).then(console.log); // 应输出 from thenable异步顺序测试console.log(script start); const p new MyPromise(resolve { console.log(executor); resolve(result); }); p.then(value console.log(then 1:, value)); p.then(value console.log(then 2:, value)); console.log(script end); // 正确输出顺序应为 // script start // executor // script end // then 1: result // then 2: result确保你的then回调是异步执行的微任务。6. 面试实战与深度问题剖析当你能够流畅地写出并通过测试后面试官可能会从代码实现层面深入到设计思想和应用场景。准备好回答以下问题能让你从“会写”提升到“精通”。6.1 Promise如何解决回调地狱这是Promise被创造出来的首要原因。回调地狱Callback Hell不仅仅是代码缩进难看更深层的问题是控制反转将执行流程的控制权交给了第三方函数难以信任。错误处理困难每个回调都需要单独处理错误容易遗漏。代码难以复用和组合。Promise通过then方法链式调用的方式将异步操作扁平化。每个then返回一个新的Promise使得异步操作可以像同步操作一样顺序书写。更重要的是错误可以通过链式末尾的一个.catch统一捕获实现了“冒泡”机制。6.2 Promise的局限性是什么没有银弹Promise也有其缺点理解这些能体现你的思考深度无法取消一旦Promise被创建就无法中途取消。这是一个设计上的取舍但确实在某些场景如超时控制下带来不便通常需要配合AbortController等额外机制。进度无法追踪Promise只有“未完成”和“已完成”两种状态没有“进行中”并报告进度的能力。对于文件上传等需要进度提示的场景Promise本身不提供支持。错误被“吞噬”如果不加.catchPromise内部的错误可能会被静默忽略只在控制台产生一个Uncaught (in promise)警告这在复杂应用中容易造成调试困难。单一值一个Promise只能resolve一个值。虽然可以用对象或数组包装多个值但不如一些响应式库如RxJS的流模型灵活。6.3 手写题常考变种与扩展面试官有时不会让你写完整的Promise而是考察某个核心片段的理解实现Promise.allSettled与all不同它会等待所有Promise落定无论成功失败返回一个描述每个Promise结果的对象数组。实现带并发限制的Promise.map给定一个数组和异步函数fn要求用Promise并发执行fn但同时运行的并发数不能超过N。解释Promise.then的第二个参数与.catch的区别p.then(success, error); // 写法一 p.then(success).catch(error); // 写法二写法一中error回调只能处理p的拒绝。如果success回调本身抛出错误或返回一个被拒绝的Promise这个错误无法被这个error捕获。 写法二中.catch位于链式调用的末尾它能捕获链路上任何一个环节包括p的拒绝以及success回调中产生的错误抛出的错误。因此在大多数情况下更推荐使用写法二的模式进行统一的错误处理。7. 从手写到理解构建你的异步知识体系手写Promise不仅仅是一道面试题它更像一把钥匙帮你打开理解现代JavaScript异步编程的大门。当你透彻理解了Promise再去学习async/await就会觉得豁然开朗——它们本质上就是Promise的语法糖用同步的写法管理异步的流程。更进一步你可以思考Generator Promise如何实现类似async/await的效果Node.js中的EventEmitter与Promise的异步模型有何异同前端框架如React、Vue的状态管理库Redux, Pinia中异步action是如何借助Promise或类似概念处理的把这些点连成线构成你自己的知识网络。下次面试官再问你“手写Promise”时你不仅可以写出代码更能从事件循环、微任务队列、函数式编程、设计模式等多个维度阐述其设计精妙之处。这才是这道题真正想考察的——你是否具备了透过语法看本质深入理解语言运行机制的能力。
返回列表