事件循环(Event Loop)详解
事件循环(Event Loop)是 JavaScript 异步编程的核心机制,理解它是成为一名优秀前端工程师的必备技能。本文将从基础概念到浏览器和 Node.js 的实现,全面讲解事件循环。
一、为什么需要事件循环?
1.1 JavaScript 是单线程的
JavaScript 从诞生之日起就是单线程的。为什么?
原因:DOM 操作的互斥性
假设 JavaScript 是多线程的:
- 线程 A 把 DOM 元素的颜色改成红色
- 线程 B 把同一个 DOM 元素的颜色改成蓝色
- 此时浏览器该听谁的?
为了避免这种复杂的同步问题,JavaScript 选择了单线程。
但单线程会带来问题:
1 | // 假设这是一个耗时 3 秒的计算 |
在 heavyTask 执行期间:
- 页面无法响应点击
- 动画会卡顿
- 用户体验很差
这就是事件循环要解决的问题:让单线程的 JavaScript 也能高效处理异步操作。
二、核心概念
2.1 执行栈(Call Stack)
执行栈是一个后进先出(LIFO)的数据结构,用来存储当前正在执行的函数调用帧。
执行栈的工作原理
- 函数调用时:创建一个调用帧(包含函数的参数、局部变量等信息)并压入栈顶
- 函数执行时:执行栈顶的调用帧
- 函数执行完毕:调用帧从栈顶弹出
- 栈为空时:执行引擎会检查任务队列
1 | function a() { |
执行过程:
1 | 1. c() 入栈 |
执行栈的重要特性:
- 同步代码执行时,执行栈会一直被占用,直到所有同步代码执行完毕
- 当执行栈为空时,事件循环才会从任务队列中取出任务执行
- 如果执行栈中有大量同步代码,会阻塞事件循环,导致页面卡顿
2.2 任务队列(Task Queue)
当异步任务完成时,它们不会直接进入执行栈,而是先进入任务队列。任务队列是一个先进先出(FIFO)的数据结构。
任务队列的分类
任务队列分为两类:
- 宏任务(Macro Task / Task):较大的任务,如定时器、I/O 操作等
- 微任务(Micro Task / Job):较小的任务,如 Promise 回调、MutationObserver 等
任务队列的执行顺序
- 每次事件循环只处理一个宏任务
- 一个宏任务执行完毕后,会清空所有微任务
- 微任务执行过程中产生的新微任务,也会在当前这一轮执行
2.3 宏任务 vs 微任务
| 类型 | 包含 | 说明 | 执行时机 |
|---|---|---|---|
| 宏任务 | setTimeout / setInterval | 定时器 | 每个事件循环周期执行一个 |
setImmediate(Node.js) | 立即执行 | 在 poll 阶段结束后执行 | |
| I/O 操作 | 文件读写、网络请求等 | 完成后进入任务队列 | |
| UI 渲染(浏览器) | 页面重绘 | 微任务执行完毕后 | |
requestAnimationFrame(浏览器) | 动画帧 | 浏览器渲染前执行 | |
| 脚本执行 | 整体脚本代码 | 初始执行 | |
| 微任务 | Promise.then/catch/finally | Promise 回调 | 每个宏任务执行完毕后 |
async/await | 底层是 Promise | 每个宏任务执行完毕后 | |
process.nextTick(Node.js) | Node 特有 | 微任务中优先级最高 | |
MutationObserver(浏览器) | DOM 变化监听 | 每个宏任务执行完毕后 | |
queueMicrotask() | 手动创建微任务 | 每个宏任务执行完毕后 |
微任务的优先级
在微任务队列中,不同类型的微任务也有优先级:
- Node.js:
process.nextTick>Promise.then - 浏览器:
Promise.then>MutationObserver
为什么需要微任务?
微任务的设计目的是为了处理那些需要在当前宏任务执行完毕后立即执行的操作,比如:
- Promise 的回调
- DOM 变化的处理
- 需要在下一次 UI 渲染前完成的操作
微任务的执行时机比宏任务更早,这使得它们可以在 UI 渲染前完成,从而避免视觉上的不一致。
三、浏览器中的事件循环
3.1 事件循环的执行流程
一句话总结:一个宏任务,一批微任务。
详细执行流程
- 初始执行:执行整体脚本代码(第一个宏任务)
- 执行同步代码:执行当前宏任务中的同步代码
- 处理微任务:执行完宏任务后,清空所有微任务队列
- UI 渲染:如果需要,执行 UI 渲染
- 下一个宏任务:从宏任务队列中取出下一个任务执行
- 循环:重复步骤 2-5
1 | ┌─────────────────────────────────────────────────────────────┐ |
关键点:
- 每次只执行一个宏任务
- 执行完这个宏任务后,要把所有微任务都执行完
- 微任务执行过程中产生的新微任务,也会在当前这一轮执行
- UI 渲染发生在微任务执行完毕后,下一个宏任务开始前
3.2 经典例题解析
例题 1:基础版
1 | console.log('1'); |
输出结果:
1 | 1 |
执行过程详解:
| 步骤 | 执行栈 | 宏任务队列 | 微任务队列 | 输出 | 说明 |
|---|---|---|---|---|---|
| 1 | console.log('1') | [] | [] | 1 | 同步代码执行 |
| 2 | setTimeout | [setTimeout] | [] | - | 宏任务加入队列 |
| 3 | Promise.resolve().then | [setTimeout] | [promise] | - | 微任务加入队列 |
| 4 | console.log('4') | [setTimeout] | [promise] | 4 | 同步代码执行 |
| 5 | 清空微任务队列 | [setTimeout] | [] | 3 | 执行微任务 |
| 6 | 执行宏任务 | [] | [] | 2 | 执行下一个宏任务 |
例题 2:async/await 版
1 | async function async1() { |
输出结果(现代浏览器):
1 | script start |
执行过程详解:
| 步骤 | 执行栈 | 宏任务队列 | 微任务队列 | 输出 | 说明 |
|---|---|---|---|---|---|
| 1 | console.log('script start') | [] | [] | script start | 同步代码执行 |
| 2 | async1() | [] | [] | async1 start | 执行 async1,输出 start |
| 3 | async2() | [] | [] | async2 | 执行 async2,输出 async2 |
| 4 | 暂停 async1 | [] | [async1 end] | - | await 让出线程,async1 end 进入微任务 |
| 5 | setTimeout | [setTimeout] | [async1 end] | - | 宏任务加入队列 |
| 6 | new Promise | [setTimeout] | [async1 end] | promise | 执行 Promise 构造函数,输出 promise |
| 7 | Promise.then | [setTimeout] | [async1 end, promise1] | - | 微任务加入队列 |
| 8 | console.log('script end') | [setTimeout] | [async1 end, promise1] | script end | 同步代码执行 |
| 9 | 清空微任务队列 | [setTimeout] | [promise2] | async1 end | 执行 async1 end |
| 10 | 继续清空微任务队列 | [setTimeout] | [] | promise1, promise2 | 执行 promise1,然后 promise2 |
| 11 | 执行宏任务 | [] | [] | setTimeout | 执行下一个宏任务 |
关键点理解:
async函数的返回值是 Promiseawait会暂停执行,让出线程await后面的代码会被包装成微任务- 多个微任务会按顺序执行
例题 3:微任务中产生微任务
1 | Promise.resolve().then(() => { |
输出结果:
1 | 1 |
执行过程详解:
| 步骤 | 执行栈 | 微任务队列 | 输出 | 说明 |
|---|---|---|---|---|
| 1 | 同步代码执行 | [Promise1] | - | 初始微任务加入队列 |
| 2 | 执行 Promise1 | [Promise2] | 1 | 输出 1,新微任务加入队列 |
| 3 | 执行 Promise2 | [Promise3] | 2 | 输出 2,新微任务加入队列 |
| 4 | 执行 Promise3 | [] | 3 | 输出 3 |
解释:
微任务队列中的任务会按顺序执行,即使在执行过程中产生新的微任务,也会在当前这一轮全部执行完。这是因为微任务队列会一直被处理,直到队列为空。
例题 4:复杂嵌套版
1 | console.log('1'); |
输出结果:
1 | 1 |
执行过程详解:
| 步骤 | 执行栈 | 宏任务队列 | 微任务队列 | 输出 | 说明 |
|---|---|---|---|---|---|
| 1 | console.log('1') | [] | [] | 1 | 同步代码执行 |
| 2 | setTimeout | [setTimeout1] | [] | - | 宏任务加入队列 |
| 3 | Promise.resolve().then | [setTimeout1] | [Promise1] | - | 微任务加入队列 |
| 4 | console.log('6') | [setTimeout1] | [Promise1] | 6 | 同步代码执行 |
| 5 | 执行微任务 | [setTimeout1, setTimeout2] | [] | 4 | 执行 Promise1,输出 4,新宏任务加入队列 |
| 6 | 执行宏任务 | [setTimeout2] | [Promise2] | 2 | 执行 setTimeout1,输出 2,新微任务加入队列 |
| 7 | 执行微任务 | [setTimeout2] | [] | 3 | 执行 Promise2,输出 3 |
| 8 | 执行宏任务 | [] | [] | 5 | 执行 setTimeout2,输出 5 |
3.3 事件循环与 UI 渲染
UI 渲染的时机
在浏览器中,UI 渲染发生在微任务队列清空后,下一个宏任务开始前。这是因为:
- 微任务执行完毕:确保所有需要在渲染前完成的操作都已执行
- DOM 变化已处理:微任务通常用于处理 DOM 变化(如 MutationObserver)
- 渲染效率:批量处理渲染,减少重绘次数
1 | 宏任务 → 微任务 → [UI 渲染] → 宏任务 → ... |
requestAnimationFrame 的执行时机
requestAnimationFrame 是一个特殊的 API,它的执行时机是在浏览器渲染前,具体来说:
- 不属于宏任务也不属于微任务:它有自己独立的执行时机
- 执行时机:在每次 UI 渲染前执行
- 用途:用于创建流畅的动画效果
- 优先级:高于宏任务,但在微任务之后
详细执行顺序
1 | console.log('开始'); |
输出顺序(确定):
1 | 开始 |
执行过程:
- 执行同步代码:
console.log('开始') setTimeout回调加入宏任务队列Promise.then回调加入微任务队列requestAnimationFrame回调加入渲染队列- 同步代码执行完毕,执行微任务:
console.log('promise') - 微任务执行完毕,执行渲染前的
requestAnimationFrame:console.log('requestAnimationFrame') - 执行 UI 渲染
- 执行下一个宏任务:
console.log('setTimeout')
实际应用:避免布局抖动
布局抖动(Layout Thrashing) 是指频繁地读写 DOM 导致浏览器频繁重排(reflow)的现象。通过合理使用事件循环,可以避免布局抖动:
1 | // ❌ 不好的做法:频繁读写 DOM,导致布局抖动 |
解释:
- 读取 DOM 属性会触发浏览器的重排计算
- 写入 DOM 属性会标记需要重排
- 通过在微任务中执行写入操作,可以确保所有读取操作都已完成,避免频繁重排
四、Node.js 中的事件循环
Node.js 的事件循环比浏览器更复杂,它基于 libuv 库实现,包含 6 个阶段。
4.1 Node.js 事件循环的 6 个阶段
1 | ┌───────────────────────────┐ |
详细解释每个阶段
timers 阶段
- 功能:执行
setTimeout和setInterval回调 - 执行时机:当定时器的时间到达时
- 实际应用:定时任务、延迟执行
- 功能:执行
pending callbacks 阶段
- 功能:执行系统级别的回调,如 TCP 连接错误
- 执行时机:上一个事件循环周期中被延迟的 I/O 回调
- 实际应用:处理网络错误、系统级回调
idle, prepare 阶段
- 功能:内部使用,用于 Node.js 内部的准备工作
- 执行时机:在 poll 阶段之前
- 实际应用:Node.js 内部使用,开发者一般不直接使用
poll 阶段(最复杂的阶段)
- 功能:
- 执行 I/O 回调
- 轮询新的 I/O 事件
- 计算阻塞时间
- 执行时机:当有 I/O 事件时
- 实际应用:处理文件读写、网络请求等 I/O 操作
- 功能:
check 阶段
- 功能:执行
setImmediate回调 - 执行时机:poll 阶段结束后
- 实际应用:需要立即执行的异步操作
- 功能:执行
close callbacks 阶段
- 功能:执行关闭相关的回调,如
socket.on('close') - 执行时机:当 socket 或其他资源关闭时
- 实际应用:资源清理、连接关闭处理
- 功能:执行关闭相关的回调,如
4.2 Node.js 事件循环的执行流程
Node.js 11+ 的行为(与浏览器一致):
每个宏任务执行完后,立即执行对应的微任务队列。
完整执行流程
进入 timers 阶段
- 检查是否有到期的定时器
- 执行定时器回调
- 执行所有微任务(
process.nextTick→Promise.then)
进入 pending callbacks 阶段
- 执行系统级回调
- 执行所有微任务
进入 idle, prepare 阶段
- Node.js 内部使用
进入 poll 阶段
- 执行 I/O 回调
- 检查是否有新的 I/O 事件
- 如果没有定时器,可能会阻塞在这里
- 执行所有微任务
进入 check 阶段
- 执行
setImmediate回调 - 执行所有微任务
- 执行
进入 close callbacks 阶段
- 执行关闭回调
- 执行所有微任务
循环:回到 timers 阶段,开始下一轮事件循环
实际应用场景
场景 1:定时器与 I/O 操作
1 | // 场景:需要在 I/O 操作完成后立即执行某些操作 |
场景 2:微任务优先级
1 | // 场景:需要确保某些操作在其他微任务之前执行 |
场景 3:事件循环与异步操作
1 | // 场景:理解事件循环如何处理多个异步操作 |
4.3 process.nextTick
process.nextTick 是 Node.js 特有的微任务,它比 Promise.then 优先级更高。
process.nextTick 的特点
- 优先级最高:在所有微任务中,
process.nextTick的优先级最高 - 执行时机:在每个事件循环阶段结束后执行,无论当前处于哪个阶段
- 队列独立:有自己独立的队列,不与其他微任务共享
1 | process.nextTick(() => { |
输出:
1 | sync |
执行顺序说明:
- 同步代码执行:
console.log('sync') - 清空
process.nextTick队列:console.log('nextTick') - 清空其他微任务队列:
console.log('promise')
注意: 虽然 process.nextTick 是微任务的一种,但它的执行时机比其他微任务更早,甚至在事件循环的每个阶段切换前都会执行。
4.4 setTimeout vs setImmediate
这是一个经典面试题。
1 | setTimeout(() => { |
输出顺序不确定!
原因:
- 浏览器中:
setTimeout(fn, 0)实际上有最小延迟时间,根据 HTML5 规范,最小延迟时间为 4 毫秒 - Node.js 中:
setTimeout(fn, 0)实际上会被转换为setTimeout(fn, 1) - 执行顺序取决于事件循环当前所处的阶段
- 如果事件循环刚好进入 poll 阶段,setImmediate 会先执行
- 如果事件循环在 timers 阶段,setTimeout 可能先执行
注意: 即使设置了 0 毫秒的延迟,回调函数也不会立即执行,而是会被放入宏任务队列,等待下一轮事件循环执行。
但在 I/O 回调中是确定的:
1 | const fs = require('fs'); |
输出:
1 | setImmediate |
原因: I/O 回调在 poll 阶段执行,执行完后进入 check 阶段,所以 setImmediate 先执行。
五、浏览器 vs Node.js 事件循环对比
| 特性 | 浏览器 | Node.js(11+) |
|---|---|---|
| 宏任务 | setTimeout, setInterval, requestAnimationFrame, I/O | setTimeout, setInterval, setImmediate, I/O |
| 微任务 | Promise.then, async/await, MutationObserver | Promise.then, async/await, process.nextTick |
| 微任务执行时机 | 每个宏任务后 | 每个宏任务后(与浏览器一致) |
| UI 渲染 | 有 | 无 |
| 阶段划分 | 简单 | 6 个阶段 |
六、常见面试题
面试题 1:什么是事件循环?
回答要点:
- JavaScript 是单线程的
- 事件循环让单线程的 JS 能处理异步操作
- 执行栈 + 任务队列(宏任务 + 微任务)
- 执行规则:一个宏任务,一批微任务
面试题 2:宏任务和微任务的区别?
回答要点:
- 宏任务:setTimeout, setInterval, I/O 等
- 微任务:Promise.then, async/await 等
- 微任务优先级更高
- 一个宏任务执行完,要清空所有微任务
面试题 3:代码输出题
1 | console.log('1'); |
输出:
1 | 1 |
七、最佳实践
7.1 避免阻塞主线程
主线程阻塞的危害:
- 页面卡顿,用户体验差
- 动画不流畅
- 事件响应延迟
解决方案:
1. 分批处理大型任务
1 | // ❌ 不好的做法:一次性处理大量数据 |
2. 使用 Web Worker
对于计算密集型任务,使用 Web Worker 可以完全避免阻塞主线程:
1 | // 主线程 |
7.2 合理使用微任务
微任务的优势:
- 执行时机早,在 UI 渲染前完成
- 适合处理 DOM 变化
- 可以批量处理操作
1. 使用 queueMicrotask 优化
1 | // ✅ 使用 queueMicrotask 优化 |
2. 批量处理 DOM 操作
1 | // ✅ 批量处理 DOM 操作 |
7.3 优化定时器
定时器的注意事项:
setTimeout(fn, 0)有最小延迟(浏览器 4ms,Node.js 1ms)- 定时器回调的执行时间不确定
- 过多的定时器会影响性能
1. 使用 requestAnimationFrame 进行动画
1 | // ✅ 使用 requestAnimationFrame 进行动画 |
2. 合并定时器
1 | // ❌ 不好的做法:多个独立定时器 |
7.4 事件循环与异步编程模式
1. 使用 async/await 简化异步代码
1 | // ✅ 使用 async/await 简化异步代码 |
2. 合理使用 Promise.all
1 | // ✅ 使用 Promise.all 并行处理多个异步操作 |
7.5 性能优化建议
1. 减少微任务的数量
微任务过多的问题:
- 可能会延迟 UI 渲染
- 影响页面响应速度
解决方案:
- 合并微任务
- 避免在微任务中执行复杂操作
- 对于非紧急操作,使用宏任务
2. 避免长任务
长任务的定义: 执行时间超过 50ms 的任务
解决方案:
- 分解长任务为多个短任务
- 使用 Web Worker 处理计算密集型任务
- 使用 requestIdleCallback 处理非紧急任务
3. 合理使用 requestIdleCallback
1 | // ✅ 使用 requestIdleCallback 处理非紧急任务 |
4. 优化事件监听器
1 | // ❌ 不好的做法:频繁触发的事件监听器 |
八、总结
8.1 核心知识点
- 为什么是单线程:DOM 操作的互斥性
- 执行栈:后进先出,存储正在执行的函数
- 任务队列:宏任务 + 微任务
- 执行规则:
- 一个宏任务
- 清空所有微任务(包括新产生的)
- (可能)UI 渲染
- 循环
8.2 记忆口诀
浏览器:
先同步,再异步;先微任务,再宏任务。
Node.js 11+:
和浏览器一致,一个宏任务,一批微任务。
事件循环是 JavaScript 的精髓,理解它能让你写出更高效、更优雅的异步代码!