JavaScript 事件循环完全理解
彻底搞懂 Event Loop、Call Stack、微任务和宏任务,面试不再慌。 · 难度:进阶 · +20XP
为什么 setTimeout 0 不是立即执行?
你一定见过这段面试题:
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
// 输出:1 → 3 → 2
如果你以为 setTimeout(fn, 0) 会"立即"执行,那输出应该是 1→2→3。但实际是 1→3→2。为什么?答案就在事件循环里。
JavaScript 是单线程的
JS 引擎只有一个调用栈(Call Stack),一次只能做一件事。你可以把它想象成一个只有一部电梯的大楼——一次只能送一个人,后面的人必须排队。
function a() { b(); }
function b() { c(); }
function c() { console.log('到顶楼了'); }
a();
调用栈的变化:
- 调用
a()→ 栈: [a] - a 里调用
b()→ 栈: [a, b] - b 里调用
c()→ 栈: [a, b, c] - c 执行完返回 → 栈: [a, b]
- b 返回 → 栈: [a]
- a 返回 → 栈: [](清空)
Web APIs 和任务队列
但 JavaScript 不只是调用栈。浏览器还提供了 Web APIs(setTimeout、fetch、DOM 事件等)和任务队列(Task Queue)。
完整的流程图:
- 同步代码 → 进入调用栈 → 立即执行
- 遇到
setTimeout→ 交给浏览器的定时器线程 → 定时器到期后,回调进入宏任务队列 - 遇到
Promise.then→ 回调进入微任务队列 - 调用栈清空后 → 先清空微任务队列 → 再从宏任务队列取一个执行 → 重复
现在回到开头的例子,答案就清楚了:
console.log('1'); // ① 同步,立即输出 1
setTimeout(() => console.log('2'), 0); // ② 交给定时器,0ms 后回调进入宏任务队列
console.log('3'); // ③ 同步,立即输出 3
// ④ 调用栈空了 → 微任务队列也是空的 → 从宏任务队列取出 setTimeout 回调
// ⑤ 输出 2
微任务 vs 宏任务
| 类型 | 包含 | 优先级 |
|---|---|---|
| 微任务 (Microtask) | Promise.then/catch/finally、queueMicrotask()、MutationObserver | 🔥 高(每轮事件循环先清空) |
| 宏任务 (Macrotask) | setTimeout、setInterval、setImmediate(Node)、I/O、UI 渲染 | 低(每轮只取一个) |
经典面试题:
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('end');
// 输出:start → end → promise → timeout
解释:Promise.then 是微任务,优先于 setTimeout(宏任务)。所以 promise 在 timeout 之前输出。
进阶:微任务中的微任务
Promise.resolve().then(() => {
console.log('1');
Promise.resolve().then(() => console.log('2'));
});
Promise.resolve().then(() => console.log('3'));
// 输出:1 → 3 → 2
关键规则:事件循环会清空所有微任务,包括在微任务执行过程中新添加的微任务。所以 1 输出后,3 已经在队列里了,先输出 3,再输出 2。
requestAnimationFrame 的位置
requestAnimationFrame(rAF)在渲染之前执行。完整的执行顺序:
- 执行一个宏任务
- 清空所有微任务
- 执行 rAF 回调
- 浏览器渲染
- 回到步骤 1
动手试试
- 基础练习:写一段代码,用 setTimeout 实现"每隔 1 秒输出一个数字"的计数器,从 1 到 5
- 进阶应用:写一段代码包含 2 个 setTimeout、3 个 Promise.then、1 个同步 console.log,预测输出顺序,然后运行验证
- 项目实战:在你的项目中找到一段加载数据后更新 DOM 的代码,用
queueMicrotask或Promise确保数据更新在 DOM 渲染前完成
接下来学什么?
掌握了事件循环,下一课学习 JavaScript Promise 从入门到精通——理解 then/catch/finally 的链式调用、Promise.all/race/allSettled 的实战场景。
📖 译自 freeCodeCamp JavaScript Event Loop Explained (CC BY 4.0),有改编和扩充。