事件循环深入
前言
事件循环(Event Loop)是 JavaScript 异步编程的核心机制,它决定了代码的执行顺序。理解事件循环不仅有助于编写高性能的 Web 应用,还能帮助开发者避开各种异步陷阱。本文将深入探讨事件循环的原理、宏任务与微任务的执行顺序、与浏览器渲染的关系,以及事件冒泡与委托机制。
一、事件循环概念与浏览器架构
1.1 为什么需要事件循环
JavaScript 是单线程语言,这意味着它同一时间只能执行一个任务。如果所有任务都同步执行,当一个任务耗时较长时(如网络请求、定时器),后续任务就会被阻塞,导致页面无响应。事件循环机制允许 JavaScript 将耗时任务交给浏览器其他线程处理,待任务完成后再将回调放入队列中排队执行,从而实现非阻塞异步编程。
1.2 浏览器进程架构
现代浏览器通常采用多进程架构,主要包含以下进程:
| 进程 | 职责 |
|---|---|
| 浏览器进程 | 控制浏览器界面(地址栏、标签页、前进/后退按钮等) |
| GPU 进程 | 处理 GPU 相关的计算任务 |
| 网络进程 | 处理网络请求 |
| 插件进程 | 管理浏览器插件 |
| 渲染进程 | 解析 HTML/CSS、执行 JavaScript、页面渲染 |
每个标签页通常对应一个独立的渲染进程。渲染进程内部包含多个线程:
- 主线程(Main Thread):执行 JavaScript、解析 DOM/CSS、布局、绘制
- 合成线程(Compositor Thread):将图层合成为最终画面
- 工作线程(Worker Threads):处理 Web Workers、Service Workers
事件循环正是在渲染进程的主线程上运行的机制。
1.3 事件循环的基本流程
事件循环的简化流程如下:
执行栈为空 → 检查微任务队列 → 取一个宏任务执行 → 检查并清空微任务队列 → 执行 requestAnimationFrame → 渲染更新 → 检查宏任务队列更精确的流程会在后续章节逐步展开。
二、宏任务(MacroTask)
2.1 什么是宏任务
宏任务是由宿主环境(浏览器/Node.js)发起的异步任务。每次事件循环会从宏任务队列中取出一个任务执行。
2.2 常见的宏任务来源
| API | 环境 | 说明 |
|---|---|---|
setTimeout(cb, delay) | 浏览器/Node.js | 延迟执行回调 |
setInterval(cb, interval) | 浏览器/Node.js | 间隔执行回调 |
setImmediate(cb) | Node.js | 在当前事件循环末尾执行(IE 也曾支持) |
| I/O 操作 | 浏览器/Node.js | 网络请求、文件读写等 |
| UI 渲染事件 | 浏览器 | 用户交互事件(点击、输入等) |
postMessage | 浏览器 | 跨文档消息通信 |
MessageChannel | 浏览器/Node.js | 消息通道通信 |
2.3 setTimeout 的延迟问题
setTimeout(cb, 0) 并不意味着回调会立即执行,而是至少等待 0 毫秒后放入宏任务队列。实际执行时间取决于事件循环队列中排在前面的任务。
console.log('A');
setTimeout(() => console.log('B'), 0);
console.log('C');
// 输出顺序:A → C → B当页面中包含大量同步计算或微任务时,setTimeout 的回调可能会被显著延迟。
三、微任务(MicroTask)
3.1 什么是微任务
微任务是由 JavaScript 引擎自身发起的异步任务。微任务会在当前宏任务执行完毕后、下一个宏任务开始前被清空。这意味着 微任务的优先级高于宏任务。
3.2 常见的微任务来源
| API | 说明 |
|---|---|
Promise.then / catch / finally | Promise 决议后的回调 |
MutationObserver | DOM 变化观察回调 |
queueMicrotask(cb) | 显式创建微任务 |
process.nextTick | Node.js 专用(优先级高于微任务队列) |
3.3 微任务队列的特性
微任务队列的核心特性是:在当前宏任务完成之后,事件循环会清空整个微任务队列(即执行队列中所有微任务),然后才进入下一轮宏任务。如果微任务中又创建了新的微任务,这些新微任务也会在本轮被一并执行,这可能导致渲染被无限推迟。
console.log('1');
Promise.resolve().then(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
});
});
setTimeout(() => console.log('4'), 0);
// 输出:1 → 2 → 3 → 4四、事件循环执行顺序图解
4.1 完整执行流程
以下是一个完整的单次事件循环迭代流程:
┌─────────────────────────────────┐
│ 开始事件循环 │
└────────────────┬────────────────┘
▼
┌─────────────────────────────────┐
│ 宏任务队列是否为空? │
│ 是 → 等待下一个宏任务 │
│ 否 → 取出一个宏任务执行 │
└────────────────┬────────────────┘
▼
┌─────────────────────────────────┐
│ 执行宏任务 │
│ (创建新的执行栈) │
└────────────────┬────────────────┘
▼
┌─────────────────────────────────┐
│ 清空微任务队列(直至为空) │
│ ┌───────────────────────────┐ │
│ │ while (微任务队列不为空) { │ │
│ │ 取出一个微任务执行 │ │
│ │ } │ │
│ └───────────────────────────┘ │
└────────────────┬────────────────┘
▼
┌─────────────────────────────────┐
│ requestAnimationFrame 回调执行 │
└────────────────┬────────────────┘
▼
┌─────────────────────────────────┐
│ 浏览器渲染更新 │
│ (样式计算 → 布局 → 绘制) │
└────────────────┬────────────────┘
▼
┌─────────────────────────────────┐
│ 检查宏任务队列,进入下一轮 │
└─────────────────────────────────┘4.2 重要执行规则
- 宏任务一次只取一个:每次事件循环只从宏任务队列中取出一个任务执行。
- 微任务队列必须清空:当前宏任务执行完后,会一口气执行完所有微任务。即使微任务中不断添加新的微任务,也会在本轮全部执行。
- 渲染在微任务之后:浏览器渲染发生在微任务队列清空之后,因此微任务过多会阻塞渲染。
4.3 综合代码示例
console.log('1: 同步代码开始');
setTimeout(() => {
console.log('2: setTimeout 宏任务');
Promise.resolve().then(() => {
console.log('3: setTimeout 内的微任务');
});
}, 0);
Promise.resolve().then(() => {
console.log('4: 第一个 Promise 微任务');
setTimeout(() => {
console.log('5: 微任务中添加的 setTimeout');
}, 0);
});
Promise.resolve().then(() => {
console.log('6: 第二个 Promise 微任务');
});
console.log('7: 同步代码结束');
// 执行顺序分析:
// 1. 执行同步代码:输出 1, 7
// 2. 清空微任务队列:输出 4, 6
// 4 中注册了宏任务(5)
// 3. 取出下一个宏任务(2):输出 2
// 2 中注册了微任务(3)
// 4. 清空微任务队列:输出 3
// 5. 取出下一个宏任务(5):输出 5
// 最终输出:1 → 7 → 4 → 6 → 2 → 3 → 5五、requestAnimationFrame 与渲染帧
5.1 requestAnimationFrame 的执行时机
requestAnimationFrame(简称 rAF)是在微任务清空之后、浏览器渲染之前执行的。它的回调执行时机与浏览器的刷新率同步(通常为 60Hz,即每 16.67ms 执行一次)。
宏任务 → 清空微任务 → requestAnimationFrame → 渲染 → 下一轮5.2 requestAnimationFrame 与 setTimeout 的区别
| 特性 | requestAnimationFrame | setTimeout |
|---|---|---|
| 执行时机 | 每次渲染之前 | 到达指定延迟后 |
| 与帧率关系 | 同步于刷新率 | 独立于帧率 |
| 页面不可见时 | 暂停执行 | 继续执行(后台限制频率) |
| 精度 | 高,与 VSync 同步 | 约 4ms 嵌套阈值 |
| 用途 | 动画、视觉更新 | 通用延迟执行 |
5.3 代码示例
console.log('A');
setTimeout(() => {
console.log('B: setTimeout');
}, 0);
requestAnimationFrame(() => {
console.log('C: rAF');
});
Promise.resolve().then(() => {
console.log('D: Promise 微任务');
});
console.log('E');
// 输出顺序:A → E → D → C → B
// 说明:同步 > 微任务 > rAF > 宏任务(setTimeout)5.4 requestAnimationFrame 的嵌套与降级
当 requestAnimationFrame 嵌套超过一定层数(浏览器阈值,通常约为 5 层),浏览器会将其降级为类似 setTimeout(fn, 0) 的行为,以避免耗尽系统资源。
六、事件冒泡与事件委托
6.1 DOM 事件流
DOM 事件流分为三个阶段:
- 捕获阶段(Capture Phase):事件从
window向下传播到目标元素的父节点 - 目标阶段(Target Phase):事件到达目标元素本身
- 冒泡阶段(Bubble Phase):事件从目标元素的父节点向上传播到
window
┌──────────────────────────────┐
│ window │
│ ┌────────────────────────┐ │
│ │ document │ │
│ │ ┌──────────────────┐ │ │
│ │ │ html │ │ │
│ │ │ ┌────────────┐ │ │ │
│ │ │ │ body │ │ │ │
│ │ │ │ ┌──────┐ │ │ │ │
│ │ │ │ │ div │ │ │ │ │
│ │ │ │ │ ┌──┐ │ │ │ │ │
│ │ │ │ │ │目│ │ │ │ │ │
│ │ │ │ │ │标│ │ │ │ │ │
│ │ │ │ │ └──┘ │ │ │ │ │
│ │ │ │ └──────┘ │ │ │ │
│ │ │ └────────────┘ │ │ │
│ │ └──────────────────┘ │ │
│ └────────────────────────┘ │
└──────────────────────────────┘
捕获阶段 → 目标阶段 → 冒泡阶段
(自上而下) ● (自下而上)6.2 使用 addEventListener 控制阶段
// 捕获阶段监听
element.addEventListener('click', handler, true);
// 或
element.addEventListener('click', handler, { capture: true });
// 冒泡阶段监听(默认)
element.addEventListener('click', handler, false);
// 或
element.addEventListener('click', handler); // 默认为冒泡阶段6.3 阻止事件传播
// 阻止后续传播(包括捕获和冒泡)
event.stopPropagation();
// 阻止传播并阻止同元素上其他同类事件处理程序被调用
event.stopImmediatePropagation();
// 阻止默认行为(如链接跳转、表单提交)
event.preventDefault();6.4 事件委托
事件委托利用事件冒泡机制,将子元素的事件处理委托给父元素统一管理。
<ul id="list">
<li>项目 1</li>
<li>项目 2</li>
<li>项目 3</li>
</ul>document.getElementById('list').addEventListener('click', (event) => {
const target = event.target;
if (target.tagName === 'LI') {
console.log('点击了:', target.textContent);
}
});事件委托的优点:
| 优点 | 说明 |
|---|---|
| 减少内存占用 | 只需要一个事件处理程序,而非每个子元素一个 |
| 动态绑定 | 新添加的子元素自动继承事件处理逻辑 |
| 简化代码 | 集中管理事件逻辑 |
事件委托的注意事项:
- 并非所有事件都会冒泡(如
focus、blur、mouseenter、mouseleave、load、unload、scroll、error等) focus和blur可通过focusin和focusout实现委托- 距离目标越远,事件触发的性能开销越大(但通常可忽略)
七、常见面试题与执行顺序分析
7.1 基础题:Promise 与 setTimeout
setTimeout(() => {
console.log('1');
}, 0);
new Promise((resolve) => {
console.log('2');
resolve();
}).then(() => {
console.log('3');
});
console.log('4');
// 答案:2 → 4 → 3 → 1
// 解析:
// - Promise 构造函数中的代码是同步执行的 → 输出 2, 4
// - Promise.then 是微任务 → 在当前宏任务末尾执行 → 输出 3
// - setTimeout 是宏任务 → 在下一轮事件循环执行 → 输出 17.2 进阶题:async/await
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
async1();
new Promise((resolve) => {
console.log('promise1');
resolve();
}).then(() => {
console.log('promise2');
});
console.log('script end');
// 输出顺序分析:
// step 1: 同步代码
// "script start" → 调用 async1()
// step 2: async1 内同步
// "async1 start" → 调用 await async2()
// step 3: async2 是同步
// "async2" → await 后续变为微任务
// step 4: 继续同步
// "promise1" → "script end"
// step 5: 清空微任务队列
// "async1 end"(await 后续)→ "promise2"
// step 6: 下一轮宏任务
// "setTimeout"
// 最终输出:script start → async1 start → async2 → promise1 → script end → async1 end → promise2 → setTimeout7.3 高阶题:混合场景
console.log('start');
setTimeout(() => {
console.log('timeout1');
Promise.resolve().then(() => {
console.log('promise1');
});
}, 0);
setTimeout(() => {
console.log('timeout2');
}, 0);
Promise.resolve().then(() => {
console.log('promise2');
setTimeout(() => {
console.log('timeout3');
}, 0);
});
Promise.resolve().then(() => {
console.log('promise3');
setTimeout(() => {
console.log('timeout4');
}, 0);
});
console.log('end');
// 输出顺序:start → end → promise2 → promise3 → timeout1 → promise1 → timeout2 → timeout3 → timeout4
// 详细分析:
// 1. 同步:输出 "start"、"end"
// 2. 微任务队列:[promise2, promise3]
// 输出 "promise2" → 注册 timeout3
// 输出 "promise3" → 注册 timeout4
// 3. 宏任务队列:[timeout1, timeout2, timeout3, timeout4]
// 取出 timeout1 执行 → 输出 "timeout1" → 注册微任务 promise1
// 清空微任务 → 输出 "promise1"
// 4. 取出 timeout2 → 输出 "timeout2"
// 5. 取出 timeout3 → 输出 "timeout3"
// 6. 取出 timeout4 → 输出 "timeout4"7.4 渲染帧题
button.addEventListener('click', () => {
console.log('click 宏任务 begin');
Promise.resolve().then(() => {
console.log('click 微任务');
});
requestAnimationFrame(() => {
console.log('rAF');
});
setTimeout(() => {
console.log('setTimeout');
}, 0);
console.log('click 宏任务 end');
});
// 用户点击按钮后,输出顺序:
// click 宏任务 begin → click 宏任务 end → click 微任务 → rAF → 渲染 → setTimeout
// 注意:如果连续点击两次,第二次点击触发的宏任务会先进入队列,
// 但 rAF 回调会在渲染前合并执行。7.5 Node.js 事件循环差异
Node.js 的事件循环与浏览器略有不同,包含额外的阶段:
┌───────────────────────────┐
┌─>│ timers │ ← setTimeout / setInterval 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ ← 延迟执行的 I/O 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ ← 内部使用
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ ← 轮询 I/O 事件(核心阶段)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ ← setImmediate 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ close callbacks │ ← 关闭事件回调(如 socket.on('close'))
│ └───────────────────────────┘Node.js 中 process.nextTick 优先级最高,会在每个阶段切换时执行,其优先级高于微任务。
八、总结
| 概念 | 关键点 |
|---|---|
| 同步代码 | 立即执行,在调用栈中顺序运行 |
| 微任务 | Promise.then / queueMicrotask / MutationObserver,在当前宏任务末尾清空 |
| requestAnimationFrame | 渲染之前执行,与屏幕刷新率同步 |
| 宏任务 | setTimeout / setInterval / I/O,每次事件循环取一个执行 |
| 渲染 | 微任务 + rAF 完成后执行 |
| 事件冒泡 | 事件从目标元素向上传播到 window |
| 事件委托 | 利用冒泡将事件处理委托给父元素 |
掌握事件循环是理解 JavaScript 异步编程的基石。它不仅能帮助你在面试中准确回答执行顺序问题,更能让你在生产环境中编写出可预测、高性能的异步代码。