【学习笔记】事件循环处理模型

5 阅读14分钟

基于 WHATWG HTML 规范简化,覆盖任务调度、微任务检查点、渲染机会、空闲期四个阶段。

完整事件循环处理模型(规范简化版)

while (true) {
  // 第一步: 取任务
  // 从多个任务队列中选择最高优先级队列的最老任务
  task = selectTask(taskQueues);
  if (task) {
    execute(task);
  } else {
    // 所有队列为空 -> 休眠等待(不是busy-loop)
    // 底层类似 epoll_wait / MsgWaitForMultipleObjects
    // 直到新任务到来(定时器到期、 I/O完成、用户交互)才唤醒
    waitForTask();
    continue; // 回到循环顶部重新 selectTask,而非空跑后续步骤
  }

  // 第二步:微任务检查点
  // 清空微任务队列(包括执行中产生新微任务)
  while (micortaskQueue.length > 0) {
    microtask = micortaskQueue.dequeue();
    execute(microtask);
  }

  // 第三步:渲染(如何有渲染机会)
  // 判断条件:vsync到达 + 有视觉变化 + 页面可见
  if (hasRenderingOpportunity()) {
    fireResizeAndScrollEvents();    // 触发 resize/scroll 事件
    evaluateMediaQueries();         // 评估媒体查询变化
    runAnimationFrameCallbacks();   // 执行 rAF 回调
    drainMicrotasks();              // rAF 后的微任务检查点
    renderPipeline();               // Style → Layout → Paint → Composite
  }

  // 第四步:空闲期
  // 条件:所有任务队列为空 或 当千帧剩余时间
  if (hasIdleTime()) {
    deadline = computeIdleDeadline(); // 最多 50 ms
    runIdleCallbacks(deadline);
  }
}

任务与微任务的区分

定义

事件循环中的异步回调分为两类,由规范决定,不是开发者选择的:

TaskWHATWG 规范术语,社区俗称"宏任务")
  → 由宿主环境(浏览器/Node.js)发起的异步操作回调
  → 每轮事件循环只取一个执行

Microtask(微任务)
  → 由 JavaScript 引擎(V8)发起的异步操作回调
  → 每轮事件循环必须全部清空

规范中只有 task 和 microtask,从未出现过 "macrotask"。"宏任务"是社区为了和 "microtask" 对称而产生的俗称。

来源分类

Task(宿主环境调度):浏览器/Node.js 负责把回调,放入 task queue

来源环境说明
setTimeout / setInterval浏览器 + Node嵌套 >5 层后最小延迟 4ms
setImmediateNode.js在 check 阶段执行
I/O 回调Node.js文件系统、网络操作
UI 渲染/事件浏览器click、scroll、keypress 等
MessageChannel浏览器 + Workerport.postMessage()
requestIdleCallback浏览器空闲期最低优先级
<script> 初始执行浏览器脚本标签解析执行

Microtask(JS引擎调度):V8 引擎负责把回调,放入 microtask queue

来源环境说明
Promise.then/catch/finally所有通过 HostEnqueuePromiseJob
queueMicrotask()浏览器 + Node显式微任务调度
MutationObserver浏览器DOM 变更观察回调
process.nextTick()Node.js在微任务之前执行(独立队列)
await 后续代码所有通过隐式 Promise.then

核心行为差异

维度TaskMicrotask
每轮取多少个一个全部清空
新产生的排到队尾,下轮再取本轮继续清空
与渲染的关系渲染在 task 间进行微任务全部清空后才渲染
队列数量多个(按任务源分类)只有一个
无限递归不会阻塞渲染(每轮只取一个)会阻塞渲染(必须清空)

谁调度的?

Task(宏任务):由浏览器 / Node.js 安排

示例触发时机
setTimeout浏览器的定时器线程到期后放入 task queue
click 事件浏览器检测到用户点击后放入 task queue
fetch 回调浏览器网络线程收到响应后放入 task queue

Microtask(微任务):由 V8 引擎安排

