JavaScript:从事件循环到手写 Promise
从单线程模型与事件循环出发,逐步推导出 Promise 的设计动机,并手写一个能通过 Promises/A+ 的最小实现
Step 01 · 00.ts
这份教程不是"事件循环讲一节、手写 Promise 讲一节"的拼盘,而是用一条因果链把两者串起来:
JS 是单线程 → 必须有异步 → 异步靠事件循环落地 → 事件循环里有微任务这种"插队任务" → 微任务催生了 Promise → Promise 的形状由几条不可妥协的约束逼出来 → 我们把这些约束翻译成代码。
读完之后你应该能回答这几个看似简单却容易答歪的问题:
- 为什么 JS 一定是单线程?后来的 Web Worker 算"多线程"吗?
- 单线程会带来哪些真实的性能问题?为什么不止"卡 UI"这么简单?
- 事件循环到底循环什么?为什么"主脚本"本身也算一个宏任务?
Promise.then为什么必须异步?换成setTimeout能不能?then为什么必须返回新的 Promise?resolvePromise那一坨判断到底在防哪些奇葩输入?
第一章 · 单线程与事件循环
为什么 JS 是单线程?
JS 一开始的目标只是给浏览器写"小动作"——表单校验、显示弹窗、操作 DOM 节点。Brendan Eich 在 1995 年用十天设计这门语言时,做了一个影响深远的决定:所有 JS 代码都跑在同一个线程上。
核心原因是 DOM 不是线程安全的。如果两条 JS 线程同时改一个 DOM 节点(一个删一个加),浏览器引擎得在每次访问节点时上锁,性能和实现复杂度都吃不消。"单线程"等于把这种竞态从语言层面直接消灭。
后来出现的 Web Worker、SharedArrayBuffer、Service Worker 看起来像"多线程",但它们都遵守同一条原则:Worker 不能直接访问主线程的 DOM,要通信只能 postMessage 把数据"搬过去"。本质上是隔离的多个单线程世界,而不是真正的共享内存多线程。
单线程的代价
只有一个主线程意味着:所有事情都得排队走这一条线。
代价不只是"页面卡"。具体说有三层:
- 任意一段长任务会阻塞所有交互——点击、滚动、动画、网络回调全得等。
- 浏览器一帧只有 ~16.7ms(60Hz 屏幕)。一旦你的 JS 跑超过这个预算,掉帧就发生了。
- CPU 密集型工作没法在主线程做——加密、压缩、大数据处理都会让页面"假死"。
这段代码做了什么
左边的代码用一个 while 循环纯粹忙等 3 秒。这 3 秒里,主线程被这个 while 死死占住——任何定时器、任何点击事件、任何渲染都得等它结束。
记住这个事实:JS 单线程的"死",不是某个 API 设计得不好,而是物理事实。要绕过它,唯一的办法就是——别在主线程上等。
Step 02 · 01.ts
异步:把"等待"交出去
上一步代码的问题是:主线程亲自在等。这一步代码做了一件根本上不一样的事——它把"等 3 秒"这件事交给了宿主(浏览器或 Node),自己立刻返回。
这就是 JS 异步执行的三件套心智模型:
- call stack(调用栈):同步代码在这里跑。栈一空,当前任务就算结束。
- host APIs(宿主 API):
setTimeout、fetch、文件 IO、DOM 事件……这些"会等"的能力不属于 JS 引擎,而是浏览器/Node 提供的。引擎只管把"任务 + 回调"丢给它们。 - task queue(任务队列):宿主完成等待后,把回调推进队列。等主线程空闲,事件循环再把它取出来执行。
setTimeout(cb, 3000) 在执行那一刻没有让线程睡觉。它干的是:
- JS 引擎把
cb和 3000ms 这条信息交给宿主。 - 宿主用自己的定时器机制(不在 JS 线程上)数 3 秒。
- 数到 3 秒后,宿主把
cb推到任务队列里。 - 主线程跑完所有同步代码,事件循环从队列里取出
cb,执行。
所以输出顺序是:start → end → (3s 后) after 3000ms。end 出现在 setTimeout 之前不是因为它"插队",而是因为 setTimeout 的回调根本没在当前调用栈里跑。
一个常见误解
很多教程把事件循环画成一个"轮询定时器的轮子"。这是错的。
事件循环的工作不是"看时间到了没",而是**"当前调用栈空了之后,从队列里取下一个任务"。它是个节拍器**,不是个计时器。计时是宿主的事。
Step 03 · 02.ts
输出顺序的反直觉
把这段代码丢给十个写过 JS 的人,会有人答 1, 2, 3, 4,有人答 1, 4, 2, 3。正确答案是 1, 4, 3, 2。
Promise.resolve().then(...) 看起来"立刻就 resolve 了",但 then 注册的回调比 setTimeout(cb, 0) 跑得还早。这只能用一个事实解释:任务队列不止一条。
引擎里有两条不同性质的队列:
- 宏任务队列(macrotask queue):放
setTimeout、setInterval、I/O、UI 事件等。 - 微任务队列(microtask queue):放
Promise.then、queueMicrotask、MutationObserver等。
对,现在你只需要先记住这两条名字。下一步会给出它们之间的精确规则——但有了"两条队列"这个事实,已经能机械推出本步的输出:
- 同步代码先跑完 → 打印
1, 4。 - 同步代码结束这一刻,引擎做一次"清空微任务队列"的动作 → 打印
3。 - 微任务清空后,事件循环才取下一个宏任务 → 打印
2。
如果你之前一直觉得 Promise 的执行时机是"玄学",原因往往就是没意识到队列不止一条。
Step 04 · 03.ts
第二章 · 宏任务与微任务
两条不可妥协的规则
宏任务和微任务的全部关系,只用两条规则就能讲清楚:
- 一次只取一个宏任务执行。
- 每个宏任务跑完之后,立刻把当前微任务队列全部清空,才允许去取下一个宏任务。
这两条规则解释了所有"输出顺序题"。本步代码给出最朴素的对照:同一时刻丢进去的 setTimeout(cb, 0)(宏任务)和 Promise.resolve().then(cb)(微任务),永远是微任务先跑。
把微任务当成"插队任务"
理解微任务最好的隐喻是插队:
当前这一轮宏任务结束、还没轮到下一个宏任务之间,存在一个"窗口期"。微任务就是塞进这个窗口里执行的。
所以微任务有两个特性:
- 优先级高于任意宏任务——再急的
setTimeout(cb, 0)也排在then之后。 - 可以连环触发——微任务执行过程中再注册的微任务,会被纳入当前这次清空,而不是等下一轮。这意味着写一个无限递归注册微任务的代码,会让事件循环永远卡在微任务清空阶段,连渲染都做不了——这是一个真实存在的反模式。
主脚本本身就是一个宏任务
这是初学者最容易漏掉的关键事实:
整段顶层
<script>代码(或 Node 的入口模块)本身,被引擎当作一个宏任务来执行。
所以"同步代码先跑完,再清空微任务"这个观察,其实就是规则 2 的特例——主脚本是当前正在执行的宏任务,它结束之前注册的所有 then 都排在它的微任务尾巴上,主脚本一结束就被立刻清空。
抓住"主脚本是宏任务",下一步那道综合输出题就能机械推出来。
Step 05 · 04.ts
综合输出题:机械推导
我们现在有了两条规则 + "主脚本是宏任务"这个事实,就可以一步一步硬推出 A, G, D, F, B, C, E。
第 0 阶段(开始执行主脚本,本身就是一个宏任务)
- 同步打印
A。 - 注册一个 timer:把
B-and-then-C这个回调挂到宿主的定时器上。 - 注册一个微任务:
then → 打印 D 并注册 timer(E)。 - 注册一个微任务:
queueMicrotask → 打印 F。 - 同步打印
G。
主脚本结束这一刻,状态是:
- 微任务队列:
[then→D, queueMicrotask→F](按注册顺序) - 宏任务队列:
[timer→B]
第 1 阶段(主脚本这个宏任务结束 → 清空微任务)
- 取出
then→D:打印D。在它内部又同步执行setTimeout(E)→ 把 E 排进宏任务队列 → 现在宏任务队列变成[timer→B, timer→E]。 - 取出
queueMicrotask→F:打印F。 - 微任务队列空了。
第 2 阶段(取下一个宏任务)
- 取出
timer→B:打印B。它内部Promise.resolve().then(C)→ 把then→C推进微任务队列。 - 这个宏任务结束 → 清空微任务 → 打印
C。
第 3 阶段(再取下一个宏任务)
- 取出
timer→E:打印E。
最终输出:A, G, D, F, B, C, E。
拿这套机械流程去解任何题
你会发现"输出顺序题"做完之后,没有任何一步是靠"感觉"或"经验"。只要严格按:
同步跑完 → 清空微任务 → 取一个宏任务 → 同步跑完 → 清空微任务 → …
去推,就一定对。这套流程看起来啰嗦,但它就是 V8 / SpiderMonkey 等引擎里 Event Loop 的真实工作方式。
Step 06 · 05.ts
Node 的两个额外角色
浏览器和 Node 共享"宏任务 + 微任务"的双队列模型,但 Node 在外层套了一个 libuv 事件循环,多出两个 API:process.nextTick 和 setImmediate。
不必背 libuv 那六个阶段(timers / pending / poll / check / close 等),只需要记住三层优先级:
所以本步的输出顺序大致是:
浏览器的渲染时机
事件循环不只跑你的 JS,它还要插入渲染。简化版的浏览器一帧大致是:
这就解释了几个常见现象:
- 大量微任务循环注册会让浏览器永远渲染不到——它卡在"清空微任务"这一步出不来。
requestAnimationFrame比setTimeout(cb, 16)更准——前者跟着帧节奏走,后者只是计时。- 在
then里改 DOM 通常很快就能看到——因为微任务清空后紧接着就是渲染。
事件循环这条线索到这里告一段落。我们接下来要切换视角——从"运行时怎么调度异步"切到"应用层怎么写出可维护的异步",这正是 Promise 出场的地方。
Step 07 · 06.ts
第三章 · 从回调到 Promise 的动机
三个具体痛点
每个写过 Node 早期代码的人都见过左边这种结构。它的问题被简称为"回调地狱",但真正的问题不是嵌套丑——那只是表象。痛点其实有三个,每一个都很具体:
1. 结构和业务无关
左边代码的缩进有 3 层,仅仅是因为我们做了 3 次异步调用。如果改成 6 次,缩进就有 6 层。结构由 API 形态决定,而不是由业务复杂度决定——这违反了"代码应该反映问题,而不是反映工具"的基本审美。
2. 错误处理无法复用
注意每一层都重复写了 if (err) return console.error(err)。这不只是难看,还会真的出 bug——业务复杂之后,很容易某一层忘了检查 err,错误就被静默吞了。Node 的"error-first callback"约定本身就是个补丁,它没有从根本上解决错误传播。
3. 异步函数没有"返回值"
getUser 的"结果"没法被赋值给一个变量,因为它要异步才知道结果。同步代码可以写:
异步代码无论多努力,都没法直接复刻这种写法——除非有一个"还没拿到结果但代表未来值"的对象。
我们到底需要一个什么样的对象?
把上面三个痛点反着看,需求就清晰了。我们需要一个对象,它:
- 代表"未来某个时刻才会有的值"——可以现在就被传递、存储、返回。
- 支持组合——两个这种对象可以串起来,得到第三个。
- 错误能在末端统一处理——而不是每一层都写
if (err)。 - 能向链路上下游传递异常——同步代码里的
try/catch可以跨层捕获,这个对象也应该能。
满足这四点的对象就是 Promise。它不是凭空设计出来的,而是被这四个需求逼出来的。
Step 08 · 07.ts
Promise 是一台一次性状态机
Promise 的全部本质,可以画成左边代码那种小图:三态、单向、一次性。
pending:初始态。可以转向fulfilled或rejected,但只能转一次。fulfilled:成功态。会带一个值(value)。rejected:失败态。会带一个原因(reason)。
两条不可妥协的约束:
- 状态不可逆——一旦离开 pending,就再也回不去了,更不能在 fulfilled / rejected 之间跳。
- resolve / reject 只生效一次——重复调用全部静默忽略。
本步代码做了一个验证:resolve(1) 之后再 resolve(2) 和 reject(...) 都不会生效,最终 then 拿到的还是 1。
为什么必须这么严格?
这两条约束看起来只是"小心翼翼",但它们的存在让消费者代码变得简单。如果状态可以反复变,那 then 里的回调就可能被同一个 Promise 触发多次(或者从成功翻车到失败),消费者就得自己处理"我已经处理过一次了吗?"这种状态——这正是事件监听器(addEventListener)的复杂度。Promise 通过单次性把这种复杂度从消费者那里移走了。
这两条约束,也是后面所有手写代码里 if (state !== 'pending') return 的来源。
接下来从 v1 到 v5,我们一行一行把这台状态机翻译成代码。
Step 09 · 08.ts
第四章 · 手写 MyPromise
v1 · 状态机骨架
本步代码是手写实现的最小骨架:一个 class,三个字段(state / value / reason),resolve 和 reject 都有 if (this.state !== 'pending') return 守卫——这就是上一节"两条约束"的代码翻译。
注意几个设计细节:
resolve和reject不是MyPromise的方法,而是构造函数里的闭包变量。这样外部拿到一个MyPromise实例后,没法手动改它的状态——状态控制权牢牢被 executor 持有。try { executor(...) } catch (e) { reject(e) }——executor 同步抛错应该被自动转成 rejected。这条规则在原生 Promise 里同样存在。then暂时只会把同步回调立即同步执行——这就是 v1 的全部能力。
v1 暴露的问题
把构造函数里 executor 改成异步触发 resolve,比如:
then 注册的那一刻,状态还是 pending。v1 的 then 对 pending 这种情况什么都不做——回调被静默丢掉了。100ms 后即使 resolve(1) 触发,也没人通知任何回调。
修复办法:在 pending 阶段把 then 传进来的回调先存起来,等到 resolve / reject 真正触发时再统一拿出来执行。这就是 v2。
Step 10 · 09.ts
v2 · 把 pending 阶段的回调存起来
v2 在两个地方动了刀:
- 新增两个数组
onFulfilledCbs/onRejectedCbs,作为"等候队列"。 then在 pending 时把回调入队;resolve / reject触发时遍历队列依次通知。
这其实就是经典的订阅者模式:Promise 是发布者,每次 then 都是注册一个订阅者。
为什么是数组而不是单个回调?因为同一个 Promise 可以被 .then 多次,比如:
这三个 .then 都得拿到通知。所以队列必须是数组。
v2 还有的问题
v2 已经能正确处理"executor 里异步 resolve"的情况了。但仔细看 then:当状态已经是 fulfilled 时,它同步调用 onFulfilled。也就是说我们的 MyPromise 出现了一种很糟糕的"双面性"——
- executor 里同步 resolve 的 →
then同步执行回调 - executor 里异步 resolve 的 →
then异步执行回调
同一个 API、同样的调用方式,行为却随上下文变化。这种 API 在社区有个绰号叫 Zalgo("释放邪神"),写出来的上层逻辑会有一类极难复现的 bug——开发期间它"碰巧"是异步的所以一切正常,上线后某个分支 resolve 变同步了,就开始随机翻车。
修法很简单:让 then 永远异步。
Step 11 · 10.ts
v3 · 让 then 永远异步
v3 的改动只有一处但分量很重:在调用 onFulfilled / onRejected 之前,统统用 queueMicrotask 包一层。无论当前状态是 fulfilled / rejected 还是 pending,回调都被推迟到微任务里去执行。
这一改之后,MyPromise 的执行时机和原生 Promise 一致了——都是微任务。看本步底部那段示例:
即使 resolve 是同步触发的,then 的回调依然在 B 之后才打印,因为它被排进了当前轮的微任务队列。
为什么是 queueMicrotask 而不是 setTimeout?
两个原因:
- 语义对齐原生 Promise:原生
then就是微任务。如果我们用setTimeout,MyPromise.then会变成宏任务,跟原生在同一段代码里混用就会出现微妙的顺序差异。 - 微任务比宏任务快得多:
setTimeout(cb, 0)即使在最理想情况下也要等 4ms(HTML 规范规定的最小 clamp);queueMicrotask紧接着当前任务就跑。Promise 的核心使用场景是"链式异步",这种场景里慢哪怕几毫秒,叠加起来都很可观。
v3 的隐藏收益
v3 还顺手解决了一个 v4 才会用到的问题:then 里需要在闭包中引用一个还没赋值完的 promise2。把回调推迟到微任务里之后,等微任务真正跑起来时,promise2 一定已经从 new MyPromise(...) 表达式里赋值出来了。这一点我们在 v5 处理 resolvePromise 时会再用到。
但 v3 还没解决最关键的问题:then 没有返回值,不能链式调用。
Step 12 · 11.ts
v4 · 链式调用的本质
链式调用 p.then(a).then(b) 之所以能成立,是因为 then 本身返回一个新的 Promise——我们叫它 promise2——而 promise2 的状态由 a 的执行结果决定:
a正常返回x→promise2resolve(x)a抛错 →promise2reject(error)
所以 v4 的核心是把 then 的返回值改成 new MyPromise((resolve, reject) => { ... }),并把 try { resolve(fulfilled(this.value)) } catch (e) { reject(e) } 这段逻辑嵌进去。
值穿透 / 错误穿透
第 36-41 行处理了一个容易忽略的情况:onFulfilled 或 onRejected 不是函数(比如开发者直接写 .then(undefined, handler) 或者只写 .then(handler) 然后再 .catch)。
规范要求这种情况下:
- 没有
onFulfilled→ 用默认透传(v) => v,把当前值原样传给下游。 - 没有
onRejected→ 用默认抛出(e) => { throw e },让下游能继续 reject。
这就是"值穿透/错误穿透"。它让 .then(...).then(...).catch(handler) 这种写法能正确工作——错误能"跨过"中间没写错误处理的 then,一路落到末端的 catch。
v4 还差最后一步
v4 已经能处理 onFulfilled 返回普通值(数字、字符串、对象)的情况。但如果它返回的 x 本身又是一个 Promise 呢?比如:
v4 会把这个 Promise 当作普通值丢进 resolve 里,导致 promise2.value === 那个 Promise 对象。下游 .then 拿到的不是订单数据,而是个 Promise。这显然不是我们要的——下游应该等到内层 Promise 也 resolve 出真正的值之后再触发。
这就是 resolvePromise 要解决的问题。
Step 13 · 12.ts
v5 · resolvePromise · 规范 2.3 节
resolvePromise 是整个手写过程里最容易出错的一段。它的工作是:拿到 onFulfilled 返回的 x,根据 x 的形态决定怎么 resolve promise2。Promises/A+ 规范 2.3 节用了整整一页篇幅描述它,对应到代码就是本步的 resolvePromise 函数。
它要应对四种情况:
1. x === promise2(自我引用)
p2 = p1.then((v) => p2) 这种写法会让 promise2 等自己——死循环。必须 reject 一个 TypeError,这是规范明确要求的。
2. x 是另一个 Promise(包括 thenable)
调用 x.then(onFulfilled, onRejected),把 x 的最终状态"传染"给 promise2。注意是递归调用 resolvePromise——因为 x resolve 出来的 y 可能还是个 Promise。
3. x 是普通对象(没有 .then 或 .then 不是函数)
直接当成值 resolve。
4. x 是基本类型(null / undefined / 数字 / 字符串等)
直接 resolve。
两个魔鬼细节
called 标志位
第 27 行的 let called = false 看起来像在防御什么。它防御的是这种"不规矩的 thenable":
第三方库或用户实现的 thenable 不一定遵守"只 settle 一次"的规则。called 标志位让我们的实现对外严格遵守一次性——无论 thenable 怎么乱来,第一次拿到结果就锁死。
const then = x.then 这行可能抛错
第 29 行单独用一个变量取出 then,是为了把"取属性"的过程包在 try 里。因为有些对象会用 getter 故意 throw:
如果直接写 if (typeof x.then === 'function'),这个 throw 会逃出 try/catch 之外。规范在 2.3.3.2 明确要求"取 then 时抛错也算 reject",所以必须写成"先取一次,存到变量里,后续都用变量"。
把 v4 里 try { resolve(fulfilled(this.value)) } 这一行改成:
到这里,MyPromise 的核心就完成了。
Step 14 · 13.ts
第五章 · 静态方法与规范验证
四个常考静态方法
Promise.all / race / allSettled / any 经常出现在面试里,其实代码差异很小——重点是语义差异。
all 和 any 是镜像关系——一个"任意失败就 reject"、一个"任意成功就 resolve"。race 和 allSettled 处于两个极端——race 抢第一个 settle 的、allSettled 等所有人 settle。
空数组的边界陷阱
每个静态方法对空数组的行为都不一样,面试常考:
race([]) 那条尤其阴险——程序不会报错,也不会走任何分支,就是永远卡住。如果你在线上看到一个"既不成功也不失败"的链路,这是一个值得排查的方向。
any 的 AggregateError
any 是 ES2021 才进规范的,配套引入了 AggregateError——一个能装多个错误原因的特殊错误对象。本步代码里 new AggregateError(errs, 'All promises were rejected') 第一个参数就是各路失败原因的数组,第二个参数是统一的 message。
这个设计的好处是:调用方可以通过 err.errors 拿到完整的失败列表,决定是统一处理还是分别报告。如果只 reject 第一个失败的 reason,信息就丢了。
Step 15 · 14.ts
用规范测试给自己打分
promises-aplus-tests 是 Promises/A+ 官方测试套件,包含 872 条用例,专门用来检验"是不是真的合规"。它的工作方式是:你提供一个 adapter 对象,暴露三个工厂函数(左边代码);测试套件会用它们造出各种 Promise 来跑测试。
实际跑一遍的步骤:
-
把
MyPromise整理到一个独立文件,暴露 default export。 -
pnpm add -D promises-aplus-tests -
写一个
adapter.cjs: -
跑:
npx promises-aplus-tests adapter.cjs -
顺利的话会看到
872 passing。如果某条 fail,套件会指明是哪一节哪一项不合规,对照规范回去补即可——v1~v5 这条主线已经覆盖了 90% 以上的用例。
收束
回头看,整套手写 Promise 其实只用了两条事实:
- JS 是单线程,异步必须把"等待"交给宿主,回调被排进任务队列。
- 微任务是"插队任务"——它让
then可以在当前轮事件循环结束前就被执行。
剩下所有代码——if (state !== 'pending') return、订阅者数组、queueMicrotask 包裹、promise2 链式、resolvePromise 的四种情况——都是在这两条事实之上,加上"状态不可逆"和"then 必须返回新 Promise"两条约束逼出来的。
V8 等真实引擎的实现当然比这复杂得多——它们会用原生 job queue 替代 queueMicrotask,会用隐藏类、内联缓存等手段优化性能,也会增加 Promise.try / Promise.withResolvers 这些较新的 API。但形状和我们手写的这一版完全一致。
如果你能把"为什么单线程 → 单线程的代价 → 异步三件套 → 宏任务 vs 微任务 → 输出顺序机械推导 → Promise 状态机 → v1 到 v5"这条因果链自己讲一遍,那么之后无论是面试被问到"输出顺序题"还是"手写 Promise",都不会再卡壳。
console.log('start') const start = Date.now()while (Date.now() - start < 3000) {} console.log('end after blocking') 这份教程不是"事件循环讲一节、手写 Promise 讲一节"的拼盘,而是用一条因果链把两者串起来:
JS 是单线程 → 必须有异步 → 异步靠事件循环落地 → 事件循环里有微任务这种"插队任务" → 微任务催生了 Promise → Promise 的形状由几条不可妥协的约束逼出来 → 我们把这些约束翻译成代码。
读完之后你应该能回答这几个看似简单却容易答歪的问题:
- 为什么 JS 一定是单线程?后来的 Web Worker 算"多线程"吗?
- 单线程会带来哪些真实的性能问题?为什么不止"卡 UI"这么简单?
- 事件循环到底循环什么?为什么"主脚本"本身也算一个宏任务?
Promise.then为什么必须异步?换成setTimeout能不能?then为什么必须返回新的 Promise?resolvePromise那一坨判断到底在防哪些奇葩输入?
第一章 · 单线程与事件循环
为什么 JS 是单线程?
JS 一开始的目标只是给浏览器写"小动作"——表单校验、显示弹窗、操作 DOM 节点。Brendan Eich 在 1995 年用十天设计这门语言时,做了一个影响深远的决定:所有 JS 代码都跑在同一个线程上。
核心原因是 DOM 不是线程安全的。如果两条 JS 线程同时改一个 DOM 节点(一个删一个加),浏览器引擎得在每次访问节点时上锁,性能和实现复杂度都吃不消。"单线程"等于把这种竞态从语言层面直接消灭。
后来出现的 Web Worker、SharedArrayBuffer、Service Worker 看起来像"多线程",但它们都遵守同一条原则:Worker 不能直接访问主线程的 DOM,要通信只能 postMessage 把数据"搬过去"。本质上是隔离的多个单线程世界,而不是真正的共享内存多线程。
单线程的代价
只有一个主线程意味着:所有事情都得排队走这一条线。
代价不只是"页面卡"。具体说有三层:
- 任意一段长任务会阻塞所有交互——点击、滚动、动画、网络回调全得等。
- 浏览器一帧只有 ~16.7ms(60Hz 屏幕)。一旦你的 JS 跑超过这个预算,掉帧就发生了。
- CPU 密集型工作没法在主线程做——加密、压缩、大数据处理都会让页面"假死"。
这段代码做了什么
左边的代码用一个 while 循环纯粹忙等 3 秒。这 3 秒里,主线程被这个 while 死死占住——任何定时器、任何点击事件、任何渲染都得等它结束。
记住这个事实:JS 单线程的"死",不是某个 API 设计得不好,而是物理事实。要绕过它,唯一的办法就是——别在主线程上等。
异步:把"等待"交出去
上一步代码的问题是:主线程亲自在等。这一步代码做了一件根本上不一样的事——它把"等 3 秒"这件事交给了宿主(浏览器或 Node),自己立刻返回。
这就是 JS 异步执行的三件套心智模型:
- call stack(调用栈):同步代码在这里跑。栈一空,当前任务就算结束。
- host APIs(宿主 API):
setTimeout、fetch、文件 IO、DOM 事件……这些"会等"的能力不属于 JS 引擎,而是浏览器/Node 提供的。引擎只管把"任务 + 回调"丢给它们。 - task queue(任务队列):宿主完成等待后,把回调推进队列。等主线程空闲,事件循环再把它取出来执行。
setTimeout(cb, 3000) 在执行那一刻没有让线程睡觉。它干的是:
- JS 引擎把
cb和 3000ms 这条信息交给宿主。 - 宿主用自己的定时器机制(不在 JS 线程上)数 3 秒。
- 数到 3 秒后,宿主把
cb推到任务队列里。 - 主线程跑完所有同步代码,事件循环从队列里取出
cb,执行。
所以输出顺序是:start → end → (3s 后) after 3000ms。end 出现在 setTimeout 之前不是因为它"插队",而是因为 setTimeout 的回调根本没在当前调用栈里跑。
一个常见误解
很多教程把事件循环画成一个"轮询定时器的轮子"。这是错的。
事件循环的工作不是"看时间到了没",而是**"当前调用栈空了之后,从队列里取下一个任务"。它是个节拍器**,不是个计时器。计时是宿主的事。
输出顺序的反直觉
把这段代码丢给十个写过 JS 的人,会有人答 1, 2, 3, 4,有人答 1, 4, 2, 3。正确答案是 1, 4, 3, 2。
Promise.resolve().then(...) 看起来"立刻就 resolve 了",但 then 注册的回调比 setTimeout(cb, 0) 跑得还早。这只能用一个事实解释:任务队列不止一条。
引擎里有两条不同性质的队列:
- 宏任务队列(macrotask queue):放
setTimeout、setInterval、I/O、UI 事件等。 - 微任务队列(microtask queue):放
Promise.then、queueMicrotask、MutationObserver等。
对,现在你只需要先记住这两条名字。下一步会给出它们之间的精确规则——但有了"两条队列"这个事实,已经能机械推出本步的输出:
- 同步代码先跑完 → 打印
1, 4。 - 同步代码结束这一刻,引擎做一次"清空微任务队列"的动作 → 打印
3。 - 微任务清空后,事件循环才取下一个宏任务 → 打印
2。
如果你之前一直觉得 Promise 的执行时机是"玄学",原因往往就是没意识到队列不止一条。
第二章 · 宏任务与微任务
两条不可妥协的规则
宏任务和微任务的全部关系,只用两条规则就能讲清楚:
- 一次只取一个宏任务执行。
- 每个宏任务跑完之后,立刻把当前微任务队列全部清空,才允许去取下一个宏任务。
这两条规则解释了所有"输出顺序题"。本步代码给出最朴素的对照:同一时刻丢进去的 setTimeout(cb, 0)(宏任务)和 Promise.resolve().then(cb)(微任务),永远是微任务先跑。
把微任务当成"插队任务"
理解微任务最好的隐喻是插队:
当前这一轮宏任务结束、还没轮到下一个宏任务之间,存在一个"窗口期"。微任务就是塞进这个窗口里执行的。
所以微任务有两个特性:
- 优先级高于任意宏任务——再急的
setTimeout(cb, 0)也排在then之后。 - 可以连环触发——微任务执行过程中再注册的微任务,会被纳入当前这次清空,而不是等下一轮。这意味着写一个无限递归注册微任务的代码,会让事件循环永远卡在微任务清空阶段,连渲染都做不了——这是一个真实存在的反模式。
主脚本本身就是一个宏任务
这是初学者最容易漏掉的关键事实:
整段顶层
<script>代码(或 Node 的入口模块)本身,被引擎当作一个宏任务来执行。
所以"同步代码先跑完,再清空微任务"这个观察,其实就是规则 2 的特例——主脚本是当前正在执行的宏任务,它结束之前注册的所有 then 都排在它的微任务尾巴上,主脚本一结束就被立刻清空。
抓住"主脚本是宏任务",下一步那道综合输出题就能机械推出来。
综合输出题:机械推导
我们现在有了两条规则 + "主脚本是宏任务"这个事实,就可以一步一步硬推出 A, G, D, F, B, C, E。
第 0 阶段(开始执行主脚本,本身就是一个宏任务)
- 同步打印
A。 - 注册一个 timer:把
B-and-then-C这个回调挂到宿主的定时器上。 - 注册一个微任务:
then → 打印 D 并注册 timer(E)。 - 注册一个微任务:
queueMicrotask → 打印 F。 - 同步打印
G。
主脚本结束这一刻,状态是:
- 微任务队列:
[then→D, queueMicrotask→F](按注册顺序) - 宏任务队列:
[timer→B]
第 1 阶段(主脚本这个宏任务结束 → 清空微任务)
- 取出
then→D:打印D。在它内部又同步执行setTimeout(E)→ 把 E 排进宏任务队列 → 现在宏任务队列变成[timer→B, timer→E]。 - 取出
queueMicrotask→F:打印F。 - 微任务队列空了。
第 2 阶段(取下一个宏任务)
- 取出
timer→B:打印B。它内部Promise.resolve().then(C)→ 把then→C推进微任务队列。 - 这个宏任务结束 → 清空微任务 → 打印
C。
第 3 阶段(再取下一个宏任务)
- 取出
timer→E:打印E。
最终输出:A, G, D, F, B, C, E。
拿这套机械流程去解任何题
你会发现"输出顺序题"做完之后,没有任何一步是靠"感觉"或"经验"。只要严格按:
同步跑完 → 清空微任务 → 取一个宏任务 → 同步跑完 → 清空微任务 → …
去推,就一定对。这套流程看起来啰嗦,但它就是 V8 / SpiderMonkey 等引擎里 Event Loop 的真实工作方式。
Node 的两个额外角色
浏览器和 Node 共享"宏任务 + 微任务"的双队列模型,但 Node 在外层套了一个 libuv 事件循环,多出两个 API:process.nextTick 和 setImmediate。
不必背 libuv 那六个阶段(timers / pending / poll / check / close 等),只需要记住三层优先级:
所以本步的输出顺序大致是:
浏览器的渲染时机
事件循环不只跑你的 JS,它还要插入渲染。简化版的浏览器一帧大致是:
这就解释了几个常见现象:
- 大量微任务循环注册会让浏览器永远渲染不到——它卡在"清空微任务"这一步出不来。
requestAnimationFrame比setTimeout(cb, 16)更准——前者跟着帧节奏走,后者只是计时。- 在
then里改 DOM 通常很快就能看到——因为微任务清空后紧接着就是渲染。
事件循环这条线索到这里告一段落。我们接下来要切换视角——从"运行时怎么调度异步"切到"应用层怎么写出可维护的异步",这正是 Promise 出场的地方。
第三章 · 从回调到 Promise 的动机
三个具体痛点
每个写过 Node 早期代码的人都见过左边这种结构。它的问题被简称为"回调地狱",但真正的问题不是嵌套丑——那只是表象。痛点其实有三个,每一个都很具体:
1. 结构和业务无关
左边代码的缩进有 3 层,仅仅是因为我们做了 3 次异步调用。如果改成 6 次,缩进就有 6 层。结构由 API 形态决定,而不是由业务复杂度决定——这违反了"代码应该反映问题,而不是反映工具"的基本审美。
2. 错误处理无法复用
注意每一层都重复写了 if (err) return console.error(err)。这不只是难看,还会真的出 bug——业务复杂之后,很容易某一层忘了检查 err,错误就被静默吞了。Node 的"error-first callback"约定本身就是个补丁,它没有从根本上解决错误传播。
3. 异步函数没有"返回值"
getUser 的"结果"没法被赋值给一个变量,因为它要异步才知道结果。同步代码可以写:
异步代码无论多努力,都没法直接复刻这种写法——除非有一个"还没拿到结果但代表未来值"的对象。
我们到底需要一个什么样的对象?
把上面三个痛点反着看,需求就清晰了。我们需要一个对象,它:
- 代表"未来某个时刻才会有的值"——可以现在就被传递、存储、返回。
- 支持组合——两个这种对象可以串起来,得到第三个。
- 错误能在末端统一处理——而不是每一层都写
if (err)。 - 能向链路上下游传递异常——同步代码里的
try/catch可以跨层捕获,这个对象也应该能。
满足这四点的对象就是 Promise。它不是凭空设计出来的,而是被这四个需求逼出来的。
Promise 是一台一次性状态机
Promise 的全部本质,可以画成左边代码那种小图:三态、单向、一次性。
pending:初始态。可以转向fulfilled或rejected,但只能转一次。fulfilled:成功态。会带一个值(value)。rejected:失败态。会带一个原因(reason)。
两条不可妥协的约束:
- 状态不可逆——一旦离开 pending,就再也回不去了,更不能在 fulfilled / rejected 之间跳。
- resolve / reject 只生效一次——重复调用全部静默忽略。
本步代码做了一个验证:resolve(1) 之后再 resolve(2) 和 reject(...) 都不会生效,最终 then 拿到的还是 1。
为什么必须这么严格?
这两条约束看起来只是"小心翼翼",但它们的存在让消费者代码变得简单。如果状态可以反复变,那 then 里的回调就可能被同一个 Promise 触发多次(或者从成功翻车到失败),消费者就得自己处理"我已经处理过一次了吗?"这种状态——这正是事件监听器(addEventListener)的复杂度。Promise 通过单次性把这种复杂度从消费者那里移走了。
这两条约束,也是后面所有手写代码里 if (state !== 'pending') return 的来源。
接下来从 v1 到 v5,我们一行一行把这台状态机翻译成代码。
第四章 · 手写 MyPromise
v1 · 状态机骨架
本步代码是手写实现的最小骨架:一个 class,三个字段(state / value / reason),resolve 和 reject 都有 if (this.state !== 'pending') return 守卫——这就是上一节"两条约束"的代码翻译。
注意几个设计细节:
resolve和reject不是MyPromise的方法,而是构造函数里的闭包变量。这样外部拿到一个MyPromise实例后,没法手动改它的状态——状态控制权牢牢被 executor 持有。try { executor(...) } catch (e) { reject(e) }——executor 同步抛错应该被自动转成 rejected。这条规则在原生 Promise 里同样存在。then暂时只会把同步回调立即同步执行——这就是 v1 的全部能力。
v1 暴露的问题
把构造函数里 executor 改成异步触发 resolve,比如:
then 注册的那一刻,状态还是 pending。v1 的 then 对 pending 这种情况什么都不做——回调被静默丢掉了。100ms 后即使 resolve(1) 触发,也没人通知任何回调。
修复办法:在 pending 阶段把 then 传进来的回调先存起来,等到 resolve / reject 真正触发时再统一拿出来执行。这就是 v2。
v2 · 把 pending 阶段的回调存起来
v2 在两个地方动了刀:
- 新增两个数组
onFulfilledCbs/onRejectedCbs,作为"等候队列"。 then在 pending 时把回调入队;resolve / reject触发时遍历队列依次通知。
这其实就是经典的订阅者模式:Promise 是发布者,每次 then 都是注册一个订阅者。
为什么是数组而不是单个回调?因为同一个 Promise 可以被 .then 多次,比如:
这三个 .then 都得拿到通知。所以队列必须是数组。
v2 还有的问题
v2 已经能正确处理"executor 里异步 resolve"的情况了。但仔细看 then:当状态已经是 fulfilled 时,它同步调用 onFulfilled。也就是说我们的 MyPromise 出现了一种很糟糕的"双面性"——
- executor 里同步 resolve 的 →
then同步执行回调 - executor 里异步 resolve 的 →
then异步执行回调
同一个 API、同样的调用方式,行为却随上下文变化。这种 API 在社区有个绰号叫 Zalgo("释放邪神"),写出来的上层逻辑会有一类极难复现的 bug——开发期间它"碰巧"是异步的所以一切正常,上线后某个分支 resolve 变同步了,就开始随机翻车。
修法很简单:让 then 永远异步。
v3 · 让 then 永远异步
v3 的改动只有一处但分量很重:在调用 onFulfilled / onRejected 之前,统统用 queueMicrotask 包一层。无论当前状态是 fulfilled / rejected 还是 pending,回调都被推迟到微任务里去执行。
这一改之后,MyPromise 的执行时机和原生 Promise 一致了——都是微任务。看本步底部那段示例:
即使 resolve 是同步触发的,then 的回调依然在 B 之后才打印,因为它被排进了当前轮的微任务队列。
为什么是 queueMicrotask 而不是 setTimeout?
两个原因:
- 语义对齐原生 Promise:原生
then就是微任务。如果我们用setTimeout,MyPromise.then会变成宏任务,跟原生在同一段代码里混用就会出现微妙的顺序差异。 - 微任务比宏任务快得多:
setTimeout(cb, 0)即使在最理想情况下也要等 4ms(HTML 规范规定的最小 clamp);queueMicrotask紧接着当前任务就跑。Promise 的核心使用场景是"链式异步",这种场景里慢哪怕几毫秒,叠加起来都很可观。
v3 的隐藏收益
v3 还顺手解决了一个 v4 才会用到的问题:then 里需要在闭包中引用一个还没赋值完的 promise2。把回调推迟到微任务里之后,等微任务真正跑起来时,promise2 一定已经从 new MyPromise(...) 表达式里赋值出来了。这一点我们在 v5 处理 resolvePromise 时会再用到。
但 v3 还没解决最关键的问题:then 没有返回值,不能链式调用。
v4 · 链式调用的本质
链式调用 p.then(a).then(b) 之所以能成立,是因为 then 本身返回一个新的 Promise——我们叫它 promise2——而 promise2 的状态由 a 的执行结果决定:
a正常返回x→promise2resolve(x)a抛错 →promise2reject(error)
所以 v4 的核心是把 then 的返回值改成 new MyPromise((resolve, reject) => { ... }),并把 try { resolve(fulfilled(this.value)) } catch (e) { reject(e) } 这段逻辑嵌进去。
值穿透 / 错误穿透
第 36-41 行处理了一个容易忽略的情况:onFulfilled 或 onRejected 不是函数(比如开发者直接写 .then(undefined, handler) 或者只写 .then(handler) 然后再 .catch)。
规范要求这种情况下:
- 没有
onFulfilled→ 用默认透传(v) => v,把当前值原样传给下游。 - 没有
onRejected→ 用默认抛出(e) => { throw e },让下游能继续 reject。
这就是"值穿透/错误穿透"。它让 .then(...).then(...).catch(handler) 这种写法能正确工作——错误能"跨过"中间没写错误处理的 then,一路落到末端的 catch。
v4 还差最后一步
v4 已经能处理 onFulfilled 返回普通值(数字、字符串、对象)的情况。但如果它返回的 x 本身又是一个 Promise 呢?比如:
v4 会把这个 Promise 当作普通值丢进 resolve 里,导致 promise2.value === 那个 Promise 对象。下游 .then 拿到的不是订单数据,而是个 Promise。这显然不是我们要的——下游应该等到内层 Promise 也 resolve 出真正的值之后再触发。
这就是 resolvePromise 要解决的问题。
v5 · resolvePromise · 规范 2.3 节
resolvePromise 是整个手写过程里最容易出错的一段。它的工作是:拿到 onFulfilled 返回的 x,根据 x 的形态决定怎么 resolve promise2。Promises/A+ 规范 2.3 节用了整整一页篇幅描述它,对应到代码就是本步的 resolvePromise 函数。
它要应对四种情况:
1. x === promise2(自我引用)
p2 = p1.then((v) => p2) 这种写法会让 promise2 等自己——死循环。必须 reject 一个 TypeError,这是规范明确要求的。
2. x 是另一个 Promise(包括 thenable)
调用 x.then(onFulfilled, onRejected),把 x 的最终状态"传染"给 promise2。注意是递归调用 resolvePromise——因为 x resolve 出来的 y 可能还是个 Promise。
3. x 是普通对象(没有 .then 或 .then 不是函数)
直接当成值 resolve。
4. x 是基本类型(null / undefined / 数字 / 字符串等)
直接 resolve。
两个魔鬼细节
called 标志位
第 27 行的 let called = false 看起来像在防御什么。它防御的是这种"不规矩的 thenable":
第三方库或用户实现的 thenable 不一定遵守"只 settle 一次"的规则。called 标志位让我们的实现对外严格遵守一次性——无论 thenable 怎么乱来,第一次拿到结果就锁死。
const then = x.then 这行可能抛错
第 29 行单独用一个变量取出 then,是为了把"取属性"的过程包在 try 里。因为有些对象会用 getter 故意 throw:
如果直接写 if (typeof x.then === 'function'),这个 throw 会逃出 try/catch 之外。规范在 2.3.3.2 明确要求"取 then 时抛错也算 reject",所以必须写成"先取一次,存到变量里,后续都用变量"。
把 v4 里 try { resolve(fulfilled(this.value)) } 这一行改成:
到这里,MyPromise 的核心就完成了。
第五章 · 静态方法与规范验证
四个常考静态方法
Promise.all / race / allSettled / any 经常出现在面试里,其实代码差异很小——重点是语义差异。
all 和 any 是镜像关系——一个"任意失败就 reject"、一个"任意成功就 resolve"。race 和 allSettled 处于两个极端——race 抢第一个 settle 的、allSettled 等所有人 settle。
空数组的边界陷阱
每个静态方法对空数组的行为都不一样,面试常考:
race([]) 那条尤其阴险——程序不会报错,也不会走任何分支,就是永远卡住。如果你在线上看到一个"既不成功也不失败"的链路,这是一个值得排查的方向。
any 的 AggregateError
any 是 ES2021 才进规范的,配套引入了 AggregateError——一个能装多个错误原因的特殊错误对象。本步代码里 new AggregateError(errs, 'All promises were rejected') 第一个参数就是各路失败原因的数组,第二个参数是统一的 message。
这个设计的好处是:调用方可以通过 err.errors 拿到完整的失败列表,决定是统一处理还是分别报告。如果只 reject 第一个失败的 reason,信息就丢了。
用规范测试给自己打分
promises-aplus-tests 是 Promises/A+ 官方测试套件,包含 872 条用例,专门用来检验"是不是真的合规"。它的工作方式是:你提供一个 adapter 对象,暴露三个工厂函数(左边代码);测试套件会用它们造出各种 Promise 来跑测试。
实际跑一遍的步骤:
-
把
MyPromise整理到一个独立文件,暴露 default export。 -
pnpm add -D promises-aplus-tests -
写一个
adapter.cjs: -
跑:
npx promises-aplus-tests adapter.cjs -
顺利的话会看到
872 passing。如果某条 fail,套件会指明是哪一节哪一项不合规,对照规范回去补即可——v1~v5 这条主线已经覆盖了 90% 以上的用例。
收束
回头看,整套手写 Promise 其实只用了两条事实:
- JS 是单线程,异步必须把"等待"交给宿主,回调被排进任务队列。
- 微任务是"插队任务"——它让
then可以在当前轮事件循环结束前就被执行。
剩下所有代码——if (state !== 'pending') return、订阅者数组、queueMicrotask 包裹、promise2 链式、resolvePromise 的四种情况——都是在这两条事实之上,加上"状态不可逆"和"then 必须返回新 Promise"两条约束逼出来的。
V8 等真实引擎的实现当然比这复杂得多——它们会用原生 job queue 替代 queueMicrotask,会用隐藏类、内联缓存等手段优化性能,也会增加 Promise.try / Promise.withResolvers 这些较新的 API。但形状和我们手写的这一版完全一致。
如果你能把"为什么单线程 → 单线程的代价 → 异步三件套 → 宏任务 vs 微任务 → 输出顺序机械推导 → Promise 状态机 → v1 到 v5"这条因果链自己讲一遍,那么之后无论是面试被问到"输出顺序题"还是"手写 Promise",都不会再卡壳。