浏览器事件循环深度解析
一句话概括
浏览器事件循环是 JavaScript 运行时协调代码执行、DOM 渲染、用户交互等异步操作的核心调度机制,它将任务分为宏任务(MacroTask)和微任务(MicroTask)两类,以固定的处理顺序和帧边界维护着异步操作的执行秩序。
背景与意义
JavaScript 是”单线程”的——但它不”慢”
JavaScript 被设计为单线程语言,原因很简单:为了操作 DOM。如果两个线程同时修改同一个 DOM 元素,浏览器无法确定哪个结果是对的。单线程避免了锁、死锁、竞态条件等复杂问题。
但单线程意味着一个耗时任务(如 while(true))会阻塞所有其他操作——页面无法交互、动画停止、滚动卡死。
这就是事件循环存在的意义:它不是”并行执行”,而是”高效调度”——把任务切成小块,保证交互操作的及时响应。
思考下面的代码会输出什么:
1
2
3
4
5
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出: 1, 4, 3, 2
如果理解不了为什么 3 在 2 之前输出,那说明还不太理解事件循环。
这个输出结果让很多初学 JavaScript 的开发者困惑——为什么 setTimeout(fn, 0) 不立即执行?为什么 Promise 的回调比它先执行?这些问题的答案都在事件循环中。
事件循环的现实意义
| 场景 | 如果事件循环处理不当 |
|---|---|
| 用户点击按钮 | 点击后 500ms 才有响应 → UI 卡顿 |
| 滚动页面 | 滚动监听阻塞 → 帧率从 60fps 降到 20fps |
| 大量数据计算 | 页面完全无响应 → 浏览器弹出”脚本无响应”对话框 |
| 动画 | 丢帧、卡顿、断续 |
| 网络请求回调 | 请求结束但回调延迟数十秒才执行 |
理解事件循环能让这些问题变得可预测、可控制。
概念与定义
事件循环的核心角色
1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────────────────────────────────┐
│ 事件循环 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ 宏任务队列 │ │ 微任务队列│ │ 渲染队列│ │
│ │ │ │ │ │ │ │
│ │ setTimeout│ │ Promise │ │ DOM树 │ │
│ │ 用户交互 │ │ Mutation│ │ CSSOM │ │
│ │ 网络请求 │ │ Observer │ │ 绘制 │ │
│ │ I/O │ │ queueMicro│ │ 合成 │ │
│ │ │ │ -task() │ │ │ │
│ └──────────┘ └──────────┘ └────────┘ │
└──────────────────────────────────────────┘
任务类型速查
| 类型 | 包含 | 执行时机 | 优先级 |
|---|---|---|---|
| 宏任务 | setTimeout, setInterval, setImmediate(Node), I/O, UI 交互事件, requestAnimationFrame | 事件循环每次迭代取一个执行 | 低 |
| 微任务 | Promise.then/catch/finally, MutationObserver, queueMicrotask, process.nextTick(Node) | 宏任务结束后、渲染前,一次性执行完所有 | 高 |
| 渲染 | 样式计算、布局、绘制、合成 | 宏任务 + 微任务完成后,视需要执行 | 在宏/微之后 |
事件循环的一次迭代(Tick)
1
2
3
4
5
6
7
8
9
一次事件循环迭代 (MacroTask Tick):
1. 从宏任务队列中取出一个最旧的任务
2. 执行该宏任务
3. 执行所有微任务(直到微任务队列为空)
↓ 如果微任务中又产生了新的微任务 → 继续执行
4. 执行 requestAnimationFrame 回调(如果到帧边界)
5. 执行样式计算、布局、绘制(渲染更新)
6. 执行 requestIdleCallback 回调(如果有空闲时间)
7. 回到第 1 步
最小示例
可视化事件循环
1
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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
<!-- event-loop-vis.html — 可视化事件循环跟踪器 -->
<!DOCTYPE html>
<html>
<head>
<style>
.timeline { display: flex; gap: 2px; align-items: flex-end; height: 100px; margin: 10px 0; }
.bar { width: 12px; background: #3498db; border-radius: 2px; transition: height 0.2s; }
.bar.macro { background: #e74c3c; }
.bar.micro { background: #2ecc71; }
.bar.raf { background: #f39c12; }
.bar.render { background: #9b59b6; }
</style>
</head>
<body>
<h3>事件循环可视化</h3>
<div id="toolbar">
<button onclick="testMacroTask()">setTimeout (宏任务)</button>
<button onclick="testMicroTask()">Promise (微任务)</button>
<button onclick="testMixed()">混合任务</button>
<button onclick="testHeavy()">长任务 (>50ms)</button>
<button onclick="clearLog()">清除</button>
</div>
<pre id="log" style="border:1px solid #ccc; padding:10px; height:300px; overflow:auto; font-size:12px; font-family:monospace;"></pre>
<div id="timeline" class="timeline"></div>
<script>
const log = document.getElementById('log');
const timeline = document.getElementById('timeline');
let barIndex = 0;
function addLog(text, type = '') {
const time = new Date().toISOString().slice(11, 23);
const entry = document.createElement('div');
entry.textContent = `[${time}] ${text}`;
entry.style.color = type === 'macro' ? '#e74c3c' : type === 'micro' ? '#2ecc71' : '';
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
function addBar(type, height = 30) {
const bar = document.createElement('div');
bar.className = `bar ${type}`;
bar.style.height = (10 + Math.random() * height) + 'px';
bar.title = `${type} #${barIndex}`;
timeline.appendChild(bar);
barIndex++;
}
// 测试 1: 宏任务
function testMacroTask() {
addLog('━━━ 宏任务测试 ━━━', 'macro');
addLog('开始宏任务', 'macro');
setTimeout(() => {
addLog(' setTimeout 回调执行', 'macro');
addBar('macro');
}, 0);
addLog('宏任务结束', 'macro');
addBar('macro', 20);
}
// 测试 2: 微任务
function testMicroTask() {
addLog('━━━ 微任务测试 ━━━', 'micro');
addLog('开始', 'micro');
Promise.resolve().then(() => {
addLog(' Promise.then 执行', 'micro');
addBar('micro');
});
addLog('结束', 'micro');
addBar('micro', 20);
}
// 测试 3: 混合任务
function testMixed() {
addLog('━━━ 混合任务测试 ━━━');
addLog('开始');
// 宏任务
setTimeout(() => {
addLog(' 宏任务: setTimeout', 'macro');
addBar('macro');
}, 0);
// 微任务
Promise.resolve().then(() => {
addLog(' 微任务: Promise.then #1', 'micro');
addBar('micro');
});
// 嵌套微任务
Promise.resolve().then(() => {
addLog(' 微任务: Promise.then #2', 'micro');
addBar('micro');
// 在微任务中创建新的微任务
queueMicrotask(() => {
addLog(' 微任务中的微任务: queueMicrotask', 'micro');
addBar('micro', 15);
});
});
// 又一层嵌套
queueMicrotask(() => {
addLog(' 微任务: queueMicrotask #1', 'micro');
addBar('micro');
});
addLog('结束');
addBar('macro', 15);
}
// 测试 4: 长任务 (>50ms)
function testHeavy() {
addLog('━━━ 长任务测试 ━━━');
// 先启动一个动画观察帧率变化
let animFrame = 0;
function anim() {
if (animFrame > 5) return;
addLog(` rAF #${animFrame++}`, 'raf');
addBar('raf', 10);
requestAnimationFrame(anim);
}
requestAnimationFrame(anim);
// 模拟一个长任务 (阻塞 60ms)
addLog('开始长任务 (60ms)...');
const start = Date.now();
while (Date.now() - start < 60) {}
addLog('长任务结束 (60ms 阻塞完成)');
addBar('macro', 50);
// 长任务期间的定时器(应该在阻塞后执行)
setTimeout(() => {
addLog(' setTimeout (长任务期间创建的)', 'macro');
addBar('macro');
}, 0);
}
function clearLog() {
log.innerHTML = '';
timeline.innerHTML = '';
barIndex = 0;
}
</script>
</body>
</html>
Node.js 事件循环验证
1
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
// event-loop-basics.mjs — 验证事件循环顺序
console.log('1: 同步代码开始');
setTimeout(() => {
console.log('2: setTimeout(0) 回调 — 宏任务');
}, 0);
setTimeout(() => {
console.log('3: setTimeout(0) 第二个 — 宏任务');
}, 0);
Promise.resolve().then(() => {
console.log('4: Promise.then — 微任务');
});
Promise.resolve().then(() => {
console.log('5: 第二个 Promise.then — 微任务');
// 在微任务中嵌套宏任务
setTimeout(() => {
console.log('6: 微任务中创建的 setTimeout — 新宏任务');
}, 0);
});
queueMicrotask(() => {
console.log('7: queueMicrotask — 微任务');
});
console.log('8: 同步代码结束');
// 输出顺序:
// 1: 同步代码开始
// 8: 同步代码结束
// 4: Promise.then — 微任务
// 5: 第二个 Promise.then — 微任务
// 7: queueMicrotask — 微任务
// 2: setTimeout(0) 回调 — 宏任务
// 3: setTimeout(0) 第二个 — 宏任务
// 6: 微任务中创建的 setTimeout — 新宏任务
// 关键观察:
// 1. 所有微任务(Promise, queueMicrotask)在宏任务之前执行
// 2. setTimeout(0) 不保证"立即执行" — 至少延迟 4ms (HTML 规范)
// 3. 微任务中创建的新宏任务,需要等到下一轮事件循环才执行
核心知识点拆解
1. 宏任务 vs 微任务的本质区别
宏任务:由宿主环境(浏览器/Node.js)发起的任务,每个宏任务占据一次事件循环迭代。
微任务:由 JavaScript 代码产生的”后续工作”,在当前宏任务结束前全部处理完。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ⭐ 核心差异: 微任务队列是"一次性清空"的
// 宏任务: 每次取一个
// 微任务: 一次取所有,直到队列为空
// 这意味着:
// 如果微任务中持续添加新的微任务 → 无限循环 → 页面卡死
function microtaskLoop() {
Promise.resolve().then(() => {
console.log('永无止境的微任务...');
microtaskLoop(); // ⚠️ 会一直执行下去,没有宏任务有机会执行
});
// 页面永远无法渲染,新事件无法处理
}
// 如果宏任务中持续添加新的宏任务 → 浏览器仍然有机会响应
function macrotaskLoop() {
setTimeout(() => {
console.log('宏任务中创建宏任务');
macrotaskLoop(); // ✔️ 浏览器可以在间隙处理事件、渲染
}, 0);
// 每帧之间的宏任务间隙 → 浏览器可以渲染、处理用户事件
}
微任务的”饥饿”问题:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 如果一个微任务持续产生新的微任务
function starvationExample() {
function processItems(index) {
queueMicrotask(() => {
console.log(`处理第 ${index} 个`);
// 无限产生新微任务
processItems(index + 1);
});
}
processItems(0);
// → 浏览器永远无法渲染,用户无法交互!
// → 构成拒绝服务(DoS)攻击
}
2. requestAnimationFrame 的执行时机
requestAnimationFrame(rAF) 是事件循环中一个特殊的存在——它在微任务之后、渲染之前执行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
宏任务 #1
↓
微任务队列 (全部清空)
↓
rAF 回调 (如果有)
↓
样式重新计算
↓
布局
↓
绘制
↓
合成
↓
(下一帧)
这使 rAF 成为修改 DOM 的最佳时机——在 rAF 回调中修改,修改结果会直接纳入本轮渲染,不额外延迟一帧。
1
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
// rAF 时机验证
function demonstrateRAFTiming() {
let frameCount = 0;
function frame() {
if (frameCount >= 3) return;
frameCount++;
// 在 rAF 中设置样式 — 立即渲染到当前帧
document.body.style.backgroundColor =
['#e74c3c', '#2ecc71', '#3498db'][frameCount - 1];
// 在 rAF 中读布局属性 — 不会触发强制同步布局
// 因为当前在帧边界,布局已经是最新状态
const width = document.body.offsetWidth; // 安全读取
console.log(`帧 #${frameCount}: 体宽度 = ${width}`);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// ⚠️ 如果在 rAF 外修改样式
document.body.style.border = '3px solid black'; // 标记脏
const height = document.body.offsetHeight; // 强制同步布局!
}
// rAF 的节流效果
function rafThrottling() {
// 页面不可见(标签页切换)时 → rAF 停止
// 这在文档.hidden = true 时启用
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
console.log('页面隐藏 — rAF 将暂停');
} else {
console.log('页面可见 — rAF 恢复');
}
});
let lastTime = performance.now();
let frames = 0;
function measure() {
frames++;
const now = performance.now();
if (now - lastTime >= 1000) {
console.log(`帧率: ${frames} FPS`);
frames = 0;
lastTime = now;
}
requestAnimationFrame(measure);
}
requestAnimationFrame(measure);
}
3. requestIdleCallback 和合作式调度
requestIdleCallback(rIC) 是事件循环中的”低优先级任务”调度器——它在帧渲染完毕后、有空闲时间时才执行:
1
2
3
4
5
6
7
帧活动期 (16ms 内):
[宏任务] → [微任务] → [rAF] → [Style] → [Layout] → [Paint]
帧空闲期:
[rIC 回调] ← 只在有空闲时间时执行
[rIC 回调] ← 可能被下一帧中断
↓ (时间不够用 → 注册下一帧继续)
1
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
// rIC 的典型应用:非关键任务拆分
function runIdleTasks(tasks) {
let currentIndex = 0;
function processIdle(deadline) {
// deadline: IdleDeadline 对象
// deadline.timeRemaining(): 当前帧剩余时间 (ms)
// deadline.didTimeout: 是否因超时而执行
while (currentIndex < tasks.length && deadline.timeRemaining() > 5) {
const task = tasks[currentIndex];
task();
currentIndex++;
}
if (currentIndex < tasks.length) {
// 还有更多任务,下一帧继续
requestIdleCallback(processIdle, { timeout: 2000 });
}
}
requestIdleCallback(processIdle, { timeout: 2000 });
// timeout: 最多等 2 秒,之后即使无空闲也必须执行
}
// 实际应用:分析日志上报
class AnalyticsReporter {
constructor() {
this.queue = [];
this.batchSize = 50;
// 使用 rIC 批量上报
this.scheduleReport();
}
track(event) {
this.queue.push(event);
}
scheduleReport() {
requestIdleCallback((deadline) => {
const batch = this.queue.splice(0, this.batchSize);
while (batch.length > 0 && deadline.timeRemaining() > 2) {
const event = batch.shift();
this.sendToServer(event);
}
// 未被处理的放回队列
this.queue.unshift(...batch);
if (this.queue.length > 0) {
this.scheduleReport();
}
}, { timeout: 5000 });
}
}
4. 宏任务嵌套微任务的执行顺序陷阱
1
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
70
71
72
// 最经典的面试陷阱代码
async function asyncTrap() {
console.log('A'); // 同步
setTimeout(() => console.log('B'), 0); // 宏任务
await Promise.resolve(); // 微任务 (await 之后的代码被放入微任务)
console.log('C');
new Promise((resolve) => {
console.log('D'); // 同步(Promise 构造函数体内是同步的)
resolve();
}).then(() => {
console.log('E'); // 微任务
setTimeout(() => console.log('F'), 0); // 宏任务内的宏任务
});
console.log('G'); // 同步
}
asyncTrap();
console.log('H'); // 同步
// 期望输出: A, D, H, C, E, B, G, F?
// 等一下,上面写的 await 之后的 C 在什么时机执行?
// 让我们重新认真分析:
// 执行流:
// 1. 调用 asyncTrap() — 同步执行至第一个 await
// 2. 输出 A (同步)
// 3. setTimeout 入队宏任务
// 4. await Promise.resolve() — 遇到 await,async 函数暂停,
// 后续代码被安排为微任务
// 5. new Promise 构造 — 构造函数体是同步的 → 输出 D
// 6. resolve() 同步执行,.then 回调入队微任务
// 7. 同步输出 G (还在构造的同步代码中)
// 等等——上面的执行流程需要重新思考
// 更准确地分析:
function realExecution() {
console.log('A');
setTimeout(() => console.log('B'), 0);
// Promise.resolve() 的 .then 回调在 await 之后
// await 之后的代码等价于 .then 回调中的代码
Promise.resolve().then(() => {
console.log('C');
new Promise((resolve) => {
console.log('D'); // 同步的
resolve();
}).then(() => {
console.log('E');
setTimeout(() => console.log('F'), 0);
});
console.log('G');
});
console.log('H');
}
// 输出:
// A — 同步
// D — new Promise 构造函数体内同步执行
// H — 同步(在 asyncTrap 的同步部分中)
// C — await 之后的代码(微任务)
// G — 微任务中的同步部分
// E — Promise.then(嵌套微任务)
// B — setTimeout(宏任务)
// F — setTimeout(下一个宏任务)
实战案例
案例:高性能 Web 游戏的主循环调度
游戏中,每秒需要执行 60 次更新+渲染循环。如何用事件循环机制构建一个高效的游戏主循环?
1
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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
// game-loop.mjs — 高性能游戏事件循环
class GameLoop {
constructor(options = {}) {
this.targetFPS = options.targetFPS || 60;
this.frameInterval = 1000 / this.targetFPS; // ~16.67ms
this.lastFrameTime = 0;
this.accumulator = 0;
this.isRunning = false;
this.frameId = null;
// 性能指标
this.stats = {
fps: 0,
frameTimes: [],
maxFrameTime: 0,
updates: 0,
renders: 0,
droppedFrames: 0
};
// 固定的时间步长:物理引擎等需要确定性的系统
this.fixedTimeStep = 1000 / 60; // ~16.67ms
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.lastFrameTime = performance.now();
// 启动主循环时,使用 rAF 同步到帧边界
this.loop(this.lastFrameTime);
}
stop() {
this.isRunning = false;
if (this.frameId) {
cancelAnimationFrame(this.frameId);
this.frameId = null;
}
}
// 主循环:用 rAF 驱动
loop(currentTime) {
if (!this.isRunning) return;
this.frameId = requestAnimationFrame((time) => this.loop(time));
// 计算帧间隔
const elapsed = currentTime - this.lastFrameTime;
this.lastFrameTime = currentTime;
// 累加时间(防止 Fixed Timestep 的帧间隔不均匀)
this.accumulator += Math.min(elapsed, 100); // 上限 100ms 防止"死亡螺旋"
// FPS 统计
this.stats.frameTimes.push(elapsed);
if (this.stats.frameTimes.length > 60) {
const removed = this.stats.frameTimes.shift();
// 如果某帧耗时超过 33ms(30fps),记录为丢帧
if (removed > 33) this.stats.droppedFrames++;
}
// 检测是否掉帧
if (elapsed > this.frameInterval * 2) {
console.warn(`⚠️ 掉帧检测: ${elapsed.toFixed(1)}ms`);
}
// ===== 固定时间步长更新 =====
// 物理/逻辑更新使用固定时间步长,确保确定性
while (this.accumulator >= this.fixedTimeStep) {
this.fixedUpdate(this.fixedTimeStep);
this.accumulator -= this.fixedTimeStep;
this.stats.updates++;
}
// 插值:剩余的"部分帧"时间 → 渲染时插值
const interpolation = this.accumulator / this.fixedTimeStep;
// ===== 渲染(在 rAF 中 = 帧边界前) =====
// 此时修改 DOM/CSS → 立即纳入本轮渲染
this.render(interpolation);
this.stats.renders++;
// 计算实际 FPS
if (this.stats.renders % 60 === 0) {
const totalTime = this.stats.frameTimes.reduce((a, b) => a + b, 0);
this.stats.fps = Math.round(
(this.stats.frameTimes.length * 1000) / totalTime
);
}
// ===== 帧结束处理(可选) =====
// 此时 requestIdleCallback 会处理低优先级的任务
this.postFrame();
}
// 固定时间步长更新:物理、输入、AI、碰撞检测
fixedUpdate(dt) {
// 子类重写
// 这里应该是确定性的,帧率无关的逻辑
}
// 渲染:视觉更新(在 rAF 中执行)
render(interpolation) {
// 子类重写
// interpolation 0.0~1.0,用于渲染插值
}
// 帧后处理:低优先级任务
postFrame() {
// 子类重写
// 例如:清理缓存、统计上报等
}
}
// 使用示例
class MyGame extends GameLoop {
constructor(canvas) {
super({ targetFPS: 60 });
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.entities = [];
// 将低优先级的统计上报放到 rIC
this.setupIdleReporting();
}
setupIdleReporting() {
const reportStats = () => {
requestIdleCallback((deadline) => {
if (deadline.timeRemaining() > 10) {
console.log(`📊 FPS: ${this.stats.fps}, 丢帧: ${this.stats.droppedFrames}`);
}
reportStats();
});
};
reportStats();
}
fixedUpdate(dt) {
// 物理更新 — dt 是固定的 16.67ms
for (const entity of this.entities) {
entity.update(dt);
}
}
render(interpolation) {
// 用画布渲染
this.ctx.clearRect(0, 0, 800, 600);
for (const entity of this.entities) {
// 使用 interpolation 对位置进行插值渲染
const renderX = entity.x + entity.vx * interpolation;
const renderY = entity.y + entity.vy * interpolation;
this.ctx.fillStyle = entity.color;
this.ctx.fillRect(renderX, renderY, entity.size, entity.size);
}
}
start() {
// 在启动游戏循环前,用 setTimeout 做初始化
// 把它变成宏任务 → 不阻塞初始渲染
setTimeout(() => {
this.createEntities(100);
super.start();
}, 0);
}
}
// 性能监控 — 在微任务中检查帧时间
function monitorFrameTimes() {
let lastFrame = performance.now();
function checkFrame() {
const now = performance.now();
const delta = now - lastFrame;
lastFrame = now;
if (delta > 50) {
console.warn(`长帧: ${delta.toFixed(1)}ms — 页面可能卡顿`);
}
// 使用微任务检测,不影响渲染时机
Promise.resolve().then(checkFrame);
}
// 在 rAF 中检查
requestAnimationFrame(() => {
lastFrame = performance.now();
Promise.resolve().then(checkFrame);
});
}
案例:使用 MicroTask 处理大量 DOM 更新
1
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
// microtask-batch.mjs — 使用微任务批量处理 DOM 更新
class MicroTaskDOMBatch {
constructor(root) {
this.root = root;
this.pendingUpdates = new Map(); // element → updates
this.isScheduled = false;
}
// 安排对某个元素的一组更新
updateElement(element, property, value) {
if (!this.pendingUpdates.has(element)) {
this.pendingUpdates.set(element, {});
}
this.pendingUpdates.get(element)[property] = value;
this.schedule();
}
// 使用微任务调度(比下一次 rAF 更快)
schedule() {
if (this.isScheduled) return;
this.isScheduled = true;
// ⭐ 关键:使用 queueMicrotask
// 在当前宏任务结束前、渲染前完成所有 DOM 修改
// 这意味着:所有对同一元素的更新合并为一次 DOM 操作
queueMicrotask(() => this.flush());
}
flush() {
this.isScheduled = false;
for (const [element, updates] of this.pendingUpdates) {
// 将对该元素的所有更新合并为一次 style 操作
let styleString = '';
for (const [prop, value] of Object.entries(updates)) {
styleString += `${prop}: ${value}; `;
}
// 批量应用样式 → 1 次 DOM 操作
element.setAttribute('style', styleString);
}
this.pendingUpdates.clear();
}
}
// 使用场景:拖拽视觉效果
const batch = new MicroTaskDOMBatch(document.body);
element.addEventListener('pointermove', (e) => {
// 频繁触发的事件 → 通过微任务批量处理
batch.updateElement(tooltip, 'left', `${e.clientX + 10}px`);
batch.updateElement(tooltip, 'top', `${e.clientY + 10}px`);
batch.updateElement(tooltip, 'display', 'block');
// 所有上述修改在同一个微任务中一次性应用到 DOM
});
底层原理
HTML 规范中的事件循环
HTML 规范(HTML Living Standard)定义了事件循环的标准实现:
1
2
3
4
5
6
7
8
9
10
规范描述的事件循环处理模型:
1. 从宏任务队列中选出一个待运行的任务(oldest task)
2. 设置当前运行任务为该任务
3. 运行该任务(执行其回调)
4. 设置当前运行任务为 null,从队列中移除该任务
5. 执行微任务检查点(Microtask checkpoint)
a. 清空微任务队列(循环直到为空)
6. 如果需要进行渲染更新(rAF、样式计算、布局、绘制)
7. 回到第 1 步
关键的规范细节:
1
2
3
4
5
6
7
8
9
// microtask checkpoint 是"递归清除"的
// 这意味着: 微任务中添加微任务 → 会被立即执行
// Node.js 中的 process.nextTick 也有类似行为
// 但 process.nextTick 的优先级高于 Promise.then
// ⚠️ queueMicrotask 与 Promise 的关系:
// queueMicrotask(fn) ≈ Promise.resolve().then(fn)
// 但在 V8 的实现中有细微差别
V8 中的任务调度实现
V8 引擎提供了 JavaScript 执行环境,任务调度由嵌入器(浏览器/Node.js)实现。但在 V8 中,微任务的实现是引擎的一部分:
1
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
// 简化自 V8: src/execution/microtask-queue.cc
class MicrotaskQueue {
public:
// 将微任务入队
void EnqueueMicrotask(v8::Microtask microtask) {
microtasks_queue_.push_back(microtask);
is_performing_microtask_checkpoint_ = false;
}
// 执行微任务检查点
int PerformCheckpoint() {
if (is_performing_microtask_checkpoint_) {
// 防止递归调用
return 0;
}
is_performing_microtask_checkpoint_ = true;
int processed = 0;
while (!microtasks_queue_.empty()) {
auto microtask = microtasks_queue_.front();
microtasks_queue_.pop_front();
// 执行微任务
microtask->Call();
processed++;
// ⚠️ 注意: 微任务执行过程中可能
// 向队列添加新的微任务
// 这些新任务会在这个 while 循环中被处理
}
is_performing_microtask_checkpoint_ = false;
return processed;
}
private:
std::deque<v8::Microtask> microtasks_queue_;
bool is_performing_microtask_checkpoint_ = false;
int depth_ = 0;
};
// V8 中 Promise 的微任务创建
// src/builtins/builtins-promise-gen.cc
void PromiseResolveThenableJob::Dispatch() {
// 当一个 Promise 被 resolve,它的 .then 回调
// 被作为微任务排队
isolate_->EnqueueMicrotask(*this);
}
Chromium 渲染主线程的任务模型
Chromium 的渲染主线程使用 MainThreadScheduler 管理任务的优先级和执行:
1
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
// 简化自 Chromium: components/scheduler/child/webthread_impl_for_worker_scheduler.cc
class MainThreadScheduler {
public:
enum class TaskPriority {
kControl, // 最高优先级:内存压力、紧急 IPC
kInput, // 用户输入事件 (点击、触摸)
kHigh, // 高优先级:rAF、媒体
kNormal, // 正常:大多数宏任务
kLow, // 低优先级:预加载、非关键网络
kIdle, // 空闲:rIC
};
// 任务队列 — 每个优先级一个
std::vector<std::unique_ptr<TaskQueue>> task_queues_;
Task* SelectNextTask() {
// 从高到低遍历优先级
// 关键:输入事件优先级高于 rAF
for (int p = kControl; p <= kIdle; ++p) {
auto* queue = task_queues_[p].get();
if (!queue->IsEmpty()) {
return queue->TakeNext();
}
}
return nullptr;
}
// 渲染步骤
void PerformRendering() {
// 1. 执行 requestAnimationFrame 回调
for (auto& frame_callback : rAF_callbacks_) {
frame_callback->Call();
}
rAF_callbacks_.clear();
// 2. 执行样式重新计算
document_->RecalcStyle();
// 3. 执行布局
if (document_->NeedsLayout()) {
document_->PerformLayout();
}
// 4. 执行绘制
if (NeedsPaint()) {
PerformPaint();
}
// 5. 准备合成帧
CompositorFrame();
}
};
setTimeout(0) 真正的延迟
HTML 规范规定:嵌套的 setTimeout 调用深度超过 5 层时,最小延迟为 4ms:
1
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
// setTimeout 的最小延迟
function testSetTimeoutDelay() {
let callCount = 0;
let lastTime = performance.now();
function callback() {
callCount++;
const now = performance.now();
const delay = now - lastTime;
lastTime = now;
console.log(`第 ${callCount} 次调用: 实际延迟 ${delay.toFixed(2)}ms`);
if (callCount < 10) {
setTimeout(callback, 0);
}
}
setTimeout(callback, 0);
}
// 实际输出:
// 第 1 次调用: 实际延迟 0.12ms ← 0ms (第一次)
// 第 2 次调用: 实际延迟 3.85ms ← 接近 4ms
// 第 3 次调用: 实际延迟 4.01ms ← 明确 4ms
// 第 4 次调用: 实际延迟 3.98ms
// 第 5 次调用: 实际延迟 4.12ms
// ...
// 这个 4ms 限制是规范要求:
// "If nesting level is greater than 5, and timeout is less than 4,
// set timeout to 4."
高频面试题解析
面试题 1:给定以下代码,说出输出顺序并解释原因:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
console.log('start');
setTimeout(() => {
console.log('timeout1');
Promise.resolve().then(() => console.log('promise1'));
}, 0);
Promise.resolve().then(() => {
console.log('promise2');
setTimeout(() => console.log('timeout2'), 0);
});
Promise.resolve().then(() => console.log('promise3'));
console.log('end');
答案要点:
1
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
执行流程分析:
1. 同步代码
"start" → 输出 start
setTimeout 入队宏任务队列 → timeout1
Promise.then(promise2) 入队微任务队列
Promise.then(promise3) 入队微任务队列
"end" → 输出 end
当前队列:
宏任务: [timeout1]
微任务: [promise2, promise3]
2. 微任务阶段 (清空全部)
promise2 → 输出 promise2, setTimeout 入队宏任务队列 → timeout2
promise3 → 输出 promise3
当前队列:
宏任务: [timeout1, timeout2]
3. 宏任务阶段 (取一个)
timeout1 → 输出 timeout1, Promise.then(promise1) 入队微任务队列
4. 微任务阶段 (清空全部)
promise1 → 输出 promise1
当前队列:
宏任务: [timeout2]
5. 宏任务阶段 (取一个)
timeout2 → 输出 timeout2
输出: start, end, promise2, promise3, timeout1, promise1, timeout2
关键洞察:
- Promise 微任务在宏任务之前处理
- 微任务队列在每次宏任务之后”完全清空”
- 微任务中新添加的微任务会被立即执行
- 但微任务中添加的宏任务要等到下一轮
面试题 2:requestAnimationFrame 和 requestIdleCallback 的调度时机分别是什么?它们有什么协作关系?
答案要点:
执行时机:
- rAF:在下一帧开始前、样式重新计算之前执行。浏览器会确保 rAF 回调在每一帧只执行一次,且与 VSync 信号同步。
- rIC:在帧空闲期执行(所有 rAF、样式、布局、绘制、合成之后)。也可能因为一直没有空闲时间而延迟执行。
协作关系:
1
2
3
4
5
6
7
8
9
// 典型的一帧处理流程
// 帧开始 (VSync)
// → 处理用户输入事件
// → 执行 rAF 回调
// → 样式重新计算 + 布局 + 绘制 + 合成
// → 帧空闲期
// → 执行 rIC 回调
// → 如果没有 rIC → 浏览器进入空闲状态
// → 等待下一个 VSync
为什么 rAF 优先于 rIC 执行: 因为 rAF 负责视觉更新——必须先于渲染完成。rIC 负责非关键任务——可以在渲染后、空闲时执行。
典型的使用模式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// rAF: DOM 动画、渲染更新
function animationLoop() {
requestAnimationFrame(() => {
// 更新动画状态
updateAnimation();
// 不在这里做日志/上报
});
}
// rIC: 非关键的后台工作
function backgroundWork() {
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 5) {
processOneNonCriticalItem();
}
if (moreItems) {
requestIdleCallback(backgroundWork);
}
});
}
面试题 3:setTimeout(fn, 0) 真的会在 0ms 后执行吗?至少会延迟多久?
答案要点:
不,setTimeout(fn, 0) 不是在 0ms 后执行。实际上:
- 浏览器的最小延迟:HTML 规范要求最小延迟为 4ms(嵌套深度 > 5 时)
- 事件循环的影响:即使延迟到了,仍然需要等到当前宏任务执行完毕、微任务队列清空后才能执行
- 页面状态影响:非活跃标签页的 setTimeout 最小延迟提升到 1000ms
实际延迟的可视化:
1
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
function measureSetTimeoutDelay() {
const start = performance.now();
setTimeout(() => {
const actualDelay = performance.now() - start;
console.log(`setTimeout(0) 实际延迟: ${actualDelay.toFixed(3)}ms`);
// 输出通常: 0.5-4ms (取决于浏览器和嵌套深度)
}, 0);
// 同步阻塞一点点
for (let i = 0; i < 1000; i++) {}
}
// 非活跃标签页的行为
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
console.log('页面隐藏: setTimeout 最小延迟提升到 1000ms');
}
});
// 实际测量同一个页面的延迟差异
function measureIdleDelay() {
const activeDelays = [];
const idleDelays = [];
function measure() {
const start = performance.now();
setTimeout(() => {
const delay = performance.now() - start;
if (document.hidden) {
idleDelays.push(delay);
} else {
activeDelays.push(delay);
}
}, 0);
}
// 连续测 10 次
for (let i = 0; i < 10; i++) setTimeout(measure, 100 * i);
setTimeout(() => {
console.log('活跃时延迟:', activeDelays.map(d => d.toFixed(1)).join(', '));
console.log('休眠时延迟:', idleDelays.map(d => d.toFixed(1)).join(', '));
}, 2000);
}
总结与扩展
事件循环是 JavaScript 异步编程的底层调度模型,理解它等于理解了”代码实际在什么时候执行”。
核心记忆点:
1
2
3
4
5
6
同步 > 微任务 > 宏任务(每个) > 渲染
微任务: 当前宏任务结束后立即全部执行
宏任务: 每次事件循环迭代只执行一个
请求动画帧: 在微任务后、渲染前执行
空闲回调: 渲染后、有空闲才执行
现实中常见的陷阱:
setTimeout与Promise:微任务总会提前于下一个宏任务- rAF 中修改 DOM:不会被延迟到下一帧
- 微任务无限循环:阻塞页面渲染和用户交互
- 长任务(>50ms):导致丢帧和输入延迟
进一步理解的方向:
- Node.js 的事件循环 vs 浏览器事件循环(Node 有 phases:timers, poll, check, close)
- Web Workers 有独立的事件循环,不与主线程共享
- Service Worker 的事件循环有独立的生命周期
await的底层实现:Promise.resolve().then(后续代码)的语法糖
事件循环不是一个”一次性理解”的概念——需要在实践中反复印证:多跑几个测试,多查看 Chrome DevTools Performance 面板,多看一些节流和去抖的实现。当你能在不运行代码的情况下准确预测输出顺序时,就真正掌握了它。