关于事件循环被问了很多次,也遇到过很多次,一直没有系统整理,网上搜的,基本明白但总感觉不够透彻,最后,自己动手,丰衣足食,哈哈
一、为什么需要事件循环?—— 单线程的困境
JavaScript 作为浏览器的核心脚本语言,从诞生起就被设计为单线程执行模式。这意味着同一时间只能处理一个任务,比如执行一段计算代码、响应一次点击事件、解析一段 JSON 数据。
这种设计带来了两个核心问题:
-
阻塞风险:如果某个任务耗时过长(如复杂计算),会导致整个程序卡住
-
异步需求:网络请求、定时器、用户输入等异步操作无法直接同步处理
为了解决这些问题,JavaScript 引入了事件循环(Event Loop)机制,它就像一个后台管家,负责协调异步任务的执行顺序,让单线程环境也能高效处理复杂的异步场景。
二、事件循环的核心组成部分
要理解事件循环,需要先掌握三个关键概念(以浏览器环境为例):
1. 调用栈(Call Stack)
-
作用:记录当前正在执行的函数调用栈
-
特点:遵循 "后进先出" 原则,主线程直接操作调用栈
-
示例:当执行a(); b();时,调用栈会先压入 a 函数,执行完弹出,再压入 b 函数
2. 任务队列(Task Queue)
-
作用:存放异步操作完成后的回调任务(宏任务)
- 常见类型:
-
setTimeout/setInterval定时器回调
-
原生 Promise 的then/catch/finally(注意:Promise 构造函数是同步执行!)
-
I/O 操作(如 Fetch 网络请求回调)
-
UI 渲染任务
3. 微任务队列(Microtask Queue)
-
作用:存放需要尽快执行的高优先级异步任务
- 关键特性:
-
- 优先级高于任务队列中的宏任务
-
- 每次事件循环开始前会先清空微任务队列
- 常见类型:
-
- Promise.then()回调(注意:async/await本质也是 Promise)
-
- MutationObserver(DOM 变化监听)
-
- Node.js 中的process.nextTick(优先级更高)
三、事件循环的执行流程 —— 阶段解析图
**
(图示说明:箭头表示执行顺序,队列存放对应任务)
核心执行步骤:
- 初始化阶段:创建调用栈、任务队列、微任务队列
- 执行同步代码:主线程先执行全局作用域中的同步代码
- 处理微任务:
-
- 按加入顺序执行微任务队列中的所有任务
-
- 每次执行微任务时可能会向队列中添加新的微任务(递归处理)
- 执行宏任务:
-
- 从任务队列中取出一个最旧的宏任务(先进先出)
-
- 将其回调放入调用栈执行
- 渲染阶段(浏览器环境特有):
-
- 检查是否有 UI 更新任务(如requestAnimationFrame)
-
- 合并多次 DOM 操作,执行最终渲染
- 循环往复:重复步骤 3-5,直到所有队列清空
关键规则:
- 微任务优先:每次事件循环开始前必先处理完所有微任务
- 宏任务排队:同一类型的宏任务按创建顺序执行
- 阶段切换:浏览器和 Node.js 的具体阶段划分略有不同(后文详述)
四、宏任务 vs 微任务:用代码看差异
console.log('同步代码1');
setTimeout(() => {
console.log('定时器回调(宏任务)');
}, 0);
Promise.resolve()
.then(() => {
console.log('Promise微任务1');
return Promise.resolve(); // 新增微任务
})
.then(() => {
console.log('Promise微任务2');
});
console.log('同步代码2');
执行顺序解析:
- 同步代码执行:输出 "同步代码 1" → "同步代码 2"
- 微任务队列:两个then回调加入队列
- 处理微任务:
-
- 执行第一个then:输出 "微任务 1",新增第二个then到队列
-
- 执行第二个then:输出 "微任务 2"
- 处理宏任务:定时器回调执行,输出 "定时器回调"
核心结论:微任务会在当前同步代码执行完毕后,立即在同一事件循环中执行,而宏任务需要等待下一次事件循环。
五、浏览器 vs Node.js:事件循环的差异
虽然核心机制相同,但具体阶段划分存在差异:
浏览器环境(重点阶段):
- Timer 阶段:处理setTimeout/setInterval回调
- I/O 阶段:处理网络请求、文件操作等 I/O 回调
- Idle/Prepare 阶段:Node.js 特有,浏览器无此阶段
- Poll 阶段:处理 I/O 完成后的回调,控制事件循环节奏
- Check 阶段:处理setImmediate(Node.js)或requestIdleCallback(浏览器)
- Close 阶段:处理close事件回调(如 TCP 连接关闭)
Node.js 环境(v11 + 阶段划分):
┌───────────────────┐
│ timers │ (处理setTimeout/setInterval)
├────────────┬──────────┤
│ I/O callbacks │ (处理网络、文件I/O回调)
├────────────┼──────────┤
│ idle/prepare │ (内部使用,用户不可操作)
├────────────┼──────────┤
│ poll │ (等待I/O事件,处理新的I/O回调)
├────────────┼──────────┤
│ check │ (处理setImmediate回调)
└────────────┴──────────┘
close callbacks (处理close事件)
关键差异:
- Node.js 的process.nextTick微任务优先级高于所有阶段
- 浏览器的事件循环与 UI 渲染强绑定(如每秒 60 次的requestAnimationFrame)
六、实战应用:如何利用事件循环优化代码
1. 避免长时间阻塞主线程
// 错误示例:同步计算阻塞主线程
function heavyCalculation() {
for(let i=0; i<10086; i++) {} // 耗时操作
}
// 正确做法:分解任务到宏任务队列
function splitTask() {
setTimeout(heavyCalculation, 0); // 下一次事件循环执行
// 或使用requestIdleCallback(空闲时执行)
}
2. 理解异步代码执行顺序
async function asyncDemo() {
console.log('async函数开始');
await Promise.resolve(); // 相当于then回调
console.log('await之后');
}
console.log('同步代码');
asyncDemo();
console.log('async调用结束');
// 输出顺序:
// 同步代码 → async函数开始 → async调用结束 → await之后(微任务阶段)
3. 处理海量数据时的性能优化
- 使用setImmediate(Node.js)或setTimeout(0ms)拆分批量操作
- 利用微任务处理高优先级回调(如状态更新后的立即通知)
七、总结:事件循环的本质与价值
事件循环的本质是单线程环境下的异步协调机制,它通过以下方式解决异步难题:
- 任务分类:通过宏任务 / 微任务队列实现优先级管理
- 循环处理:通过不断往返于 "执行 - 队列处理" 实现异步任务调度
- 阶段控制:在浏览器和 Node.js 中通过不同阶段划分实现场景优化
理解事件循环,就能真正掌握 JavaScript 异步编程的核心:
- 为什么Promise.then比setTimeout先执行?
- async/await背后的执行机制是什么?
- 如何避免 UI 卡顿或 Node.js 事件循环阻塞?
下次遇到异步代码执行顺序问题时,不妨在脑海中画出这个循环图:同步代码→微任务→宏任务→渲染,按照这个顺序拆解,复杂问题就能迎刃而解。
(全文完,欢迎在评论区讨论事件循环的实际应用场景~)