事件循环承诺机制
# 08.事件循环 Promise 一体论
📍 上接第 07 篇《Proxy 元编程与迭代协议》。同步世界的工具已就位。本文进入 JS 的灵魂——异步。事件循环为什么先 micro 后 macro?Promise 的状态机怎么保证"状态不可逆"?async/await 到底脱糖成了什么?手写一个跑通 872 条测试用例的 Promise,它需要哪几块核心代码?
# 目录介绍
- 1. 案例与疑问引入
- 2. 架构全景概览
- 3. 宏任务队列详解
- 4. 微任务队列详解
- 5. Promise实
- 6. Promise实
- 7. 六大反模式详解
- 8. async实现
- 9. 错误并发控制
- 10. 综合案例串讲
# 1. 案例与疑问引入
# 1.1 一个点击后 3
先看一段在生产环境真实跑过的代码——一个表单编辑页面,每输入一个字符都会触发"自动保存"逻辑。用户反馈:"打完一行字,点了保存按钮,3 秒之后才弹提示":
// auto-save.js —— 自动保存引擎(故障版本)
let isDirty = false;
const pendingSaves = [];
// 监听输入——每输入一个字符就标记"脏了"
input.addEventListener('input', () => {
isDirty = true;
});
// 每 500ms 检查一次是否需要保存
setInterval(() => {
if (isDirty) {
isDirty = false;
pendingSaves.push(Date.now());
// "自动保存"——把当前内容序列化并写入 localStorage
Promise.resolve().then(() => {
const data = editor.getValue();
localStorage.setItem('draft', data); // ← 同步 API——阻塞微任务!
console.log('Auto-saved at', pendingSaves.shift());
// "保存完成后"再触发一次检查(以防下次输入太快)
Promise.resolve().then(() => {
if (isDirty) {
// 又脏了?再排一次队
isDirty = false;
pendingSaves.push(Date.now());
Promise.resolve().then(() => {
localStorage.setItem('draft', editor.getValue());
pendingSaves.shift();
});
}
});
});
}
}, 500);
// 保存按钮——用户点它期望立刻响应
saveBtn.addEventListener('click', () => {
console.log('Save clicked!');
// ...发送网络请求保存数据...
alert('Saved!');
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
现象:
- 快速输入 20 个字符(每 200ms 一个字符)→ 每 500ms 触发一次
Promise.resolve().then保存 → 微任务队列有 5 个在排队的localStorage.setItem - 用户在输入过程中点击"保存"按钮 →
click事件(宏任务)被放入宏任务队列 - 但当前宏任务(
setInterval回调)的微任务队列还没清空——有 5 个localStorage.setItem('draft', ...)在排队,每个耗时 ~3ms click事件要等当前宏任务执行完 + 所有微任务清空之后才能执行- 总延迟 = 5 × 3ms(localStorage 写入)= 15ms——理论上不严重
- 但用户输入了 200 个字符(持续打字 40 秒)→
pendingSaves累积了几十个 → 微任务队列里积压了几十个Promise.resolve().then的嵌套 → 点击按钮到弹 alert 延迟超过 3 秒
核心问题不是 localStorage.setItem 慢——而是微任务在清空队列时是"一口气"全部执行的,不会中断去处理下一个宏任务。
# 1.2 顺藤摸到根因
带着这条线往下挖:
- 假设 1:是不是
localStorage.setItem太慢了?——单独测试:一次setItem约 1-3ms。20 次确实只要 60ms。但这里是"嵌套的微任务",每次的 then 回调里又会触发新的 then——形成一个"微任务瀑布"。浏览器会一口气执行完所有这些嵌套微任务——不给宏任务(如 click 事件)任何插队机会。 - 假设 2:那为什么不用
setTimeout代替Promise.resolve().then?——因为setTimeout是宏任务——它排在当前宏任务之后的队列中。如果改成setTimeout,保存操作会在当前宏任务结束、微任务清空、渲染之后再执行——不会阻塞 click 事件的响应。但代价是:保存延迟从 0ms(微任务立即执行)变成 ~4ms(宏任务到下一轮才执行)——对自动保存来说可接受。 - 假设 3:那为什么浏览器不设置"微任务执行上限"?——因为微任务的设计初衷就是"在当前宏任务结束前,尽快完成所有轻量更新"。如果限制微任务数量,就会有些
Promise.then的回调被推迟到下一轮——违背了"then 的回调在当前宏任务结束前执行"的规范承诺。ECMA-262 规范没有规定微任务的数量上限——实现者(V8/JSC/SpiderMonkey)可以自行决定。 - 假设 4:V8 有防护吗?——有。V8 内部有"微任务递归深度检查"——如果一个微任务中产生的微任务数量超过一定阈值(V8 的
kMaxRecursion),会触发警告。但这不是硬限制——理论上你可以让微任务嵌套到内存耗尽。V8 的设计哲学是:不对微任务执行次数设硬上限,但如果明显在制造"无限微任务"则报警。
# 1.3 我们要回答什么
这段代码里至少藏着 7 个原理点:
① 事件循环一个 tick 的完整流程:宏任务→微任务→渲染——每一步谁在等谁? → 第 2 章
② 微任务为什么是"一口气清空"而不是一次执行一个? → 第 2.2 节
③ 宏任务有哪六种来源?它们的优先级为什么不同? → 第 3 章
④ MutationObserver 为什么选择微任务而非宏任务或同步回调? → 第 4.2 节
⑤ Promise/A+ 规范要求 then 的回调必须是异步的——这个"异步"怎么保证? → 第 5.2 节
⑥ async/await 脱糖后到底是什么?Babel 为什么把它变成 Generator+Promise? → 第 8 章
⑦ Promise.allSettled 和 Promise.any 的短路语义为什么不一样? → 第 9.2 节
2
3
4
5
6
7
本篇路线:
架构总图(第 2 章)
↓
宏任务队列(第 3 章)──→ 解开"六种宏任务的优先级和调度差异"
↓
微任务队列(第 4 章)──→ 解开"微任务的执行时机和 V8 内部检查点"
↓
手写 Promise(第 5~6 章)──→ 解开"Promise/A+ 872 条用例的每一条要求"
↓
六大反模式(第 7 章)──→ 解开"生产代码中的高频陷阱"
↓
async/await 本质(第 8 章)──→ 解开"语法糖是怎么变成 Generator 的"
↓
错误处理和并发(第 9 章)──→ 解开"五种并发方法的短路与不短路语义"
↓
综合案例(第 10 章)──→ 案例彻底剖开 + 哲学四条 + 速查表
2
3
4
5
6
7
8
9
10
11
12
13
14
15
📌 本篇定位:这是 JS 异步的总枢纽篇——第 03~07 篇讲的是"同步世界的语法和工具",本篇讲"异步世界的根本机制"。事件循环是 JS 单线程并发模型的基石;Promise 是异步操作的统一抽象。读完本篇后,你对每一行
await、每一个.then()、每一个setTimeout在什么时机执行,不再有模糊地带。
# 2. 架构全景概览
# 2.1 浏览器事件循环完
┌──────────────────────────────────────────────────────────────────┐
│ 浏览器事件循环 —— 一个 tick 的完整流程 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ① 从宏任务队列中取出一个 task(FIFO) │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ 执行这个 task —— 一段同步的 JS 代码 │ │ │
│ │ │ 这可能是:script 整体执行 / setTimeout 回调 / 事件回调 │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────┬───────────────────────────────────┘ │
│ │ 当前 task 执行完毕 │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ② 清空微任务队列(Microtask Checkpoint) │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ while (microtaskQueue.length > 0) { │ │ │
│ │ │ const microtask = microtaskQueue.shift(); │ │ │
│ │ │ microtask.run(); │ │ │
│ │ │ // ⚠️ 如果这个微任务产生了新的微任务 → 追加到队列尾部 │ │ │
│ │ │ // → 同一轮全部清空! │ │ │
│ │ │ } │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────┬───────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ③ 检查是否需要渲染(如果有渲染管线任务) │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ if (需要渲染) { │ │ │
│ │ │ requestAnimationFrame 回调执行 │ │ │
│ │ │ → Style Recalc → Layout → Paint → Composite │ │ │
│ │ │ } │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────┬───────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ④ 回到步骤 ① —— 取下一个宏任务 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
V8 内部——Microtask Checkpoint 的实现位置:
在 V8 源码中,微任务的"清空点"不是由 JS 代码触发的——它嵌入在 V8 执行完每一段 JS 代码之后。具体在
src/execution/microtask-queue.cc中的MicrotaskQueue::RunMicrotasks()函数。每当 V8 的Invoke()函数执行完一段 JS(如一个 script 标签、一个事件回调、一个 setTimeout 回调),V8 都会检查微任务队列——如果有待执行的微任务,就在这里同步清空它们,然后才把控制权交还给事件循环。
# 2.2 为什么微任务必须
疑惑:如果浏览器"每次只执行一个微任务,然后检查是否需要渲染",是不是就能解决第 1 章的按钮延迟问题?
论证:
这种方案的问题在于:微任务的"轻量"假设被打破了。
微任务被设计成"在两次可能引发渲染的时机之间,给开发者一个尽快完成轻量更新的机会"。规范假设的是:Promise.then 里的回调都很轻——更新一个 DOM 属性、改一个变量——几微秒完成。在这种假设下,"一口气全清空"是合理的——与其每次微任务后检查一次渲染(开销大),不如全部跑完再渲染(一次渲染覆盖所有变更)。
但如果微任务里的回调做了重任务(如 localStorage.setItem 同步写入磁盘 3ms),"一口气全清空"就会变成"一口气卡死主线程"。
正确的思路不是"改变微任务的清空策略"——而是"不要把重任务放进微任务":
| 任务类型 | 放哪里 | 原因 |
|---|---|---|
更新一个 DOM 属性(el.style.color = 'red') | 微任务(Promise.then) | 轻量——1µs 完成,且需要在当前帧渲染前生效 |
读一个 DOM 布局属性(el.offsetHeight) | 不需要微任务——直接读 | 已经在同步代码中 |
| 写入 localStorage(磁盘 I/O) | 宏任务(setTimeout) | 重——3ms,不应该阻塞其他微任务和渲染 |
发送网络请求(fetch) | 本来就是异步的——直接调用 | fetch 本身就是宏任务(I/O 回调) |
| 大量 DOM 插入(1000 个元素) | requestAnimationFrame | 对齐渲染帧——在帧开始时做 |
结论:微任务的"一口气清空"策略是正确的——前提是开发者遵守轻量承诺。如果这个承诺被打破(在微任务里做重 CPU/磁盘 I/O 操作),修复方向是"把重操作移到宏任务",而不是"修改浏览器的清空策略"。
# 3. 宏任务队列详解
# 3.1 script
疑惑:同样是宏任务,为什么"点击按钮"和"setTimeout 回调"在 Chrome 里的调度行为不同?
论证——浏览器内部不是"一个队列",而是多个队列,每种事件来源(task source)有自己的队列:
┌───────────────────────────────────────────────────────────────┐
│ 浏览器内部的多个宏任务队列(task sources) │
├───────────────────────────────────────────────────────────────┤
│ │
│ ① DOM 操作任务源: │
│ 来源:<script> 标签、document.write │
│ 优先级:最高——这是"当前正在跑的代码" │
│ │
│ ② 用户交互任务源: │
│ 来源:click / input / keydown / scroll 等 │
│ 优先级:高——用户输入应该被尽快响应(~1ms 内) │
│ │
│ ③ 网络任务源: │
│ 来源:fetch 回调 / XHR 回调 / WebSocket 消息 │
│ 优先级:中——网络 RTT 远大于调度延迟,提高调度优先级无意义 │
│ │
│ ④ 定时器任务源: │
│ 来源:setTimeout / setInterval │
│ 优先级:中低——浏览器会等更高优先级队列清空后才处理它们 │
│ ⚠️ 这个队列受 4ms 夹持约束(第 09 篇展开) │
│ │
│ ⑤ 媒体任务源: │
│ 来源:<video> / <audio> 事件 │
│ 优先级:高——音视频播放不能卡 │
│ │
│ ⑥ 消息任务源: │
│ 来源:postMessage / MessageChannel │
│ 优先级:中高——用于跨窗口/Worker 通信,不希望被延迟 │
│ │
│ 事件循环的调度逻辑(HTML Spec §8.1.4.2): │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 每次"选下一个宏任务"时: │ │
│ │ 1. 优先从"用户交互"队列取(对用户点击的响应必须快) │ │
│ │ 2. 然后从"消息"队列取 │ │
│ │ 3. 然后从"网络"和"定时器"队列取 │ │
│ │ 4. "定时器"可能被推迟到多个 tick 之后 │ │
│ │ │ │
│ │ 这不是"严格的优先级队列"——而是"计算机选择"(Picking a Task) │ │
│ │ 浏览器可以根据自己的策略决定从哪个队列取 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
六种宏任务来源对比:
| 来源 | 典型 API | 触发时机 | 相对优先级 | 嵌套夹持 |
|---|---|---|---|---|
| DOM 操作 | <script> 执行 | 页面加载时 | 最高 | — |
| 用户交互 | click / input / scroll | 真实用户操作时 | 高 | — |
| 消息通信 | postMessage | 代码调用 postMessage 时 | 中高 | — |
| 网络 | fetch 回调 / XHR.onload | 网络响应就绪时 | 中 | — |
| 定时器 | setTimeout / setInterval | 延迟到期时 | 中低 | 嵌套≥5 层 → 4ms |
| 媒体 | <video>.onplay | 媒体事件触发时 | 高 | — |
# 3.2 不同宏任务源的优
// 测试:postMessage 和 setTimeout 谁先执行?
setTimeout(() => console.log('setTimeout'), 0);
const channel = new MessageChannel();
channel.port1.onmessage = () => console.log('postMessage');
channel.port2.postMessage(null);
// postMessage 内部调用:channel.port2.postMessage → 触发 port1.onmessage
// Chrome 输出:postMessage → setTimeout
// 原因:MessageChannel 属于"消息任务源"——优先级高于"定时器任务源"
2
3
4
5
6
7
8
9
10
React Scheduler 就利用了这个差异:React 的并发模式需要"高优先级但又是宏任务"的调度原语(既要能中断、又要优先级高于 setTimeout)。React 选择 MessageChannel 来实现 scheduler——因为 postMessage 是宏任务(可被更高级的事件中断),但优先级高于 setTimeout(不会被 4ms 夹持)。
# 3.3 浏览器 vs N
| 维度 | 浏览器 | Node.js |
|---|---|---|
| 事件循环模型 | HTML Spec §8.1.4 — macroTask→microTask→render | libuv 六阶段 — timers→pending→poll→check→close |
| 宏任务类 | 6 种来源队列(DOM/用户交互/网络/定时器/媒体/消息) | 4 种阶段(timers/poll/check/close) |
| 微任务 | Promise.then + MutationObserver + queueMicrotask | Promise.then + process.nextTick + queueMicrotask |
| setImmediate | ❌ | ✅ check 阶段执行 |
| 渲染管线 | ✅ | ❌ |
nextTick | ❌ | ✅ 优先级高于 Promise.then |
详细对比见第 12 篇《Node.js 运行时与流式编程》第 3 章。
# 4. 微任务队列详解
# 4.1 Promise.
疑惑:Promise.then(fn) 的回调 fn 到底什么时候被加入微任务队列?是 .then() 被调用时,还是 Promise resolve 时?
论证——这取决于 Promise 当前的状态:
// 场景 A:Promise 已经 settled——then 立即入队微任务
const p1 = Promise.resolve('done'); // p1 已经是 fulfilled
p1.then(v => console.log('A:', v)); // ← 立刻注册微任务
console.log('sync');
// → sync → A: done
// 场景 B:Promise 还是 pending——then 的回调先存储,等 resolve 时再入队
let resolveP2;
const p2 = new Promise(r => { resolveP2 = r; });
p2.then(v => console.log('B:', v)); // ← 回调被存储到 p2 的 [[PromiseFulfillReactions]] 列表中
console.log('sync');
// → sync('B' 还没输出——因为 p2 还没 resolve)
resolveP2('done'); // ← 此时把回调加入微任务队列
// → B: done
2
3
4
5
6
7
8
9
10
11
12
13
14
V8 内部——PromiseReaction 的执行路径:
Promise.resolve(value)
│
▼
① p.[[PromiseState]] = fulfilled, p.[[PromiseResult]] = value
│
▼
② 遍历 p.[[PromiseFulfillReactions]](存储所有 then 的回调)
│
▼
③ 对每个 reaction:
→ 创建一个 PromiseReaction Job(微任务)
→ EnqueueJob("PromiseJobs", PromiseReactionJob)
→ 把 Job 加入 V8 的微任务队列
│
▼
④ 当前宏任务执行完毕 → V8 的 Microtask Checkpoint 到达
│
▼
⑤ 执行 PromiseReactionJob:
→ 调用 reaction.handler(value)
→ 拿到返回值 → resolve 下一个 Promise(链式调用)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 4.2 Mutation
疑惑:如果我在同一个同步代码块中改变 DOM 10 次,MutationObserver 的回调会触发几次?
论证——一次,且以微任务形式触发:
let count = 0;
const observer = new MutationObserver(() => {
console.log('Mutation called, count =', ++count);
});
observer.observe(document.body, { childList: true });
// 同步代码块:连续修改 DOM 10 次
for (let i = 0; i < 10; i++) {
document.body.appendChild(document.createElement('div'));
}
console.log('sync done');
// → sync done
// → Mutation called, count = 1 ← 只触发了一次!
// 为什么是一次?
// 每次 appendChild 时,浏览器把 mutation record 追加到 observer 的内部缓冲区
// 当前宏任务执行完毕 → V8 进入 Microtask Checkpoint → 清空微任务队列
// → observer 的回调被放在微任务队列中 → 此时浏览器才"通知"你——
// 把 10 条 mutation records 打包成一个数组传给你
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
为什么选微任务而不是同步回调:
如果 MutationObserver 是同步的(每次 appendChild 立刻触发回调),那 10 次 DOM 修改 = 10 次同步回调。如果回调里又修改了 DOM(响应式更新)→ 触发更多 MutationObserver → 递归 → "无法停止的连锁反应"。微任务把多个同步变更聚合为一次异步通知——既保证了"所有变更都被记录",又避免了同步反馈循环。
# 4.3 queueMic
// queueMicrotask:标准微任务入口(ECMA-262 §27.5)
queueMicrotask(() => console.log('via queueMicrotask'));
// Promise.resolve().then:同样入微任务——但多一次 Promise 创建开销
Promise.resolve().then(() => console.log('via Promise'));
// 两者在同一个微任务队列中,按注册顺序执行
queueMicrotask(() => console.log('1'));
Promise.resolve().then(() => console.log('2'));
// → 1 → 2
// 性能差异:queueMicrotask 不创建 Promise 对象 → 更轻量
// 推荐:意图是"往微任务队列放一个回调" → 用 queueMicrotask
// 意图是"在 Promise 链上追加操作" → 用 .then()
2
3
4
5
6
7
8
9
10
11
12
13
14
# 5. Promise实
# 5.1 构造函数 + 三
疑惑:Promise 的状态为什么必须是"不可逆"的?resolve 之后不能变 reject,反之亦然?
论证——规范要求 ECAM-262 §27.2.1.2:
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
class MyPromise {
constructor(executor) {
this.state = PENDING; // ← 初始:等待中
this.value = undefined; // fulfilled 时的值
this.reason = undefined; // rejected 时的原因
this.onFulfilledCallbacks = []; // 等状态变更后要执行的成功回调
this.onRejectedCallbacks = []; // 等状态变更后要执行的失败回调
const resolve = (value) => {
// ⚠️ 状态只能从 PENDING 变成 FULFILLED——不能逆转!
if (this.state !== PENDING) return;
this.state = FULFILLED;
this.value = value;
// 状态变了 → 异步执行所有等待的 onFulfilled 回调
this.onFulfilledCallbacks.forEach(fn => fn());
};
const reject = (reason) => {
// ⚠️ 同样:只能从 PENDING 变成 REJECTED
if (this.state !== PENDING) return;
this.state = REJECTED;
this.reason = reason;
this.onRejectedCallbacks.forEach(fn => fn());
};
try { executor(resolve, reject); }
catch (e) { reject(e); }
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
为什么必须不可逆:
// 如果状态可逆——反例:
// resolve() 之后又 reject() → then 的第一个回调执行了 → 但状态又被改成 rejected
// → 同一个 Promise 的 then 到底该调用哪个回调? → 不可预测
// ❌ 如果状态可逆——这段代码的行为无法确定:
const p = new Promise((resolve, reject) => {
resolve('A'); // 状态 → fulfilled
reject('B'); // 如果允许这行执行 → 状态倒回到 rejected → 破坏确定性
});
p.then(v => console.log('then:', v), e => console.log('catch:', e));
// then 和 catch 哪个会执行?取决于 resolve 和 reject 被调用的顺序——
// "不确定性"是异步编程的最大敌人
2
3
4
5
6
7
8
9
10
11
12
结论:不可逆的状态机是 Promise 的基石——它保证了"一个 Promise 只会有一个结果",消除了异步操作中"结果来回变"的混乱。
# 5.2 then 的链式
then(onFulfilled, onRejected) {
// 值穿透:如果没传 onFulfilled → 用默认函数把值传给下一个 then
onFulfilled = typeof onFulfilled === 'function'
? onFulfilled : value => value;
onRejected = typeof onRejected === 'function'
? onRejected : reason => { throw reason; };
// then 必须返回一个新 Promise(链式调用的关键)
const promise2 = new MyPromise((resolve, reject) => {
const handleFulfilled = () => {
// ⚠️ A+ 规范 §2.2.4:onFulfilled 必须异步调用(不能同步!)
queueMicrotask(() => {
try {
const x = onFulfilled(this.value);
// 递归解析:x 可能是普通值(直接 resolve)或另一个 Promise(等它 settle)
resolvePromise(promise2, x, resolve, reject);
} catch (e) {
reject(e);
}
});
};
const handleRejected = () => {
// ⚠️ A+ 规范 §2.2.4:onRejected 同样必须异步调用
queueMicrotask(() => {
try {
const x = onRejected(this.reason);
resolvePromise(promise2, x, resolve, reject);
} catch (e) {
reject(e);
}
});
};
// 根据当前状态决定:立刻执行 或 存储等待
if (this.state === FULFILLED) {
handleFulfilled(); // ← 已 fulfilled → 立刻把回调入微任务
} else if (this.state === REJECTED) {
handleRejected();
} else { // PENDING
// ← 还是 pending → 把回调存起来,等状态变更时再调用
this.onFulfilledCallbacks.push(handleFulfilled);
this.onRejectedCallbacks.push(handleRejected);
}
});
return promise2;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
为什么 then 的回调必须是异步的(A+ §2.2.4):
// 如果回调是同步的——反例:
const p = Promise.resolve('A');
p.then(v => console.log('1:', v));
console.log('2: sync');
// 如果 then 同步执行 → 1: A → 2: sync (先输出 1)
// 如果 then 异步执行 → 2: sync → 1: A (先输出 2)
// A+ 规范选择了后者:回调永远是异步的。
// 原因:保证"行为一致"。
// 即使 Promise 已经 settled,then 的回调也不应该"突然在同步代码中间执行"。
// 这让开发者可以把 Promise.then 写在同步代码的任何位置——
// 而不担心"这个 then 会不会突然打断我的同步代码"。
2
3
4
5
6
7
8
9
10
11
12
# 5.3 递归解析 the
这是 Promise/A+ 规范中最复杂的部分。resolvePromise 要处理四种情况:x 是 promise2 自身(循环引用)、x 是 MyPromise 实例、x 是 thenable 对象、x 是普通值。
function resolvePromise(promise2, x, resolve, reject) {
// ① 循环引用——'promise2' 和 'x' 指向同一个对象
if (promise2 === x) {
return reject(new TypeError('Chaining cycle detected for promise'));
}
// ② x 是 MyPromise 实例——直接接管它的状态
if (x instanceof MyPromise) {
x.then(resolve, reject);
}
// ③ x 是 thenable 对象(有 .then 方法的对象/函数)
else if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
let called = false; // ← 防止 resolve 和 reject 都被调用
try {
const then = x.then; // 只取一次——防止 getter 副作用
if (typeof then === 'function') {
then.call(x,
y => {
if (called) return; called = true;
// 递归解析——y 可能又是一个 Promise
resolvePromise(promise2, y, resolve, reject);
},
r => {
if (called) return; called = true;
reject(r);
}
);
} else {
resolve(x); // x.then 不是函数 → 当作普通值
}
} catch (e) {
if (called) return; called = true;
reject(e);
}
}
// ④ 普通值——直接 resolve
else {
resolve(x);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
called 标志位的作用:
// 场景:一个 thenable 同时调用了 resolve 和 reject
const maliciousThenable = {
then(resolve, reject) {
resolve('A'); // ← 第一次调用 → 应该生效
reject('B'); // ← 第二次调用 → 应该被忽略!
}
};
// 如果没有 called 标志位:
// resolve('A') → promise2 变成 fulfilled (value='A')
// reject('B') → promise2 变成 rejected (reason='B') ← 把 A 覆盖了!
// → 状态不可逆被打破!
// 有了 called 标志位:
// resolve('A') → called = true → promise2 fulfilled
// reject('B') → called 已经 true → return(什么都不做)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 6. Promise实
# 6.1 catch
// catch = 只传 onRejected 的 then
catch(onRejected) {
return this.then(null, onRejected);
}
// finally = "无论如何都要执行这个回调——但不要改变决议"
finally(onFinally) {
return this.then(
// fulfilled 路径:先执行 finally 回调,然后把原值继续往下传
value => MyPromise.resolve(onFinally()).then(() => value),
// rejected 路径:先执行 finally 回调,然后把原因继续往下抛
reason => MyPromise.resolve(onFinally()).then(() => { throw reason; })
);
// ⚠️ 关键点:
// 1. finally 的回调不接收参数(不关心值是啥——只关心"时候到了")
// 2. finally 新返回 Promise 继续前一个 Promise 的 value/reason
// 3. 但如果 finally 的回调返回了 rejected Promise → 决议被"覆盖"!
}
// 验证 finally 的"穿透"行为:
const p = Promise.resolve('hello');
p.finally(() => console.log('cleanup'))
.then(v => console.log(v)); // → cleanup → hello ← 值穿透了!
// 但如果 finally 回调返回 rejected Promise:
p.finally(() => Promise.reject('override'))
.then(v => console.log(v)) // ← 不会执行!
.catch(e => console.log(e)); // → override ← 被 finally 的 rejection 覆盖了
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 6.2 all / ra
// resolve / reject:快速创建 settled Promise
static resolve(value) {
if (value instanceof MyPromise) return value; // 已经是 Promise → 直接返回
return new MyPromise(resolve => resolve(value));
}
static reject(reason) {
return new MyPromise((_, reject) => reject(reason));
}
// all:全部成功 → 成功;任一个失败 → 失败(短路)
static all(promises) {
return new MyPromise((resolve, reject) => {
const result = [];
let count = 0;
for (let i = 0; i < promises.length; i++) {
MyPromise.resolve(promises[i]).then(
value => { result[i] = value; if (++count === promises.length) resolve(result); },
reject // ← 任一个失败直接 reject(短路:后面的不再等待)
);
}
});
}
// race:最快的决定结果(无论成功或失败)
static race(promises) {
return new MyPromise((resolve, reject) => {
for (const p of promises) {
MyPromise.resolve(p).then(resolve, reject);
// ← 第一个 settle 的 Promise 直接决定结果(后面的"比赛"被忽略)
}
});
}
// allSettled:全部跑完,不管成功失败——每个的结果都记录下来
static allSettled(promises) {
return new MyPromise(resolve => {
const result = [];
let count = 0;
const handle = (i, status, val) => {
result[i] = { status, [status === 'fulfilled' ? 'value' : 'reason']: val };
if (++count === promises.length) resolve(result);
};
for (let i = 0; i < promises.length; i++) {
MyPromise.resolve(promises[i]).then(
v => handle(i, 'fulfilled', v),
r => handle(i, 'rejected', r)
);
}
});
}
// any:第一个成功 → 成功(短路);全失败 → AggregateError
static any(promises) {
return new MyPromise((resolve, reject) => {
let count = 0;
const errors = [];
for (let i = 0; i < promises.length; i++) {
MyPromise.resolve(promises[i]).then(
resolve, // ← 第一个成功立刻 resolve(短路)
err => {
errors[i] = err;
if (++count === promises.length) {
reject(new AggregateError(errors, 'All promises were rejected'));
}
}
);
}
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
五种并发方法的精确语义:
| 方法 | 何时决议 | 短路? | ES 版本 |
|---|---|---|---|
Promise.all | 全成功 → resolve;任一失败 → reject | ✅(失败) | ES2015 |
Promise.race | 第一个 settle 的 Promise 决定结果 | ✅(第一个) | ES2015 |
Promise.allSettled | 全跑完 → resolve(永不 reject) | ❌ | ES2020 |
Promise.any | 任一成功 → resolve;全失败 → AggregateError | ✅(成功) | ES2021 |
# 6.3 A+ 测试套件跑
Promise/A+ 测试套件(promises-aplus-tests)包含 872 条测试用例,核心验证点:
| 测试类别 | 覆盖内容 | 典型边界条件 |
|---|---|---|
| 2.1 状态 | 三个状态和状态转换 | resolve() 后 reject() → 应被忽略;executor 抛异常 → 应 reject |
| 2.2 then | then 的链式调用和异步性 | then 回调不传 → 值穿透;回调抛异常 → 下一个 then 的 onRejected 应收到 |
| 2.3 Promise 解析 | thenable 的递归解析 | 循环引用检测;thenable 里的 getter 抛异常;resolve() 里传 Promise 本身 |
| 3.x 静态方法 | resolve/reject/all/race 等 | all 传空数组;race 传空数组;传 already-settled Promise |
适配器代码:
module.exports = {
resolved: MyPromise.resolve.bind(MyPromise),
rejected: MyPromise.reject.bind(MyPromise),
deferred: () => {
let resolve, reject;
const promise = new MyPromise((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
};
// 运行:npx promises-aplus-tests ./my-promise.js
2
3
4
5
6
7
8
9
10
# 7. 六大反模式详解
# 7.1 构造器反模式
// ❌ 反模式 1:构造器反模式——把已有的 Promise 包在 new Promise 里
function fetchData() {
return new Promise((resolve, reject) => {
fetch('/api').then(resolve).catch(reject);
// fetch 本身返回 Promise——你不需要再包一层!
});
}
// ✅ 直接返回
function fetchData() { return fetch('/api'); }
// ❌ 反模式 2:忘记 return——链断掉了
fetch('/user')
.then(res => { res.json(); }) // ← 没 return!下一个 then 拿到 undefined
.then(user => console.log(user)); // → undefined
// ✅ 记得 return
fetch('/user').then(res => res.json()).then(user => console.log(user));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 7.2 嵌套地狱 vs
// ❌ 反模式 3:嵌套地狱——.then 里面又 .then(不是链式!)
fetch('/a').then(a => {
fetch('/b').then(b => { /* b 依赖于 a */ });
});
// ✅ 扁平链——每个 then 返回一个 Promise
fetch('/a').then(a => fetch('/b')).then(b => { /* b */ });
// ❌ 反模式 4:串行执行独立请求——浪费 3×时间
await fetch('/a'); await fetch('/b'); await fetch('/c'); // 三次串行,各 200ms
// ✅ 并行——1×时间
const [a, b, c] = await Promise.all([fetch('/a'), fetch('/b'), fetch('/c')]);
// ❌ 反模式 5:不处理 rejection——静默吞掉错误
fetch('/api'); // 如果网络失败 → UnhandledPromiseRejectionWarning → Node 14+ 会杀进程
// ✅ 总是加 .catch() 或 try/catch
// ❌ 反模式 6:forEach + async——并行但不可控(详见 §8.3)
urls.forEach(async url => { await fetch(url); });
// ✅ 用 for...of + await(顺序可控)
for (const url of urls) { await fetch(url); }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 8. async实现
# 8.1 Generator
疑惑:async function 在被 Babel 转换后,变成了 Generator + Promise——但这两种机制为什么刚好能组合?
论证——async/await 本质上是利用 Generator 的"暂停-恢复"能力来装饰 Promise 链:
// ① 源码(ES2017)
async function fetchUser(id) {
const user = await fetch(`/user/${id}`);
const orders = await fetch(`/orders/${user.id}`);
return { user, orders };
}
// ② Babel 脱糖后的等价代码(简化后):
function fetchUser(id) {
return _asyncToGenerator(function*() {
const user = yield fetch(`/user/${id}`); // ← await 变成 yield
const orders = yield fetch(`/orders/${user.id}`);
return { user, orders };
})();
}
// ③ _asyncToGenerator 的核心实现:
function _asyncToGenerator(genFn) {
return function() {
return new Promise((resolve, reject) => {
const gen = genFn.apply(this, arguments);
function step(key, arg) {
let result;
try {
result = gen[key](arg); // gen.next(arg) 或 gen.throw(e)
} catch (e) {
return reject(e);
}
const { value, done } = result;
if (done) {
return resolve(value); // ← Generator 执行完毕 → resolve
}
// ⚠️ 核心:value 是 yield 产出的 Promise → 等它 settle 再推进 Generator
Promise.resolve(value).then(
v => step('next', v), // ← Promise 成功 → Generator 继续
e => step('throw', e) // ← Promise 失败 → Generator 抛异常
);
}
step('next'); // ← 启动 Generator
});
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
为什么 Generator + Promise 恰好够用:
| Generator 的能力 | async/await 如何利用它 |
|---|---|
yield 暂停函数执行 | await = yield → 暂停 async 函数 |
gen.next(value) 恢复执行 + 传入值 | await 后的 Promise resolve → .then(v => gen.next(v)) → 把值传回 |
gen.throw(e) 在 yield 位置注入异常 | await 后的 Promise reject → .catch(e => gen.throw(e)) → 让 try/catch 捕获 |
return 结束 Generator | Generator done = true → resolve(value) |
# 8.2 await 的微
// 验证:await null 后的代码被放入微任务
async function demo() {
console.log('A');
await null; // ← await 后面的所有代码 → 被包装为微任务
console.log('B'); // ← 微任务
}
demo();
Promise.resolve().then(() => console.log('C'));
console.log('D');
// → A D B C
// 不是 A B C D(因为 await 后的代码是异步的!)
// B 和 C 都是微任务——B 先注册,所以 B 先执行
2
3
4
5
6
7
8
9
10
11
12
await 的等价转换:
// await expression 的等价代码(ECMA-262 §14.7.5.13):
// await expression
// → Promise.resolve(expression).then(result => { /* await 后面的代码 */ })
async function demo() {
console.log('A');
await null;
console.log('B');
}
// 等价于:
function demo() {
console.log('A');
return Promise.resolve(null).then(() => {
console.log('B'); // ← 这段代码作为 then 的回调 → 被放入微任务队列
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 8.3 for…of
// ❌ forEach + async:每次迭代"发射"一个 async 函数——不等它完成
const urls = ['/a', '/b', '/c'];
urls.forEach(async url => {
const res = await fetch(url); // ← 每个 await 独立——三个 fetch 几乎同时发出
console.log(url, res.status);
});
// 输出顺序不确定:a/b/c 随机——取决于哪个先返回
// 而且外层代码不会等这三个 forEach 完成——forEach 本身不返回 Promise!
// ✅ for…of + await:每次迭代等待当前 await 完成再进入下一次
for (const url of urls) {
const res = await fetch(url);
console.log(url, res.status);
}
// 输出顺序确定:a → b → c(串行——每次等上一个完成)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 9. 错误并发控制
# 9.1 try/catch
// ✅ async 函数内——try/catch 可以捕获 await 的 rejection
async function demo() {
try {
await Promise.reject('async error');
} catch (e) {
console.log('Caught:', e); // → Caught: async error
}
}
// 原理:await 把 rejection 转化为异常 → 抛给包含它的 try/catch
// ❌ 非 async 上下文——try/catch 不能捕获异步 reject
try {
Promise.reject('sync error');
} catch (e) {
console.log('Never runs'); // ← 永远不会执行!
}
// 原因:try/catch 只捕获同步异常。
// Promise.reject 是同步创建了一个 rejected Promise——但 rejection 本身
// 是异步传播的(通过微任务调用 onRejected)。try/catch 早就执行完了。
// → UnhandledPromiseRejectionWarning
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 9.2 五个并发方法的精
| 方法 | 成功条件 | 失败条件 | 短路? | 结果顺序 | ES |
|---|---|---|---|---|---|
Promise.all | 全部成功 | 任一失败(立即 reject) | ✅ 失败短路 | 按输入顺序 | ES2015 |
Promise.race | 第一个成功的 | 第一个失败的 | ✅ 短路 | 不保持顺序 | ES2015 |
Promise.allSettled | 永不 reject——一直等到全部完成 | 不会 reject | ❌ 不短路 | 按输入顺序 | ES2020 |
Promise.any | 任一成功(立即 resolve) | 全部失败 → AggregateError | ✅ 成功短路 | 第一个成功的 | ES2021 |
// 场景选择:
// ① 多个独立请求(一个失败全失败 = 页面核心数据) → Promise.all
// ② 多源数据(有些失败没关系,只记录结果) → Promise.allSettled
// ③ CDN 回源(从多个 CDN 请求同一个文件,谁先到用谁)→ Promise.any
// ④ 超时检测(请求 vs 5s 定时器,谁先 settle) → Promise.race
2
3
4
5
# 9.3 三层错误防护策略
┌──────────────────────────────────────────────────┐
│ Promise 错误处理的三层纵深防御 │
├──────────────────────────────────────────────────┤
│ │
│ 第一层:每个 Promise 链末尾加 .catch() │
│ ┌────────────────────────────────────────────┐ │
│ │ fetch('/api') │ │
│ │ .then(handleSuccess) │ │
│ │ .catch(err => { │ │
│ │ showToast('操作失败'); │ │
│ │ reportError(err); // ← 知道具体哪个操作失败了│ │
│ │ }); │ │
│ └────────────────────────────────────────────┘ │
│ │
│ 第二层:async 函数内用 try/catch 包裹 await │
│ ┌────────────────────────────────────────────┐ │
│ │ async function loadPage() { │ │
│ │ try { │ │
│ │ const data = await fetchData(); │ │
│ │ render(data); │ │
│ │ } catch (e) { │ │
│ │ showFallbackUI(); │ │
│ │ } │ │
│ │ } │ │
│ └────────────────────────────────────────────┘ │
│ │
│ 第三层:全局兜底(最后的防线) │
│ ┌────────────────────────────────────────────┐ │
│ │ window.addEventListener( │ │
│ │ 'unhandledrejection', │ │
│ │ event => { │ │
│ │ // event.reason = 未被处理的 rejection 原因│ │
│ │ // event.promise = 被 reject 的 Promise │ │
│ │ reportException(event.reason); │ │
│ │ event.preventDefault(); // ← 抑制控制台警告│ │
│ │ } │ │
│ │ ); │ │
│ └────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的自动保存按钮延迟——七个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
| ① 事件循环一个 tick 的完整流程 | 第 2.1 节:取宏任务→执行→清空所有微任务→可选渲染→下一个宏任务。微任务在宏任务之间"一口气"全部清空 |
| ② 微任务为什么是一口气清空 | 第 2.2 节:微任务的"轻量更新"假设——规范假设 then 回调几µs 完成。如果遵守轻量承诺→一口气清空是最优的;如果违反→卡主线程。修复是"移重到宏任务" |
| ③ 六种宏任务来源的优先级差异 | 第 3 章:用户交互 > 消息通信 > 网络/定时器。不同 task source 有不同队列,浏览器"选择"而非"严格优先级" |
| ④ MutationObserver 为什么用微任务 | 第 4.2 节:防止同步回调连锁反应——微任务把多个同步 DOM 变更聚合为一次异步批量通知 |
| ⑤ Promise.then 的异步保证 | 第 5.2 节:A+ §2.2.4——then 回调必须用 queueMicrotask 或等价机制异步执行,保证行为一致性 |
| ⑥ async/await 脱糖本质 | 第 8 章:Generator 的暂停-恢复 + Promise 的链式推进。await = yield;.then(v => gen.next(v)) = 恢复并传入值 |
| ⑦ 五个并发方法的语义差异 | 第 9.2 节:all(失败短路)/ race(第一个短路)/ allSettled(不短路)/ any(成功短路) |
修复方案(按代价从小到大):
方案 A:把 localStorage.setItem(同步磁盘 I/O)从微任务移到宏任务
// ❌ 原版:重操作在微任务中——阻塞所有后续宏任务
Promise.resolve().then(() => {
localStorage.setItem('draft', data); // ← 3ms 同步写入
});
// ✅ 修复版:重操作在宏任务中——不阻塞 click 事件等用户交互
setTimeout(() => {
localStorage.setItem('draft', data);
}, 0);
2
3
4
5
6
7
8
9
方案 B:用 requestIdleCallback 做自动保存(理想方案)
// 自动保存是"低优先级任务"——用户不关心它什么时候完成
function scheduleAutoSave(data) {
requestIdleCallback(deadline => {
localStorage.setItem('draft', data);
}, { timeout: 5000 }); // ← 最晚 5 秒内必须保存
}
2
3
4
5
6
# 10.2 实现一个支持重试
class RequestManager {
constructor(maxConcurrent = 5) {
this.maxConcurrent = maxConcurrent;
this.running = 0;
this.queue = [];
}
async request(fn, { retries = 3, timeout = 10000 } = {}) {
// 并发控制:如果正在跑的请求达到上限,排队等待
while (this.running >= this.maxConcurrent) {
await new Promise(r => this.queue.push(r));
}
this.running++;
try {
let lastError;
for (let i = 0; i <= retries; i++) {
try {
const result = await this._withTimeout(fn(), timeout);
return result; // 成功 → 直接返回
} catch (e) {
lastError = e;
if (i < retries) {
// 指数退避 + jitter:200ms → 400ms → 800ms
const base = 200 * Math.pow(2, i);
const jitter = base * (0.5 + Math.random() * 0.5);
await new Promise(r => setTimeout(r, jitter));
}
}
}
throw lastError;
} finally {
this.running--;
if (this.queue.length > 0) this.queue.shift()(); // 排队中的下一个出队
}
}
_withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
)
]);
}
}
const manager = new RequestManager(3); // 最多 3 个并发
const urls = ['/a', '/b', '/c', '/d', '/e'];
const results = await Promise.all(
urls.map(url => manager.request(() => fetch(url), { retries: 2, timeout: 5000 }))
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 10.3 设计哲学回扣
哲学一·「单线程的并发幻觉——事件循环是对"简化 vs 能力"的工程妥协」
JS 是单线程的——但用户不可能等一个请求完成再点下一个按钮。事件循环用非抢占式调度(一个宏任务跑完才换下一个)给了用户并发的幻觉,同时避免了多线程的竞态锁和死锁噩梦。代价是:任何长任务都会阻塞后续所有任务——这就是为什么 Worker(第 09 篇)必须存在。
事件循环不是"因为 JS 引擎做不到多线程所以凑合的"——它是主动选择的设计决策:"开发者心智负担低"优先于"运行时并行度"。在 2009 年(Node.js 诞生)和 1995 年(JavaScript 诞生),这个决策是对的——当时服务器只有 1~2 核。今天 16 核是标配——所以 Worker 应运而生,在不破坏"主线程无锁心智模型"的前提下,给了并行计算的通道。
哲学二·「微任务是宏任务之间的'冲刺区'——设计为轻量,但也可以成为瓶颈」
微任务的设计意图是:在两次"可能引发渲染"的时机之间,给开发者一个尽快完成轻量更新的机会。Promise.then 注册的回调不需要等到下一帧——它就在当前宏任务结束、渲染之前执行。MutationObserver 用它来做"批量 DOM 变更的聚合通知"。微任务的"一口气清空"策略是设计选择——不是 bug。它的脆弱之处在于:假设了开发者会把轻任务放进微任务。 一旦这个假设被打破(把 localStorage.setItem 放进 then 回调)——微任务从"冲刺区"变成了"拥堵区"。
哲学三·「Promise 的不可逆状态——用'单一结果'消除不确定性,是异步编程的根本保障」
Promise 的三个状态(pending/fulfilled/rejected)只能从 pending 出发、一去不回。这个不可逆性是 Promise 比回调强大的根基——它保证了 then 的回调只会被调用一次,且只有一个结果。回调模式下,你的 callback(err, result) 可以被调用零次(忘了调)、一次、甚至两次(两次 resolve)——你完全失控。Promise 用状态机把"结果"和"通知"绑定——状态变了才通知,状态不变不通知,状态不可逆所以通知只有一次。
哲学四·「async/await 不是新事物——是 Generator + Promise 的语法封装,JS 用同一个底层能力驱动了两种控制流」
await 就是 yield——等待的"值"是 Promise,引擎用 .then() 把代码推进下一步。这不是魔术——这是 Generator(第 07 篇 §9)的暂停/恢复机制在 Promise 链上的自然延伸。JS 没有发明两套不同的异步机制——它发明了一个"暂停-恢复"的底层能力(Generator),然后把它在"同步迭代器"和"异步控制流"上各用了一次。 Generator 是语法层面的"保存当前执行位置",Promise 是值层面的"未来的结果"——两个正交的概念组合在一起,恰好能实现 async/await。
# 10.4 速查表
事件循环一个 tick 的四阶段:
| 阶段 | 发生了什么 | 耗时 |
|---|---|---|
| 1. 取宏任务 | 从宏任务队列出队并执行这一整段同步 JS | 取决于任务复杂度 |
| 2. 清空微任务 | 一口气执行所有微任务(包括新产生的) | 取决于微任务数量和每个微任务的耗时 |
| 3. 渲染(可选) | rAF → Style Recalc → Layout → Paint → Composite | ~5-16ms(取决于变更量) |
| 4. 下一个宏任务 | 回到步骤 1 | — |
Promise 六大反模式速查:
| 反模式 | 症状 | 修复 |
|---|---|---|
| 构造器反模式 | new Promise 包裹已有 Promise | 直接返回已有的 Promise |
| 忘记 return | 下一个 then 拿到 undefined | 每个 then 回调加 return |
| 嵌套地狱 | .then 里嵌套 .then | 扁平链:每个 then 返回下一个 Promise |
| 忽略并发 | await 一个接一个(串行) | Promise.all(并行) |
| 忽略 rejection | UnhandledPromiseRejectionWarning | .catch() 或 try/catch |
| forEach + async | 顺序错乱或未等待 | for…of + await 或 Promise.all |
五种并发方法速查:
| 方法 | 成功条件 | 失败条件 | 短路 | ES |
|---|---|---|---|---|
all | 全部成功 | 任一失败 | ✅ 失败 | ES2015 |
race | 第一个成功的 | 第一个失败的 | ✅ 第一个 | ES2015 |
allSettled | 永不 reject | 不会 reject | ❌ | ES2020 |
any | 任一成功 | 全部失败 → AggregateError | ✅ 成功 | ES2021 |
async function 脱糖对照:
| async/await 语法 | 等价 Generator + Promise |
|---|---|
async function fn() {} | function fn() { return _asyncToGenerator(function*(){}); } |
await promise | yield promise |
await 后的代码 | Promise.resolve(value).then(v => gen.next(v)) 的回调 |
return value | resolve(value)(Generator 结束) |
throw error | reject(error) 或 gen.throw(error) |
下一步:事件循环统一了主线程的调度。但 JS 不止一个线程——Worker 和并发即将展开。进入 09.Worker 并发与调度时钟。