基于 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);
}
}
任务与微任务的区分
定义
事件循环中的异步回调分为两类,由规范决定,不是开发者选择的:
Task(WHATWG 规范术语,社区俗称"宏任务")
→ 由宿主环境(浏览器/Node.js)发起的异步操作回调
→ 每轮事件循环只取一个执行
Microtask(微任务)
→ 由 JavaScript 引擎(V8)发起的异步操作回调
→ 每轮事件循环必须全部清空
规范中只有 task 和 microtask,从未出现过 "macrotask"。"宏任务"是社区为了和 "microtask" 对称而产生的俗称。
来源分类
Task(宿主环境调度):浏览器/Node.js 负责把回调,放入 task queue
| 来源 | 环境 | 说明 |
|---|---|---|
setTimeout / setInterval | 浏览器 + Node | 嵌套 >5 层后最小延迟 4ms |
setImmediate | Node.js | 在 check 阶段执行 |
| I/O 回调 | Node.js | 文件系统、网络操作 |
| UI 渲染/事件 | 浏览器 | click、scroll、keypress 等 |
MessageChannel | 浏览器 + Worker | port.postMessage() |
requestIdleCallback | 浏览器 | 空闲期最低优先级 |
<script> 初始执行 | 浏览器 | 脚本标签解析执行 |
Microtask(JS引擎调度):V8 引擎负责把回调,放入 microtask queue
| 来源 | 环境 | 说明 |
|---|---|---|
Promise.then/catch/finally | 所有 | 通过 HostEnqueuePromiseJob |
queueMicrotask() | 浏览器 + Node | 显式微任务调度 |
MutationObserver | 浏览器 | DOM 变更观察回调 |
process.nextTick() | Node.js | 在微任务之前执行(独立队列) |
await 后续代码 | 所有 | 通过隐式 Promise.then |
核心行为差异
| 维度 | Task | Microtask |
|---|---|---|
| 每轮取多少个 | 一个 | 全部清空 |
| 新产生的 | 排到队尾,下轮再取 | 本轮继续清空 |
| 与渲染的关系 | 渲染在 task 间进行 | 微任务全部清空后才渲染 |
| 队列数量 | 多个(按任务源分类) | 只有一个 |
| 无限递归 | 不会阻塞渲染(每轮只取一个) | 会阻塞渲染(必须清空) |
谁调度的?
Task(宏任务):由浏览器 / Node.js 安排
| 示例 | 触发时机 |
|---|---|
setTimeout | 浏览器的定时器线程到期后放入 task queue |
click 事件 | 浏览器检测到用户点击后放入 task queue |
fetch 回调 | 浏览器网络线程收到响应后放入 task queue |
Microtask(微任务):由 V8 引擎安排
| 示例 | 触发时机 |
|---|---|
Promise.then | V8 在 Promise resolve 时调用 HostEnqueuePromiseJob |
await 后续代码 | V8 将 async 函数恢复操作入队微任务 |
queueMicrotask | 直接调用 V8 的微任务入队接口 |
特殊情况:process.nextTick(Node.js)
process.nextTick 是微任务,但有自己的独立队列,优先级比 Promise 微任务更高
每个阶段之间的清空顺序(Node 11+):
- 清空 process.nextTick 队列
- 清空 Promise 微任务队列
- 进入下一个阶段
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,在以下时机都会触发:
- 每个 task 执行完毕后
- 调用栈从 C++ 回到 JavaScript 时。如 DOM API 回调
- rAF 回调执行完毕后
- 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 信号是否到达? │ ← 硬件层
│ No → return false │
└────────────┬─────────────┘
│ Yes
┌────────────▼─────────────┐
│ 2. 页面是否可见? │ ← 可见性层
│ hidden → return false │
└────────────┬─────────────┘
│ visible
┌────────────▼─────────────┐
│ 3. 是否有视觉变化? │ ← 脏检查层
│ No → return 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):
- DOM 被修改(增删改节点)
- CSSOM 被修改(style/class/attribute 变化)
- 有 pending 的 rAF 回调
- CSS animation / transition 正在进行
- 滚动位置变化
- resize 发生
不需要渲染(clean):
- 页面静止,没有任何变化
- 只执行了纯计算 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 │ Style │ Layout │ Paint │ Rest? │
│ │ │
│ 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 执行位置。每轮循环的流程:
- 首先取一个任务(task)执行;
- 执行完毕后清空微任务队列,包含执行过程中产生的新的微任务;
- 然后判断是否需要渲染,是否渲染取决于 vsync 信号是否到达、页面是否可见、是否有视觉变化,如果需要渲染则首先执行
rAF,然后执行Style → Layout → Paint → Composite; - 最后判断是否还有空闲时间,如果有则执行
requestIdleCallback。最后进入新的一轮循环。