ARTICLE DETAIL

资讯详情

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

手写Promise:从状态机到异步编程的底层逻辑

手写Promise:从状态机到异步编程的底层逻辑 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 - fulfilled和pending - 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经典方案是用MutationObserver或process.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执行器接收resolve和reject两个函数作为参数。我们的构造函数需要初始化状态为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方法接收两个参数onFulfilled和onRejected分别处理成功和失败。核心逻辑是如果当前状态是pending就把回调存进列表如果是fulfilled或rejected就把回调放进微任务队列异步执行。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没有传对应回调时需要实现值穿透——成功值会一路传递到下一个then的onFulfilled失败原因会一路传递到下一个then的onRejected。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的回调返回qq又在等自己完成。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转换一次所以resolve和reject里都要做状态判断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传第二个参数异常会穿透到下一个then的onRejected或catch。我们的实现里通过参数归一化保证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.all、Promise.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这些默认方法要不要手写我的建议是race和all值得手写因为它们涉及到异步竞态控制的本质allSettled和any可以看一眼就过它们更像是业务工具而不是原理挑战。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共享同一个resolve和reject谁先触发谁决定最终状态。这里有一个面试常问的变型题Promise.race能实现请求超时中断吗答案是不能真正中断底层请求只能先返回超时结果底层Promise的处理结果仍然会在某个时刻被结算但不会再影响race的结果。理解了race共享resolve的机制这个问题的答案自然就浮现出来了。5.3 async/await与手写Promise的关系async/await本质上是Promise的语法糖。async函数返回的本身就是一个Promiseawait会把后面的值包成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) ); }await和then的异常捕获在语义上完全一致理解了then的reject分支处理逻辑自然就理解了try/catch为什么能捕获await的异常。6. 手写Promise的价值边界与学习建议手写Promise这件事我在不同阶段有过不同的体会。最开始学习的时候我觉得这就是个面试表演项目把代码背下来就行。后来在项目里排查线上问题时遇到了一个因为Promise回调执行顺序导致的竞态bug查了很久才发现是对微任务调度理解不透彻。那时候才真正明白手写Promise的价值不在于能把代码默写出来而在于通过实现去建立对异步执行机制的空间想象力。6.1 手写到什么程度算合格我给自己定的标准有三个层次。第一个层次是能把状态机、then链、值穿透实现出来能通过最基本的测试场景第二个层次是能说清楚每个设计决策的理由——为什么then要用微任务而不是同步执行为什么要递归吸收thenable为什么Promise的状态不可变第三个层次是能根据实际场景扩展出Promise.all、race、超时控制、重试机制这些衍生工具。大部分人的问题在于直接从第一层跳到背代码跳过了第二层。面试官真正想听的其实不是代码本身而是那些为什么。6.2 一次解决一类问题举一反三的学习路径学完手写Promise后我强烈建议沿着这条线往下深挖两个方向。一个方向是事件循环机制把宏任务、微任务、渲染时机放在一起看这样你就知道setTimeout(fn, 0)和queueMicrotask(fn)在任务队列里的位置差距有多大也就理解了为什么Vue的nextTick要用微任务来实现。另一个方向是异步流程控制模式手写一个带并发数限制的asyncPool、一个可取消的Promise——这些在真实项目里的出场率远比想象中高。理解了Promise之后再看这些工程实现你会发现自己不再需要硬背API而是能根据底层机制推导出正确行为。这才是手写中间件代码真正的意义。
返回列表