彻底搞懂 JavaScript 事件循环
一篇让你再也不会被"执行顺序"面试题绊倒的文章。
先解决一个根本问题:为什么需要事件循环?
JavaScript 是单线程语言。这意味着同一时间只能执行一个任务。
你可能会问:那异步操作怎么办?网络请求、定时器、用户点击------难道要全部阻塞主线程?
如果真是这样,点一个按钮页面就卡死了,浏览器直接弹出"页面未响应"。
事件循环(Event Loop) ,就是 JavaScript 在单线程限制下,实现"非阻塞异步"的核心机制。它的本质是:
通过任务队列,制造出"并发执行"的假象。
三个核心组件
| 组件 | 数据结构 | 存放内容 | 执行时机 |
|---|---|---|---|
| 调用栈 Call Stack | LIFO(后进先出) | 同步函数调用帧 | 立即执行 |
| 宏任务队列 Macrotask | FIFO(先进先出) | setTimeout、I/O、UI渲染等 | 栈清空后,每次取一个 |
| 微任务队列 Microtask | FIFO(先进先出) | Promise.then、MutationObserver 等 | 每次宏任务结束后,全部清空 |
记住一句话:微任务优先级高于宏任务,且每轮必须全部清空。
事件循环的完整执行流程
① 执行一个宏任务(如整体 script 代码)
↓
② 执行过程中遇到异步操作:
→ 宏任务 → 放入宏任务队列
→ 微任务 → 放入微任务队列
↓
③ 当前宏任务执行完毕,调用栈清空
↓
④ ✅ 检查微任务队列 → 依次执行所有微任务(包括微任务产生的新微任务)
↓
⑤ 如有必要,执行 UI 渲染(浏览器环境)
↓
⑥ ❌ 取下一个宏任务执行
↓
⑦ 回到步骤③,循环往复
执行顺序口诀:同步代码 → 微任务清空 → 宏任务一个 → 循环。
一张表看清优先级
| 操作 | 队列 | 优先级 | 执行时机 |
|---|---|---|---|
| console.log(同步) | 调用栈 | 最高 | 立即执行 |
| Promise.then / catch / finally | 微任务 | 高 | 当前宏任务结束后立即执行 |
| async/await 的后续代码 | 微任务 | 高 | await 暂停处的后续代码入微任务队列 |
| queueMicrotask | 微任务 | 高 | 同上 |
| MutationObserver | 微任务 | 高 | 同上 |
| setTimeout / setInterval | 宏任务 | 中 | 下一轮事件循环 |
| I/O 操作 | 宏任务 | 中 | 下一轮事件循环 |
| UI 渲染(requestAnimationFrame) | 宏任务 | 中 | 浏览器每帧渲染前 |
| setImmediate(Node.js) | 宏任务 | 中 | Node 特殊阶段 |
| process.nextTick(Node.js) | 独立队列 | 超高 | 优先于微任务 |
用代码验证一切
案例 1:最经典的问题
javascript
javascript
console.log('1. 同步开始');
setTimeout(() => {
console.log('5. setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('3. Promise');
});
console.log('2. 同步结束');
输出:1 → 2 → 3 → 5
解析:
1、2是同步代码,立即执行setTimeout回调入宏任务队列Promise.then回调入微任务队列- 同步代码执行完毕 → 先清空微任务(输出
3)→ 再取宏任务(输出5)
案例 2:微任务嵌套宏任务
javascript
javascript
console.log('script start');
setTimeout(() => {
console.log('setTimeout 1');
Promise.resolve().then(() => console.log('promise in setTimeout'));
}, 0);
Promise.resolve().then(() => console.log('promise 1')).then(() => console.log('promise 2'));
console.log('script end');
输出:script start → script end → promise 1 → promise 2 → setTimeout 1 → promise in setTimeout
关键规则:在执行完一个宏任务后,会再次清空所有微任务。 所以 promise in setTimeout 不会等到下一轮宏任务,而是在当前宏任务(setTimeout 1)执行完后立即执行。
案例 3:async/await 的真相
javascript
javascript
async function async1() {
console.log('2. async1 start');
await async2();
console.log('6. async1 end'); // ← 这部分是微任务
}
async function async2() {
console.log('3. async2');
}
console.log('1. script start');
setTimeout(() => console.log('8. setTimeout'), 0);
async1();
new Promise(resolve => {
console.log('4. Promise executor');
resolve();
}).then(() => console.log('7. Promise then'));
console.log('5. script end');
输出:1 → 2 → 3 → 4 → 5 → 6 → 7 → 8
await 的本质:async 函数中 await 之后的代码,等价于 Promise.then,被放入微任务队列。
浏览器 vs Node.js:有什么不同?
| 维度 | 浏览器 | Node.js |
|---|---|---|
| 实现依据 | HTML 规范 | libuv 库 |
| 阶段划分 | 相对简单 | 6 个阶段:timers → pending callbacks → poll → check → close callbacks → timers |
| 特有 API | requestAnimationFrame、MutationObserver | process.nextTick、setImmediate |
| setImmediate vs setTimeout(fn,0) | 无此 API | I/O 未完成时 setImmediate 可能更快,否则顺序不确定 |
但核心规则一致:微任务永远在宏任务之前清空。
三条铁律(面试够用了)
规则 1:微任务优先
每执行完一个宏任务,必须清空所有微任务。微任务执行期间产生的新微任务,会在本次循环中继续执行。
规则 2:async/await = Promise.then
await 之后的代码是微任务,await 之前的代码是同步执行。
规则 3:宏任务多个来源,微任务只有一个队列
宏任务可能来自定时器、I/O、UI 渲染等不同来源,各自排队;微任务只有一个队列,按入队顺序执行。
写在最后
事件循环并不复杂,它就是一个不断检查调用栈是否为空、然后按优先级取任务执行的死循环。
抓住两个关键词就够了:
- 宏任务 vs 微任务
- 调用栈清空后,先清微任务
剩下的,都是这两条规则的组合应用。
下次再遇到"这段代码输出什么"的问题,画一张调用栈 + 两个队列的图,答案自己就出来了。