JavaScript事件循环机制
核心:JavaScript 是单线程语言,通过事件循环(Event Loop)机制实现异步操作,避免代码阻塞,让页面保持响应。
一、什么是事件循环?
事件循环是 JavaScript 运行时的核心机制,负责协调代码执行、事件处理、异步任务的调度。简单来说:
- 单线程:JavaScript 一次只能做一件事
- 非阻塞:通过异步机制,不会因为等待而卡住
- 循环调度:持续检查任务队列,按优先级执行
想象一个餐厅服务员(JavaScript 引擎):他一次只能服务一桌客人(单线程),但可以先接单、再上菜、中间去收盘子(异步),通过合理安排顺序(事件循环),让所有客人都不用等太久。
二、核心组成部分
2.1 调用栈(Call Stack)
存放正在执行的代码。JavaScript 引擎逐行执行代码,函数调用会入栈,执行完毕就出栈。栈空了才会去检查任务队列。
2.2 宏任务(Macrotask)
浏览器级别的异步任务,每次事件循环只执行一个宏任务。
| 宏任务类型 | 说明 | 示例 |
|---|---|---|
setTimeout |
延迟执行 | setTimeout(() => {}, 1000) |
setInterval |
定时执行 | setInterval(() => {}, 1000) |
DOM 事件 |
用户交互 | button.onclick |
AJAX 请求 |
网络请求 | XMLHttpRequest |
script 标签 |
整段脚本 | <script>...</script> |
2.3 微任务(Microtask)
JavaScript 引擎级别的异步任务,优先级高于宏任务。每次宏任务执行完,会清空所有微任务。
| 微任务类型 | 说明 | 示例 |
|---|---|---|
Promise.then/catch/finally |
Promise 回调 | promise.then(() => {}) |
MutationObserver |
DOM 变化监听 | observer.observe() |
queueMicrotask |
手动添加微任务 | queueMicrotask(() => {}) |
async/await |
语法糖(底层是 Promise) | await fetch() |
三、事件循环执行流程
遇到函数调用就入栈] A --> B{调用栈是否为空?} B -->|否| A B -->|是| C[检查微任务队列] C --> D{有微任务?} D -->|是| E[执行一个微任务] E --> F{执行过程中产生新微任务?} F -->|是| E F -->|否| D D -->|否| G[检查宏任务队列] G --> H{有宏任务?} H -->|是| I[取出一个宏任务执行] I --> A H -->|否| J([等待新任务]) J --> C style A fill:#409EFF,stroke:#409EFF,color:#FFFFFF style E fill:#67C23A,stroke:#67C23A,color:#FFFFFF style I fill:#E6A23C,stroke:#E6A23C,color:#FFFFFF
四、宏任务 vs 微任务执行顺序
关键规则:每执行完一个宏任务,必须清空所有微任务,再执行下一个宏任务。这保证了 Promise 等微任务的响应速度快于定时器等宏任务。
五、实战案例:代码执行过程可视化
经典面试题
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
});
}, 0);
Promise.resolve().then(() => {
console.log('4');
setTimeout(() => {
console.log('5');
}, 0);
});
console.log('6');
同步代码] B --> C[注册 setTimeout
回调函数到宏任务队列] C --> D[注册 Promise.then
回调函数到微任务队列] D --> E[输出 '6'
同步代码] E --> F[同步代码执行完毕] F --> G[执行微任务:Promise.then] G --> H[输出 '4'] H --> I[注册新的 setTimeout
到宏任务队列] I --> J[微任务队列清空] J --> K[执行宏任务1:setTimeout] K --> L[输出 '2'] L --> M[注册新的 Promise.then
到微任务队列] M --> N[执行微任务:Promise.then] N --> O[输出 '3'] O --> P[微任务队列清空] P --> Q[执行宏任务2:setTimeout] Q --> R[输出 '5'] R --> S[结束] style B fill:#409EFF,stroke:#409EFF,color:#FFFFFF style E fill:#409EFF,stroke:#409EFF,color:#FFFFFF style G fill:#67C23A,stroke:#67C23A,color:#FFFFFF style K fill:#E6A23C,stroke:#E6A23C,color:#FFFFFF style N fill:#67C23A,stroke:#67C23A,color:#FFFFFF style Q fill:#E6A23C,stroke:#E6A23C,color:#FFFFFF
正确输出顺序:1 → 6 → 4 → 2 → 3 → 5
执行步骤拆解
| 步骤 | 操作 | 输出 | 队列状态 |
|---|---|---|---|
| 1 | 执行同步代码 | 1, 6 | 微任务:[Promise4] 宏任务:[setTimeout2] |
| 2 | 清空微任务 | 4 | 微任务:[] 宏任务:[setTimeout2, setTimeout5] |
| 3 | 执行宏任务1 | 2 | 微任务:[Promise3] 宏任务:[setTimeout5] |
| 4 | 清空微任务 | 3 | 微任务:[] 宏任务:[setTimeout5] |
| 5 | 执行宏任务2 | 5 | 微任务:[] 宏任务:[] |
六、常见问题与注意事项
Q1: setTimeout(fn, 0) 真的会立即执行吗?
不会!即使延迟设为 0,回调函数也会被放入宏任务队列,等待当前同步代码和所有微任务执行完后才会执行。实际延迟通常在 4ms 左右(浏览器最小延迟限制)。
Q2: async/await 是宏任务还是微任务?
微任务!async/await 是 Promise 的语法糖。await 后面的代码相当于 Promise.then() 回调,会被添加到微任务队列。
Q3: 微任务会无限循环吗?
function recursiveMicrotask() {
Promise.resolve().then(() => {
console.log('微任务');
recursiveMicrotask(); // 递归调用
});
}
recursiveMicrotask();
会!微任务产生新微任务会在本轮清空,导致宏任务永远无法执行,页面卡死。这种情况会触发浏览器的保护机制强制中断。
七、总结
| 概念 | 特点 | 典型场景 | 执行时机 |
|---|---|---|---|
| 调用栈 | 同步执行,LIFO | 普通函数调用 | 立即执行 |
| 微任务 | 高优先级,批量清空 | Promise、async/await | 每个宏任务后 |
| 宏任务 | 低优先级,单次执行 | setTimeout、事件监听 | 微任务清空后 |
记忆口诀:同步代码先执行,微任务紧跟其后,宏任务排队等候。每次宏任务后都要清空微任务,如此循环往复,JavaScript 虽是单线程,却能高效处理异步任务。