示例触发时机
Promise.thenV8 在 Promise resolve 时调用 HostEnqueuePromiseJob
await 后续代码V8 将 async 函数恢复操作入队微任务
queueMicrotask直接调用 V8 的微任务入队接口

特殊情况:process.nextTick(Node.js)

process.nextTick 是微任务,但有自己的独立队列,优先级比 Promise 微任务更高

每个阶段之间的清空顺序(Node 11+):

  1. 清空 process.nextTick 队列
  2. 清空 Promise 微任务队列
  3. 进入下一个阶段
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));

// Node.js 输出:nextTick → promise

深入 selectTask:任务选取机制

不是一个队列,是多个队列

WHATWG 规范定义了任务源(task source) 的概念。不同来源的任务进入不同的队列,浏览器可以按优先级决定先处理哪个队列:

┌─────────────────────────────────────────────────────────┐
│                  Task Scheduler(任务调度器)              │
│                                                         │
│  ┌────────────────┐   ┌──────────────┐ ┌──────────────┐ │
│  │ 用户交互队列     │   │ 定时器队列     │ │ 网络回调队列   │ │
│  │ click/key/touch│   │ setTimeout   │ │ fetch 回调    │ │
│  │ 【最高优先级】   │   │ setInterval  │ │ XMLHttpRequest│ │
│  └──────┬─────────┘   └──────┬───────┘ └──────┬───────┘  │
│         │                    │                │          │
│         └────────┬───────────┴────────┬───────┘         │
│                  ↓                                      │
│          选择最高优先级队列中最老的那个任务                   │
│          每轮只取【一个】                                  │
└─────────────────────────────────────────────────────────┘

Chromium 的优先级策略

优先级任务源说明
最高(User-blocking)click、keydown、touch直接影响用户交互体验
高(High)合成器通知、IntersectionObserver内部操作
普通(Normal)setTimeout、fetch 回调、MessageChannel日常异步操作
低(Low)后台清理任务非紧急
最低(Best-effort)requestIdleCallback空闲才执行

"浏览器可以从任意任务队列中选择一个任务",优先级的实现细节,不同浏览器策略不完全相同,但都遵循"用户交互优先"原则。

每轮只取一个 task

这是和微任务"必须清空"的最大区别。task 每轮只取一个,执行完就进入微任务检查点。这保证了:

  • 微任务有机会在两个 task 之间插入
  • 渲染有机会在两个 task 之间进行
  • 不会因为 task 队列太长而饿死渲染

队列为空时:休眠等待

事件循环不会 busy-loop 空转。当所有任务队列为空时,主线程进入休眠,由操作系统的 I/O 多路复用机制等待唤醒:

所有队列为空时:
  浏览器(Chromium)→ 底层 message pump 阻塞等待
                     (Windows: MsgWaitForMultipleObjects, Linux: epoll_wait)
  Node.js           → libuv 的 uv_run 在 poll 阶段阻塞等待 I/O

唤醒条件:
  - 定时器到期(setTimeout/setInterval 的时间到了)
  - I/O 完成(网络响应到达、文件读取完成)
  - 用户交互(点击、键盘、滚动)
  - 其他线程 postMessage(Web Worker、合成器线程通知)

这是事件循环高效的关键——空闲时不消耗 CPU,有事件时立即响应。

深入 drainMicrotasks:微任务检查点

为什么是 while 循环

while (microtaskQueue.length > 0) {
  microtask = microtaskQueue.dequeue();
  execute(microtask);
  // 如果 microtask 执行中产生了新的微任务,
  // 新微任务被 push 到同一个队列,
  // 下一轮 while 继续取出执行
}

这意味着:微任务可以无限递归产生新微任务,while 循环就永远不会结束,后续 task 和渲染永远等不到。这就是微任务饥饿问题。

// ❌ 微任务饥饿 → 页面冻结
function loop() {
  Promise.resolve().then(loop); // 永远清不完
}
loop();

// ✅ 改用 setTimeout → 每次回到事件循环,渲染有机会执行
function loop() {
  setTimeout(loop, 0);
}

微任务检查点不只在 task 后触发

