ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

手写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→fulfilled,pending→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设计故意选择了“异步回调”,目的是让执行过程更可控,避免在一个循环中反复触发回调导致死循环。

三个关键细节必须记住:

  1. 微任务总是优先于下一个宏任务:即使setTimeout延时是0,也会排在Promise回调之后。
  2. 微任务队列会被一次性清空:如果某个微任务又创建了新微任务,新任务也会立即被执行,直到队列为空。
  3. 浏览器渲染被安排在微任务之后:所以不要在微任务里做耗时操作,否则会卡住渲染。

可以用一段代码验证:

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); // false

p仍然保持着resolve(10)这个结果,q接收的是10+1。如果直接把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.allSettled:

const 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环境或旧版本浏览器里,可能直接导致进程退出。为此,养成两个习惯:

  1. 在所有Promise链终点加.catch()
  2. 在浏览器中监听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引用整个Page,Page里又引用DOM }); }, 5000); } stop() { // 忘记clearInterval } }

这个Page实例只要还在被定时器引用,内部的所有DOM节点都清不掉。解决方法是:

  • 在组件卸载或路由切换时,及时clearInterval和clearTimeout
  • 如果确实无法避免长期引用,考虑用WeakRef或把对象放在WeakMap中
  • 不要在then回调里保存不必要的全局变量

对于Promise本身,如果创建后没有任何地方保存引用,它内部缓存的值可能会被GC回收,但如果它一直处于pending,且被其他对象引用,就可能造成泄漏。所以如果你用永不resolve的Promise作为“耗时判断”,一定要确保它的调用方可以被取消。

4.5 常见问题速查表

下面这张表是我踩坑后的总结,能帮你快速定位问题。

现象可能原因建议处理
回调里的this输出undefined传统函数丢失this改用箭头函数或bind
Promise链中一个失败,后面不执行没有catch,异常被丢弃添加catch或try/await
用Promise.all时整体失败某个请求reject改用allSettled
定时器里的Promise回调长期不触发定时器未清理确保clearInterval
微任务里同步循环导致页面卡顿微任务阻塞渲染拆分到宏任务

这张表我一直贴在项目文档里。每遇到异步问题,先对号入座,再考虑具体代码,效率能高不少。

5. 最后分享几个排查工具与学习建议

如果你也想亲手验证JavaScript语言的异步调度,我推荐两个最直观的方法:

  • 在开发工具中录制Performance面板,看看“任务”标签下的绿色块和紫色块,宏任务和微任务一目了然。
  • 使用断点调试,在then回调的入口处打断点,查看调用栈的顺序,能清楚看到当前微任务队列被清空的时机。

我在这次手写Promise之后,又把平时常用的async/await用法过了一遍,明显感觉不再“玄学”了。每次遇到异步问题,我都会先问自己:当前代码在哪个队列?状态机是否可能变化?回调里到底有没有异常被吞掉?这种问法极大提升了排查速度。

对于一个前端工程师来说,JavaScript语言的核心机制可能不是每天都要手写Promise,但它决定了你写代码时的判断力。当你真正理解了状态、队列、原型和this,很多框架源码看起来也不再是天书。希望这次分享能给你一条值得走下去的“拆”语言的路径。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进