资讯详情

手写Promise:从事件循环到原型链,彻底拆解JavaScript异步机制

📅 2026/10/16 10:50:21 | 华诺云谱 👁 阅读
手写Promise:从事件循环到原型链,彻底拆解JavaScript异步机制
最近一个月我都在和JavaScript语言较劲。起因是某项目老代码里有一段三层嵌套回调的业务逻辑线上数据偶尔会错乱每次排查都要花大半天。为了彻底根治这个问题我选择用最简单的方式去“拆”它手写一个Promise把JavaScript语言里的异步机制、事件循环、原型链和this绑定从头到尾实现一遍。这篇文章是这次实践的完整记录。如果你正在学前端或者写了几年JavaScript却总觉得哪里没想透这篇文章应该能帮你把很多知识点串起来。我会尽量用大白话讲清楚每个设计背后的为什么同时给出可以直接跑起来的示例代码。也说明一下这不是一篇标准的Promise源码解析而是我以“手写”为主线把JavaScript语言的核心知识重新过了一遍后沉淀下来的东西。篇幅不短但每一段都有可落地的代码或真实踩坑记录值得耐心读下来。1. 为什么值得把JavaScript语言底层拆开看1.1 从一次线上事故说起先讲我遇到的事。某个老项目中用户上传文件后会触发一条数据变更流程。最初的实现是文件上传成功回调里接着去请求用户信息拿到用户信息后再请求权限列表最后才更新页面。三层回调嵌套每一层都依赖上一层的数据。某天晚上权限列表接口偶发变慢导致更新页面的逻辑被延迟但上游回调里已经改了一个全局变量于是页面渲染出了另一个用户的权限菜单。这种“数据错乱”的根源不是某个函数写错了而是JavaScript语言的异步执行顺序和我的直觉不一致。当时我第一反应是补一个布尔标志位后来发现完全没用因为回调触发顺序不是代码书写顺序。我又尝试用Promise.all把所有接口合并结果因为某个接口超时导致全部失败问题反而更严重了。我意识到如果不把事件循环、微任务、状态机这些底层机制搞清楚以后还会踩类似坑。于是那个周末我推掉所有安排把手头所有异步相关代码做了个复盘最后决定自己动手实现一个小型Promise。这个决定让我对JavaScript语言的异步机制有了完全不同的感知。1.2 搞懂核心机制的价值很多教程会说JavaScript语言是单线程、非阻塞、事件驱动但真正理解这句话的人不多。我自己写业务代码的前两年其实也只会用setTimeout、Promise.then。直到那次事故我才开始认真看调用栈、任务队列、渲染帧之间的联合调度。可以把主线程理解成一个只有一个店员的咖啡店。如果某个顾客的订单需要磨咖啡豆加打奶泡店员不会让其他顾客一直干等着而是会先把这件事交代给旁边的机器然后继续接下一单。JavaScript语言也是这样遇到耗时的IO、定时器、网络请求不会僵在那里而是把后续逻辑封装成回调放进对应的任务队列等到合适的时机再回来执行。搞懂这些之后再看异步代码脑子里会自然浮现出“此刻这个函数被放进哪个队列、什么时候才会被执行”的画面。你在setTimeout、Promise、事件处理之间切换时也不再靠试错来猜执行顺序。看着调用栈和队列能少写很多“玄学bug”。这篇文章适合正在学习前端、准备面试、或者写了不少代码却总被异步bug折磨的人。你不需要一开始就把所有源码都读完只要跟着我这次手写Promise的实践把几个关键概念串起来很多困惑会迎刃而解。1.3 我选择手写Promise的理由想理解JavaScript语言的异步最直接的方法是动手实现一个迷你Promise。Promise本身是语言提供的一个类但它的设计里浓缩了状态机、回调队列、微任务调度、错误处理等多个机制。自己写一遍远比看十篇“Promise用法”文章有效。手写Promise的过程会逼你回答几个问题为什么then还能链式调用resolve执行后曾经注册的回调要怎么激活回调里抛异常谁来接为啥Promise的执行顺序和setTimeout不一样这些问题如果只背概念很容易混。但当你真的把代码跑起来看到状态从pending变成fulfilled看到回调经过微任务队列再被取出一切都踏实了。接下来我会直接进入手写环节先实现一个能跑通基本链式调用的学习版SimplePromise。提示这个简化版Promise不是标准Promise的完整替代品它省略了Promise Resolution Procedure里最复杂的“递归采纳”逻辑但保留了对理解语言核心最重要的骨架。2. 手写一个Promise吃透异步与事件循环2.1 Promise的状态机与then链Promise的本质是一个状态机。它有三个状态pending初始状态等待中fulfilled操作完成并有一个结果值rejected操作失败并有一个失败原因状态只能从pending变到fulfilled或rejected一旦改变就不可逆。也就是说你不能让一个已经成功的结果再去变成失败也不能从失败再回到成功。这个设计很像生活中点外卖先下单pending要么商家接单后正常送达fulfilled要么商家取消rejected不会出现先送达再回到下单状态。状态迁移是Promise回调链能保持稳定的基础。我们在手写实现时第一步就是定义状态并在状态改变时执行对应的回调集合。如果你画一个简单的状态迁移图只有两条路径pending→fulfilledpending→rejected。没有第三条路。为什么要有状态机因为它把“结果”和“后续动作”解耦了。在回调风格里你必须在请求函数内部直接处理结果而Promise则允许你先把不同状态下的动作挂载起来等状态确定后再统一调度。这也为链式调用铺平了道路每个then都基于当前Promise的状态来决定下一步动作。2.2 微任务实现的三个关键细节Promise的回调不是同步执行的也不是普通宏任务。它被放进“微任务队列”microtask queue。微任务的执行时机是当前宏任务执行完、浏览器渲染前把当前微任务队列清空。这也是为什么下面这段代码的输出顺序是固定的console.log(1); Promise.resolve().then(() console.log(2)); console.log(3); // 输出顺序是 1、3、2第一段宏任务先执行完同步代码1和3随后才执行微任务2。如果这里的then回调是同步执行结果就会是1、2、3但Promise设计故意选择了“异步回调”目的是让执行过程更可控避免在一个循环中反复触发回调导致死循环。三个关键细节必须记住微任务总是优先于下一个宏任务即使setTimeout延时是0也会排在Promise回调之后。微任务队列会被一次性清空如果某个微任务又创建了新微任务新任务也会立即被执行直到队列为空。浏览器渲染被安排在微任务之后所以不要在微任务里做耗时操作否则会卡住渲染。可以用一段代码验证setTimeout(() console.log(a), 0); Promise.resolve() .then(() console.log(b)) .then(() console.log(c)); console.log(d); // 输出 d、b、c、a很多面试题会让你分析这类输出理解了微任务队列直接套“先同步、再清空微任务、最后宏任务”就能秒答。我在手写Promise时也正是因为知道回调必须进入微任务才没有简单地在resolve方法里直接同步调用handler。2.3 完整代码和逐步解读下面是我写的学习版SimplePromise。为了让大家看清核心我省略了标准Promise的Resolution Procedure里对then返回值的嵌套解析保留了状态管理和回调链主结构class SimplePromise { constructor(executor) { this.state pending; this.value undefined; this.handlers []; try { executor(this._resolve.bind(this), this._reject.bind(this)); } catch (e) { this._reject(e); } } _resolve(value) { if (this.state ! pending) return; this.state fulfilled; this.value value; this._flush(); } _reject(reason) { if (this.state ! pending) return; this.state rejected; this.value reason; this._flush(); } _flush() { const handlers this.handlers.splice(0); handlers.forEach(handler { queueMicrotask(() { if (this.state fulfilled) { handler.onFulfilled handler.onFulfilled(this.value); } else { handler.onRejected handler.onRejected(this.value); } }); }); } then(onFulfilled, onRejected) { return new SimplePromise((resolve, reject) { const handler { onFulfilled: (val) { if (typeof onFulfilled ! function) { resolve(val); return; } try { const result onFulfilled(val); resolve(result); } catch (e) { reject(e); } }, onRejected: (reason) { if (typeof onRejected ! function) { reject(reason); return; } try { const result onRejected(reason); resolve(result); } catch (e) { reject(e); } } }; if (this.state pending) { this.handlers.push(handler); } else { // 状态已经改变放入微任务执行 queueMicrotask(() handler.onFulfilled(this.value)); } }); } }逐行说明几个关键点构造函数中executor会被立即同步执行。这是Promise设计的一个特点所以我们在构造函数里调用executor并传入resolve和reject。为了防止executor内部抛错用try/catch包起来并转为rejected状态。注意我们这里使用了this._resolve.bind(this)是为了确保在executor里直接调用resolve(value)时函数内部的this依然指向SimplePromise实例而不是丢失。状态保护_resolve和_reject开头都判断了this.state ! pending就返回。这个保护保证状态一旦变化后续调用都无效。flush与微任务_flush先把handlers数组整体取出再对每个handler放入微任务队列。这样做的好处是如果回调里新注册了then新的handler不会在当前这一轮被重复执行。then返回新Promise这是链式调用的核心。每次then都会新建一个SimplePromise并把onFulfilled/onRejected包装成新的resolve/reject。这样只要前一个回调里返回了值就会传给后一个then。2.4 为什么then必须返回一个全新Promise这是避坑的关键。如果then直接返回this那么状态已经改变后后面再注册的回调就会被状态保护拦住链式调用无法继续。标准Promise的每次then都创建一个新对象同时保持旧Promise状态不可变。我们在实现里用return new SimplePromise(...)来模拟这个设计也解释了你平时写promise.then(...).then(...)时中间的then为什么不会影响原Promise。举个例子const p new SimplePromise((resolve) { setTimeout(() resolve(10), 10); }); const q p.then(value value 1); console.log(p q); // falsep仍然保持着resolve(10)这个结果q接收的是101。如果直接把q当作p的引用后面状态就乱了。所以每个then都生产一个新对象从语义上隔离了“原始结果”和“派生结果”。2.5 手写版和原生Promise的执行顺序对比为了让你们更直观地理解微任务我写了这样一段对比测试const p new SimplePromise(resolve resolve(手写Promise)); p.then(value console.log(value)); const q Promise.resolve(原生Promise); q.then(value console.log(value)); console.log(同步代码);在支持queueMicrotask的现代浏览器中输出顺序是同步代码 手写Promise 原生Promise两个Promise的回调都排在同步代码之后说明我们手写的版本在执行时机上跟原生的概念是一致的。当然原生Promise还做了很多边界处理但核心机制已经能看到。3. JavaScript语言原型链与this绑定的实际影响3.1 原型链在Promise实现中的应用在SimplePromise中then方法定义在class内部它会被挂到SimplePromise.prototype上。实例p.then(...)能访问这个方法正是因为JavaScript语言的原型链机制对象查找属性时先看自身找不到再沿__proto__一层一层向上找。原型链的好处是公共方法只存一份不占实例内存。坏处是如果随意修改Array.prototype或Object.prototype会造成全局污染。我在实现Promise时也提醒自己不要给内置原型添加额外方法否则在大型工程里很容易出现预测不到的bug。我们可以用最简单的代码验证原型链的存在class A {} const a new A(); console.log(a.__proto__ A.prototype); // true console.log(A.prototype.__proto__ Object.prototype); // true很多开发者觉得原型链是八股文但当你去理解图书馆、框架的插件机制时本质上都是利用原型链做方法查找和覆盖。Promise的then能被子类继承也正是因为它在原型层而不是在每个实例上复制一份。3.2 箭头函数与传统函数的this差异我们在构造函数里写executor(this._resolve.bind(this), this._reject.bind(this))这里必须绑定this否则_resolve被调用时内部的this会指向undefined在严格模式下。类方法默认是严格模式所以丢失this会直接报错。箭头函数没有自己的this它捕获定义时所在作用域的this。所以也可以把resolve和reject定义成箭头函数的实例字段constructor(executor) { this._resolve value this._resolveCore(value); ... }但在class中实例字段和原型方法的写法各有优劣。最稳妥的做法是使用bind或箭头函数。了解这个差异非常重要因为日常开发中很多人会在setTimeout或事件回调里错误地使用function声明导致this指向改变。示例function User(name) { this.name name; setTimeout(function() { console.log(this.name); // 输出 undefined等于window.name }, 100); } new User(张三);改成箭头函数后function User(name) { this.name name; setTimeout(() { console.log(this.name); // 输出 张三 }, 100); }箭头函数的this绑定到User实例。这个差异在处理事件、定时器、数组方法回调时特别重要。你在原生Promise的then回调里如果用普通函数很可能遇到this丢失所以很多时候要么用箭头函数要么提前bind。3.3 现场演示一段代码搞懂call/apply/bind这三种方法都能显式指定函数执行时的this区别在于传参方式func.call(obj, arg1, arg2)逐个传参func.apply(obj, [arg1, arg2])数组传参func.bind(obj)()返回一个绑定了this的新函数不立即执行我在Promise实现中使用的是bind因为它能把this固定为实例同时让executor在构造函数中能立即被调用。function greet(prefix, punctuation) { console.log(prefix this.name punctuation); } const person { name: 李四 }; greet.call(person, 你好, ); greet.apply(person, [你好, ]); const boundGreet greet.bind(person); boundGreet(嗨, ~);三个输出都指向person。理解了这三个方法你就会明白为什么很多库的函数参数喜欢把this作为第一个参数传递。反过来说如果你听到“回调里的this指向不对”第一反应就应该是查看调用时有没有bind、箭头函数绑定或者函数是不是被作为方法调用。3.4 ES6 class语法糖背后的原型关系很多人觉得class是“新东西”扯不到原型链。但class本质只是构造函数的语法糖。class SimplePromise { then() {} }真正执行时还是创建了一个名为SimplePromise的构造函数并把then方法挂到它的prototype属性上。这意味着如果你手工写一个构造函数版本效果是一样的function SimplePromise2(executor) { // ... } SimplePromise2.prototype.then function(onFulfilled, onRejected) { // ... };所以我在用class写的时候始终知道方法在原型对象上。如果想要继承一个Promise类直接用extends就可以因为原型链会把Parent.prototype的方法暴露给实例。理解这一点后看框架里的mixin、插件系统思路会很顺。4. 常见坑点与排查技巧实录4.1 回调地狱的层层嵌套怎么破先看一段典型代码fetchUser(id, function(user) { fetchOrders(user.id, function(orders) { fetchItems(orders[0].id, function(items) { render(items); }); }); });这种写法第一是缩进太深第二是错误处理很难做。用Promise链重构fetchUser(id) .then(user fetchOrders(user.id)) .then(orders fetchItems(orders[0].id)) .then(items render(items)) .catch(err console.error(err));清晰很多。如果再结合async/await甚至可以把异步代码写得像同步逻辑async function loadData() { try { const user await fetchUser(id); const orders await fetchOrders(user.id); const items await fetchItems(orders[0].id); render(items); } catch (e) { console.error(e); } }我在重构老项目时通常先把每一层出入参列成表格再决定用Promise链还是async/await。原则是如果中间有复杂条件分支用async/await更易读如果纯粹是一条流水线then链就行。但不管用哪种都要记得兜住错误不能让异常变成unhandled rejection。4.2 Promise.all与Promise.race的边界情况用Promise.all处理多个并行请求时有一个容易忽略的点空数组会直接resolve返回[]。而如果其中任意一个reject整体就会立即reject其他任务的结果不会等待。这经常导致“整体失败”掩盖了部分成功。对于需要“所有任务都要完成而且都想拿到结果”的场景建议使用Promise.allSettledconst results await Promise.allSettled([ fetchData(1), fetchData(2), fetchData(3) ]);这样每个任务的状态都能拿到再区分处理。另外Promise.race第一个settled的Promise会胜出但它并不会取消其他Promise其他人仍然在后台运行。如果你希望超时后自动中断不合理的逻辑不要指望race取消应在内部使用AbortController。实战里我遇到过这样的问题三个接口并发一个偶尔超时导致Promise.all总是失败。用allSettled之后日志里能看到其余两个接口是成功的再针对超时接口做重试问题就解决了。这告诉我们工具选型要匹配业务目标而不是无脑用all。4.3 异步错误捕获与未处理拒绝未捕获的Promise rejection不会自动触发浏览器全局的error事件。它会默认打印一条警告但在Node环境或旧版本浏览器里可能直接导致进程退出。为此养成两个习惯在所有Promise链终点加.catch()在浏览器中监听unhandledrejection事件window.addEventListener(unhandledrejection, event { event.preventDefault(); console.error(未处理的Promise拒绝, event.reason); });还有一点async函数里的try/catch只能捕获被await的Promise拒绝如果某个Promise没有被await而是在触发后异步失败你的try/catch可能根本接不住。要养成“所有异步入口都有终止处理”的习惯。4.4 内存泄漏排查记录我曾经在某个定时器里创建Promise并在回调中引用了页面上的DOM节点。页面切换后定时器没有清掉DOM节点一直被闭包引用内存占用直线上升。排查时用浏览器性能面板录制看到持续增长的趋势最终定位到定时器和Promise的引用链。看一段反面代码class Page { start() { this.timer setInterval(() { this.loadData().then(() { this.render(); // this引用整个PagePage里又引用DOM }); }, 5000); } stop() { // 忘记clearInterval } }这个Page实例只要还在被定时器引用内部的所有DOM节点都清不掉。解决方法是在组件卸载或路由切换时及时clearInterval和clearTimeout如果确实无法避免长期引用考虑用WeakRef或把对象放在WeakMap中不要在then回调里保存不必要的全局变量对于Promise本身如果创建后没有任何地方保存引用它内部缓存的值可能会被GC回收但如果它一直处于pending且被其他对象引用就可能造成泄漏。所以如果你用永不resolve的Promise作为“耗时判断”一定要确保它的调用方可以被取消。4.5 常见问题速查表下面这张表是我踩坑后的总结能帮你快速定位问题。现象可能原因建议处理回调里的this输出undefined传统函数丢失this改用箭头函数或bindPromise链中一个失败后面不执行没有catch异常被丢弃添加catch或try/await用Promise.all时整体失败某个请求reject改用allSettled定时器里的Promise回调长期不触发定时器未清理确保clearInterval微任务里同步循环导致页面卡顿微任务阻塞渲染拆分到宏任务这张表我一直贴在项目文档里。每遇到异步问题先对号入座再考虑具体代码效率能高不少。5. 最后分享几个排查工具与学习建议如果你也想亲手验证JavaScript语言的异步调度我推荐两个最直观的方法在开发工具中录制Performance面板看看“任务”标签下的绿色块和紫色块宏任务和微任务一目了然。使用断点调试在then回调的入口处打断点查看调用栈的顺序能清楚看到当前微任务队列被清空的时机。我在这次手写Promise之后又把平时常用的async/await用法过了一遍明显感觉不再“玄学”了。每次遇到异步问题我都会先问自己当前代码在哪个队列状态机是否可能变化回调里到底有没有异常被吞掉这种问法极大提升了排查速度。对于一个前端工程师来说JavaScript语言的核心机制可能不是每天都要手写Promise但它决定了你写代码时的判断力。当你真正理解了状态、队列、原型和this很多框架源码看起来也不再是天书。希望这次分享能给你一条值得走下去的“拆”语言的路径。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