规范定义了 perform a microtask checkpoint,在以下时机都会触发:

  1. 每个 task 执行完毕后
  2. 调用栈从 C++ 回到 JavaScript 时。如 DOM API 回调
  3. rAF 回调执行完毕后
  4. Observer 回调创建/移除前后

经典陷阱:真实点击 vs element.click()

div.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('micro1'));
  console.log('listener1');
});

div.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('micro2'));
  console.log('listener2');
});

用户真实点击

浏览器创建一个 click 事件 task,开始派发事件,调用栈变化(注意区分 C++ 帧和 JS 帧,微任务检查点只看 JS 帧是否为空):

  [] → [dispatchEvent(C++)] → [dispatchEvent(C++), listener1(JS)]
  listener1 执行完,弹出 → 回到 [dispatchEvent(C++)]
  → JS 执行上下文栈为空,触发微任务检查点 → 执行 micro1
  → dispatchEvent 继续派发 → [dispatchEvent(C++), listener2(JS)]
  listener2 执行完,弹出 → 回到 [dispatchEvent(C++)]
  → JS 执行上下文栈为空,再次触发微任务检查点 → 执行 micro2

输出:listener1 → micro1 → listener2 → micro2

div.click() 脚本派发

调用栈变化(click() 是 JS 调用,JS 帧始终不为空):

  [script] → [script, click()] → [script, click(), listener1]
  listener1 执行完,弹出 → 回到 [script, click()]
  → JS 帧还有 click() 和 script,不触发微任务检查点
  → [script, click(), listener2]
  listener2 执行完,弹出 → [script, click()] → [script] → []
  → JS 执行上下文栈终于清空,触发微任务检查点 → 执行 micro1, micro2

输出:listener1 → listener2 → micro1 → micro2

本质区别:不是"每个 listener 是独立 task",而是调用栈深度不同。真实点击时 listener 之间 JS 调用栈清空(回到 C++ 的 dispatchEvent),触发微任务检查点;click() 时 JS 栈始终有 click() 方法压着,直到所有 listener 都执行完、click() 返回后才清空。

深入 hasRenderingOpportunity:渲染机会判断

判断流程(四层)

hasRenderingOpportunity():

  ┌──────────────────────────┐
  │ 1. vsync 信号是否到达?     │ ← 硬件层
  │    Noreturn false     │
  └────────────┬─────────────┘
               │ Yes
  ┌────────────▼─────────────┐
  │ 2. 页面是否可见?          │ ← 可见性层
  │    hidden → return false │
  └────────────┬─────────────┘
               │ visible
  ┌────────────▼─────────────┐
  │ 3. 是否有视觉变化?         │ ← 脏检查层
  │    Noreturn false     │
  └────────────┬─────────────┘
               │ Yes
  ┌────────────▼─────────────┐
  │ 4. 是否被节流?            │ ← 节流层
  │    跨域iframe/被遮挡       │
  │    → 可能降频              │
  └────────────┬─────────────┘
               │
      return true → 执行渲染

第一层:vsync 信号(硬件驱动)

浏览器不用定时器轮询,而是由显示器硬件发出 vsync(垂直同步)信号:

60Hz 屏幕:每 16.67ms 一次 vsync
120Hz 屏幕:每 8.33ms 一次 vsync

vsync ─────┬────────────────┬────────────────┬─────
           │     一帧       │     一帧        │
           │   16.67ms     │   16.67ms      │

在 Chromium 中,合成器线程接收 GPU 的 vsync 信号,通过 BeginFrame 消息通知主线程"现在可以准备下一帧了"。主线程收到 BeginFrame 后才会执行 rAF 回调和渲染流水线。

第二层:页面可见性

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    // 浏览器会:
    // - 暂停渲染(不再触发 rAF)
    // - setTimeout/setInterval 节流到 ~1000ms
    // - 节省 CPU/GPU 资源
  }
});

第三层:脏检查(有无视觉变化)

即使 vsync 到了、页面可见,如果没有任何视觉变化,浏览器也会跳过渲染。

