# 事件循环与渲染时序详解

彻底搞懂宏任务、微任务、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 │
└─────────────────────────────────┘

关键规则

  1. 微任务在当前宏任务结束后、渲染前全部清空
  2. rAF 在微任务之后、渲染之前执行
  3. 渲染不一定要发生(浏览器可能跳过,比如1秒内没有 DOM 变化)
  4. 长任务(>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 后面的代码

解释

  1. foo() 开始执行,输出 "1"
  2. 执行 bar(),输出 "2",返回 Promise.resolve('result')
  3. await 遇到 Promise,把后面的代码注册为微任务
  4. 继续执行 foo() 外面的同步代码,输出 "4"
  5. 同步代码执行完,清空微任务,执行 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-浏览器全链路内存全景图