# 事件循环与渲染时序详解
彻底搞懂宏任务、微任务、requestAnimationFrame 的精确执行时序
前置知识:2_1_v8Learn(V8 事件循环)+ 第01章(浏览器渲染管线)
阅读指南(初学者必看)
为什么你需要学习事件循环与渲染时序?
你已经在 V8 项目里学了事件循环的基础概念,但你可能还有这些困惑:
setTimeout(fn, 0)和Promise.then(fn)谁先执行?requestAnimationFrame到底是在渲染前还是渲染后执行?- 游戏主循环为什么推荐用
requestAnimationFrame而不是setInterval? - 代码里写了
await,后面的代码什么时候执行?
学完本章,你能回答:
- 宏任务、微任务、rAF、渲染的精确执行顺序
- 为什么游戏主循环要用固定时间步
- 长任务怎么导致掉帧,怎么拆分
本文结构
第一部分:事件循环机制(宏任务/微任务/rAF的精确时序)
第二部分:requestAnimationFrame 与游戏主循环(为什么用rAF)
第三部分:长任务与掉帧(为什么卡顿,怎么修复)
第四部分:Promise/async-await 的事件循环时序(常见面试题)
一、事件循环机制
生活类比:事件循环就像餐厅的出餐流程。
客人点单(宏任务) → 厨房做菜
→ 菜做好了,通知服务员(微任务)
→ 服务员把所有菜都端上桌(微任务清空)
→ 摆盘、拍照(rAF回调)
→ 上菜(渲染)
精确时序图
┌──────────── 宏任务 ────────────┐
│ 执行同步代码 │
│ 遇到 setTimeout → 放入宏任务队列 │
│ 遇到 Promise.then → 放入微任务队列│
└────────────┬───────────────────┘
│
▼
┌──────────── 微任务 ────────────┐
│ 清空所有微任务 │
│ (包括执行过程中新产生的微任务) │
└────────────┬───────────────────┘
│
▼
┌──────────── 渲染前 ────────────┐
│ requestAnimationFrame 回调 │
└────────────┬───────────────────┘
│
▼
┌──────────── 渲染 ──────────────┐
│ Style → Layout → Paint → Composite │
└─────────────────────────────────┘
关键规则
- 微任务在当前宏任务结束后、渲染前全部清空
- rAF 在微任务之后、渲染之前执行
- 渲染不一定要发生(浏览器可能跳过,比如1秒内没有 DOM 变化)
- 长任务(>50ms)会阻塞渲染,导致掉帧
代码验证时序
console.log('1: 同步代码');
setTimeout(() => console.log('2: setTimeout(宏任务)'), 0);
Promise.resolve().then(() => {
console.log('3: Promise.then(微任务)');
return Promise.resolve('4: 微任务中产生的微任务');
}).then(console.log);
requestAnimationFrame(() => console.log('5: rAF(渲染前)'));
console.log('6: 同步代码');
// 输出顺序:
// 1: 同步代码
// 6: 同步代码
// 3: Promise.then(微任务)
// 4: 微任务中产生的微任务
// 5: rAF(渲染前)
// 2: setTimeout(宏任务) ← 在下一轮宏任务中执行!
// 为什么 5 比 2 先?
// 因为 rAF 在当前宏任务的微任务之后执行,
// 而 setTimeout 是下一轮宏任务!
宏任务 vs 微任务完整列表
| 宏任务(Macro Task) | 微任务(Micro Task) |
|---|---|
setTimeout / setInterval |
Promise.then/catch/finally |
MessageChannel |
MutationObserver |
| I/O 操作(网络、文件) | queueMicrotask() |
| UI 交互事件(click、scroll) | AsyncFunction(await 后面的代码) |
requestAnimationFrame ⚠️ |
|
requestIdleCallback |
⚠️ 注意:
requestAnimationFrame在 HTML 规范中不算宏任务,它有自己独立的队列,在微任务之后、渲染之前执行。
二、requestAnimationFrame 与游戏主循环
为什么游戏要用 rAF 而不是 setInterval?
| 特性 | requestAnimationFrame | setInterval(fn, 16) |
|---|---|---|
| 与渲染同步 | ✅ 在渲染前执行 | ❌ 可能在渲染中执行 |
| 后台标签页 | 自动暂停 | 继续执行(浪费CPU) |
| 帧率适配 | 自动匹配显示器刷新率 | 固定间隔 |
| 精确时序 | 参数是高精度时间戳 | 可能累积误差 |
| 浏览器优化 | 可与 CSS 动画合批 | 独立调度 |
为什么 setInterval 不适合游戏?
// setInterval 的问题:
// 1. 不精确:浏览器可能因为其他任务延迟执行
setInterval(() => update(), 16); // 期望60fps,实际可能30fps
// 2. 后台标签页继续执行
// 用户切到其他标签页,游戏还在跑,浪费CPU和电量
// 3. 累积误差
// 如果一次 update 耗时 20ms,下一次会立即执行
// 不会跳过,而是追赶,导致画面"加速"
正确的游戏主循环
// ❌ 新手常见写法:直接用 deltaTime,帧率不同时逻辑不一致
let lastTime = 0;
function gameLoop(timestamp) {
const dt = timestamp - lastTime;
lastTime = timestamp;
update(dt); // dt 可能是 16ms、33ms、100ms……
render();
requestAnimationFrame(gameLoop);
}
// 问题:
// 60fps时 dt=16ms,角色移动 5px
// 30fps时 dt=33ms,角色移动 10px
// 看起来一样?但碰撞检测的结果可能不同!
// 60fps 时角色可能刚好跳过障碍物
// 30fps 时角色移动距离更大,可能穿过障碍物(隧道效应)
// ✅ 正确写法:固定时间步(Fixed Timestep)
let lastTime = 0;
const FIXED_DT = 1000 / 60; // 固定 16.67ms
let accumulator = 0;
function gameLoop(timestamp) {
if (lastTime === 0) lastTime = timestamp;
const deltaTime = timestamp - lastTime;
lastTime = timestamp;
// 固定时间步:逻辑更新与帧率解耦
accumulator += deltaTime;
while (accumulator >= FIXED_DT) {
update(FIXED_DT); // 用固定 dt 更新逻辑
accumulator -= FIXED_DT;
}
// 插值因子:用于平滑渲染
const alpha = accumulator / FIXED_DT;
render(alpha); // 用插值因子做平滑渲染
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
固定时间步为什么重要?
场景:角色以 300px/s 的速度移动,墙壁厚 10px
60fps:dt = 16.67ms,每步移动 5px
→ 需要 2 帧穿过墙壁,碰撞检测能捕获
30fps:dt = 33.33ms,每步移动 10px
→ 1 步就穿过墙壁,碰撞检测可能错过!
15fps:dt = 66.67ms,每步移动 20px
→ 直接穿过 10px 厚的墙壁!隧道效应!
固定时间步:
无论实际帧率如何,update 始终用 FIXED_DT
→ 碰撞检测的行为一致
→ 不会出现隧道效应
插值渲染
// 固定时间步有一个小问题:
// 渲染频率可能高于逻辑更新频率(比如144Hz显示器)
// 这时画面会"抖动"
// 解决方案:插值渲染
function render(alpha) {
// alpha = 0~1,表示当前帧在两个逻辑帧之间的位置
// alpha = 0:刚完成一个逻辑帧
// alpha = 0.5:两个逻辑帧中间
// alpha = 1:即将执行下一个逻辑帧
// 线性插值位置
const renderX = prevState.x + (currState.x - prevState.x) * alpha;
const renderY = prevState.y + (currState.y - prevState.y) * alpha;
// 用插值后的位置渲染
drawSprite(renderX, renderY);
}
三、长任务与掉帧
什么是掉帧?
理想情况(60fps):每帧 16.67ms
帧1: |----update----|----render----| 16ms ✅
帧2: |----update----|----render----| 16ms ✅
帧3: |----update----|----render----| 16ms ✅
掉帧情况:某个 update 耗时太长
帧1: |----update----|----render----| 16ms ✅
帧2: |----------长任务----------|-------| 50ms ❌ 跳过3帧!
帧3: |----update----|----render----| 16ms ✅
用户体验:画面"卡了一下"
哪些操作会导致长任务?
| 操作 | 耗时 | 解决方案 |
|---|---|---|
| 大量 DOM 操作 | 10~100ms | 批量操作 / DocumentFragment |
| 复杂计算(排序、搜索) | 10~500ms | Web Worker / 分帧 |
| 大 JSON 解析 | 10~100ms | 流式解析 / 分块 |
| 同步网络请求 | 100~1000ms | 改为异步 |
| GC 暂停 | 5~50ms | 减少对象创建(对象池) |
分帧执行:把长任务拆成多个小任务
// ❌ 一次性处理 10000 个元素 → 长任务
function processAll(items) {
for (const item of items) {
processItem(item); // 假设每个耗时 0.01ms
}
// 总耗时:10000 × 0.01ms = 100ms → 掉帧!
}
// ✅ 分帧执行:每帧处理一部分
function processInChunks(items, chunkSize = 200) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, items.length);
for (let i = index; i < end; i++) {
processItem(items[i]);
}
index = end;
if (index < items.length) {
// 还有未处理的,下一帧继续
requestAnimationFrame(processChunk);
}
}
requestAnimationFrame(processChunk);
}
用 Web Worker 处理 CPU 密集型任务
// main.js
const worker = new Worker('compute-worker.js');
worker.onmessage = (e) => {
const result = e.data;
renderResult(result); // 在主线程渲染结果
};
// 发送计算任务到 Worker
worker.postMessage({ type: 'pathFind', map: gameMap, from, to });
// compute-worker.js
self.onmessage = (e) => {
if (e.data.type === 'pathFind') {
const path = aStar(e.data.map, e.data.from, e.data.to);
self.postMessage(path); // 结果发回主线程
}
};
Web Worker 的限制:
- 不能访问 DOM(不能操作 Canvas)
- 数据通过
postMessage传递,有序列化开销 SharedArrayBuffer可以共享内存,但需要安全头
四、Promise/async-await 的事件循环时序
await 到底做了什么?
async function foo() {
console.log('1: foo 开始');
await bar();
console.log('3: await 后面的代码');
}
function bar() {
console.log('2: bar 执行');
return Promise.resolve('result');
}
foo();
console.log('4: 同步代码');
// 输出顺序:
// 1: foo 开始
// 2: bar 执行
// 4: 同步代码
// 3: await 后面的代码
解释:
foo()开始执行,输出 "1"- 执行
bar(),输出 "2",返回Promise.resolve('result') await遇到 Promise,把后面的代码注册为微任务- 继续执行
foo()外面的同步代码,输出 "4" - 同步代码执行完,清空微任务,执行
await后面的代码,输出 "3"
常见陷阱
// 陷阱1:多个 await 的执行顺序
async function test() {
// 这两个 Promise 几乎同时开始
// 但 await 是串行等待结果
const a = await fetch('/api/a'); // 等 200ms
const b = await fetch('/api/b'); // 再等 200ms
// 总耗时:400ms
}
// 修复:并行请求
async function testFixed() {
const [a, b] = await Promise.all([
fetch('/api/a'), // 同时开始
fetch('/api/b'), // 同时开始
]);
// 总耗时:200ms
}
// 陷阱2:forEach 中的 async
async function processItems(items) {
// ❌ forEach 不会等待 async 函数完成!
items.forEach(async (item) => {
await processItem(item);
});
console.log('完成'); // 这里会在所有 processItem 完成之前执行!
// ✅ 用 for...of 串行处理
for (const item of items) {
await processItem(item);
}
console.log('完成'); // 这里才是真正完成
}
setTimeout(fn, 0) vs Promise.then vs requestAnimationFrame 执行顺序
setTimeout(() => console.log('A: setTimeout'), 0);
Promise.resolve().then(() => console.log('B: Promise'));
requestAnimationFrame(() => console.log('C: rAF'));
// 输出顺序:
// B: Promise ← 微任务最先
// C: rAF ← 渲染前
// A: setTimeout ← 下一轮宏任务
// 所以:微任务 > rAF > 宏任务
自问自答
Q:requestAnimationFrame 是宏任务还是微任务? A:都不是。rAF 有自己独立的队列,在微任务清空后、渲染之前执行。它既不是宏任务也不是微任务。
Q:为什么 setTimeout(fn, 0) 不是0ms执行?
A:HTML5 规范规定 setTimeout 的最小延迟是 4ms(嵌套层级 >= 5 时)。而且即使延迟到了,也要等当前宏任务和所有微任务执行完才轮到它。
Q:游戏里 setInterval 做 UI 更新有什么问题?
A:三个问题:1) 不与渲染同步,可能在渲染中间执行,导致画面撕裂;2) 后台标签页继续执行,浪费资源;3) 如果回调耗时超过间隔,会累积执行,导致卡顿。
Q:await 后面的代码什么时候执行?
A:作为微任务执行。如果 await 的是一个 already-resolved 的 Promise,后面的代码会在当前宏任务的微任务阶段执行。如果 await 的是一个 pending 的 Promise,会在 Promise resolve 后的下一个微任务阶段执行。
实践任务
- 任务1:编写事件循环时序测试,验证宏任务/微任务/rAF 的执行顺序
- 任务2:对比 rAF 和 setInterval 作为游戏循环的帧率稳定性(用 Performance 面板录制对比)
- 任务3:实现固定时间步游戏循环,测试在不同帧率下(用 Chrome DevTools CPU throttling 模拟)的逻辑一致性
- 任务4:制造一个长任务(遍历 10000 个 DOM 元素),用分帧执行改造,对比帧率变化
- 任务5:用 Web Worker 把寻路算法移到后台线程,主线程帧率变化
与其他章节的关联
| 本章内容 | 关联章节 | 关联点 |
|---|---|---|
| 事件循环 | 2_1_v8Learn | V8 项目讲了事件循环基础,本章补充渲染时序 |
| rAF 游戏主循环 | 第01章 | rAF 在渲染管线中的位置 |
| 长任务掉帧 | 第09章 | 对象池减少 GC 停顿 |
| 微任务 | 第07章 | 内存泄漏中的闭包和事件监听器 |
上一章:03-重排与重绘优化 下一章:05-浏览器全链路内存全景图