需要渲染(dirty):

  1. DOM 被修改(增删改节点)
  2. CSSOM 被修改(style/class/attribute 变化)
  3. 有 pending 的 rAF 回调
  4. CSS animation / transition 正在进行
  5. 滚动位置变化
  6. resize 发生

不需要渲染(clean):

  1. 页面静止,没有任何变化
  2. 只执行了纯计算 JS,没有触及 DOM/CSSOM

第四层:节流策略

场景策略
后台标签页渲染暂停,rAF 不触发,setTimeout 节流到 ~1000ms
跨域 iframe可能降频渲染
被完全遮挡的区域部分浏览器跳过该区域的 Paint
省电模式可能降低帧率

验证:不是每个 task 都触发渲染

// 实验:连续调度大量 setTimeout,观察渲染频率
let taskCount = 0;
let frameCount = 0;

// 统计 task 执行次数
function scheduleTask() {
  setTimeout(() => {
    taskCount++;
    if (taskCount < 1000) scheduleTask();
  }, 0);
}

// 统计实际渲染帧数
function countFrame() {
  frameCount++;
  requestAnimationFrame(countFrame);
}

scheduleTask();
requestAnimationFrame(countFrame);

// 1 秒后查看:
setTimeout(() => {
  console.log(`tasks: ${taskCount}, frames: ${frameCount}`);
  // 典型结果:tasks: ~250, frames: ~60
  // 说明 ~250 个 task 执行期间只渲染了 ~60 次
  // 浏览器把多个 task 攒在一帧里
}, 1000);

渲染阶段内部流程

hasRenderingOpportunity() 返回 true 后,按顺序执行:

渲染阶段(一帧内):

fireResizeAndScrollEvents()     触发 resize/scroll 事件回调
evaluateMediaQueries()          评估媒体查询变化
runAnimationFrameCallbacks()    执行本帧的 rAF 回调
drainMicrotasks()               清空 rAF 产生的微任务
renderPipeline():
┌──────────────────────────────────────────────────────────┐
│  ① Style(样式计算)                                      │
│     匹配 CSS 规则,计算每个元素的最终样式                     │
│                       ↓                                  │
│  ② Layout(布局)                                        │
│     计算元素的位置和尺寸,生成布局树                          │
│                       ↓                                  │
│  ③ Paint(绘制记录)                                      │
│     生成绘制指令列表(不是真正画像素)                         │
│                       ↓                                  │
│  ④ Composite(合成)→ 交给合成器线程                        │
│     图层分块、光栅化、GPU 合成最终画面                        │
│     主线程到这里就释放了,可以处理下一个 task                  │
└──────────────────────────────────────────────────────────┘

rAF 的双缓冲机制

浏览器维护两个 rAF 回调列表,防止无限循环:

本帧 rAF 列表(正在执行)     下一帧 rAF 列表(收集中)
┌──────────────────┐        ┌──────────────────┐
│ callback_A       │        │                  │
│ callback_B       │ ──→    │ callback_C       │ ← rAF 回调中注册的新 rAF
│                  │        │                  │    进入下一帧列表
└──────────────────┘        └──────────────────┘

本帧只执行"本帧列表"中的回调
回调中注册的 rAF 进入"下一帧列表",下一帧再执行
→ 保证每帧的 rAF 回调数量有限,不会无限循环
requestAnimationFrame(() => {
  console.log('帧 1');

  requestAnimationFrame(() => {
    console.log('帧 2'); // 下一帧才执行,不会在帧 1 中执行
  });
});

深入 hasIdleTime:空闲期判断

两种空闲类型

类型 1:帧内空闲,当前帧的任务 + 渲染完成后,还没到下一个 vsync

┌──────────────────────────────────────────────────────┐
│ Task │ 微任务 │ rAF │ StyleLayoutPaintRest?  │
│                                                │     │
│                                            rIC │     │
│ ←───────────────── 16.67ms ────────────────────────→ │
└──────────────────────────────────────────────────────┘

Rest (剩余时间)如果有,则用于执行 requestIdleCallback

类型 2:队列全空,所有任务队列为空,没有渲染需求

     ┌──────────┐                    ┌──────────┐
     │  Task A  │      空闲期         │  Task B  │
     │          │ ←── rIC 可执行 ──→  │          │
     └──────────┘                    └──────────┘
                 没有 task、没有渲染需求
                 整段时间都可以给 rIC

deadline 的计算

computeIdleDeadline():
  if (有渲染的帧) {
    // 本帧剩余时间 = 下一个 vsync 时间 - 当前时间
    return nextVsyncTime - now();
  } else {
    // 队列全空的空闲期,规范设上限 50ms
    // 防止突然到来的用户输入得不到及时响应
    return 50;
  }

  // 最终:Math.min(剩余时间, 50ms)

为什么上限是 50ms

  • 人类对交互延迟的感知阈值约 100ms
  • 留 50ms 给 rIC,剩 50ms 给后续可能到来的用户交互处理
  • 保证即使 rIC 用满时间,用户输入也能在 100ms 内响应

deadline 对象

requestIdleCallback(deadline => {
  // deadline.timeRemaining()
  //   → 返回当前空闲期剩余毫秒数
  //   → 会实时递减,每次调用都重新计算
  //   → 如果是 timeout 强制执行,返回 0

  // deadline.didTimeout
  //   → 是否因为 timeout 超时被强制调用
  //   → true 时 timeRemaining() 一定返回 0

  while (deadline.timeRemaining() > 0 && tasks.length > 0) {
    doTask(tasks.pop());
  }

  if (tasks.length > 0) {
    requestIdleCallback(doWork); // 还有任务,注册下一次空闲回调
  }
});

timeout 强制执行机制

requestIdleCallback(cb, { timeout: 2000 })

时间线:
0ms        500ms       1000ms      1500ms       2000ms
│           │           │           │            │
│  忙碌...   │  忙碌...   │  忙碌...   │  忙碌...   │ 超时!
│  没有空闲  │  没有空闲   │  没有空闲   │  没有空闲   │
│           │           │           │             ↓
│           │           │           │      强制执行 cb
│           │           │           │      didTimeout = true
│           │           │           │      timeRemaining() = 0
│           │           │           │      ⚠️ 挤占当前帧预算!

rIC 的实际局限性

理论很美好,但 rIC 在实际工程中有明显问题:

问题说明
Safari 长期不支持Safari 直到 2024 年底才开始支持,之前需要 polyfill
调度精度不够空闲时间不确定,可能很短(1-2ms),也可能很长
帧率不匹配高刷屏(120Hz)下每帧只有 8.33ms,空闲时间更少
无法保证执行如果持续忙碌且没设 timeout,rIC 可能永远不执行

React 的选择:React 早期(Fiber 架构)尝试用 rIC 做时间切片,后来放弃,改用 MessageChannel 自建调度器。为什么 React 用 MessageChannel 而不是 rIC?

rIC 的问题

  • 一帧最多执行一次,吞吐量太低
  • 空闲时间不可控,可能只有 1ms
  • Safari 不支持

MessageChannel 的优势

  • 属于 task,每轮事件循环都能执行
  • 不受帧率约束,调度频率更高
  • 全浏览器支持
  • React 自己控制时间切片(5ms 一片),用 performance.now() 手动判断是否超时

简化逻辑

const channel = new MessageChannel();
channel.port1.onmessage = () => {
  const start = performance.now();
  // 执行任务,每 5ms 检查一次是否该让出
  while (hasWork() && performance.now() - start < 5) {
    doWork();
  }
  if (hasWork()) {
    channel.port2.postMessage(null); // 安排下一次调度
  }
};

全局视角:一帧 16.67ms 的预算分配

16.67ms 帧预算分配

把四个步骤放到一帧的时间预算里看:

│←────────────────────── 16.67ms(一帧 @60fps)──────────────────────→│
│                                                                    │
│  JS 执行区域(主线程)                    │  渲染区域(主线程)          │
│  Task + 微任务                          │                           │
│                                        │                           │
│  ┌──────────┬────────┐                 │  ┌─────┬──────┬─────┐     │
│  │   Task   │ 微任务  │                 │  │Style│Layout│Paint│     │
│  │  ≤10ms   │ ≤2ms   │                 │  │~2ms │~2ms  │~1ms │     │
│  └──────────┴────────┘                 │  └─────┴──────┴─────┘     │
│                                        │                     │     │
│         rAF(在渲染区域开头执行)──→  rAF ─┘                     │     │
│                                                          Composite → 合成器线程
│                                                               │     │
│                                                         Idle ─┤     │
│                                                         rIC   │     │
│                                                                     │

主线程 vs 合成器线程

不是所有渲染步骤都占用主线程:

┌─────────────── 主线程 ────────────────┐     ┌──── 合成器线程 ─────┐
│                                      │     │                   │
│  Task → 微任务 → rAF                  │     │                   │
│  → Style → Layout → Paint(生成指令)  │────→│  光栅化 → GPU 合成  │
│                                      │     │                   │
│  主线程到 Paint 完就释放了              │     │  不受 JS 阻塞       │
│  可以继续处理下一个 task                │     │  独立线程完成        │
└──────────────────────────────────────┘     └────────────────────┘

这意味着,CSS 属性与渲染开销:

  • transform / opacity

    • 只需 Composite(合成器线程处理)
    • 跳过 Style + Layout + Paint
    • 成本最低,推荐用于动画
  • color / background

    • 需要 Style + Paint + Composite
    • 跳过 Layout
    • 中等成本
  • width / height / top / left

    • 需要 Style + Layout + Paint + Composite
    • 全流程,成本最高
    • 避免在动画中使用

长任务导致掉帧

当 JS 执行时间超过帧预算时。

正常帧(JS ≤ 10ms)

┌──────────────────────────────────────────────────┐
│ JS(8ms) │ 微任务 │ rAF │ Style │Layout│Paint│Idle│
│←──────────────── 16.67ms ──────────────────────→│
└──────────────────────────────────────────────────┘

长任务帧(JS > 50ms)

┌─────────────────────────────────────────────────────────────────────┐
│ JS 长任务 (80ms)                                 │Style│Layout│Paint │
│←──────────────── 16.67ms ───→│←── 跳过 ──→│←────── 16.67ms ───→│     │
│                   帧 1 丢失     帧 2 丢失     帧 3 丢失      帧 4       │
└─────────────────────────────────────────────────────────────────────┘

掉了 3 帧,用户感知到卡顿。

Long Task 定义:超过 50ms 的 task(Performance API 标准)。

监控长任务

// 使用 PerformanceObserver 监控
new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    console.log(`Long Task: ${entry.duration.toFixed(1)}ms`);
  }
}).observe({ type: 'longtask', buffered: true });

应对长任务的策略

时间切片(手动让出主线程):

  • 每处理一批数据就 yield,让渲染有机会执行
  • React Fiber、scheduler.yield() 都是这个思路

Web Worker(搬到其他线程):

  • 计算密集型任务放到 Worker 线程
  • 主线程只负责 DOM 更新
  • 适合纯计算,不涉及 DOM 操作

requestAnimationFrame(对齐帧节奏):

  • 动画/视觉更新用 rAF 而非 setTimeout
  • 保证每帧恰好执行一次,不会过度执行或丢帧

总结:用一段话说清事件循环

因为 JavaScript 是单线程,所以需要事件循环来协调异步操作。事件循环模型包含:任务队列、微任务队列、渲染时机、rAF 和 requestIdleCallback 执行位置。每轮循环的流程:

  1. 首先取一个任务(task)执行;
  2. 执行完毕后清空微任务队列,包含执行过程中产生的新的微任务;
  3. 然后判断是否需要渲染,是否渲染取决于 vsync 信号是否到达、页面是否可见、是否有视觉变化,如果需要渲染则首先执行 rAF,然后执行 Style → Layout → Paint → Composite
  4. 最后判断是否还有空闲时间,如果有则执行 requestIdleCallback。最后进入新的一轮循环。