今天想和大家聊聊一个在面试中几乎必问,但在实际工作中又容易被忽略的话题——Event Loop(事件循环)。
你可能在面试时被问过:“JavaScript 是单线程的,那它是怎么实现异步的?”或者“setTimeout(fn, 0)
真的是立即执行吗?”这些问题的答案,都藏在 Event Loop 里。
别担心,即使你是第一次接触这个概念,我也尽量用最通俗的方式,带你一步步搞懂它。我们不堆术语,不讲玄学,只说人话。
一、JavaScript 的“单线程”到底是什么意思?
我们常说 JavaScript 是“单线程”的,这句话到底意味着什么?
你可以把 JavaScript 的执行环境想象成一个只有一位服务员的快餐店。这位服务员(也就是主线程)一次只能处理一件事:点餐、做汉堡、收钱……他不能同时做两件事。
比如,你让服务员“做一份汉堡,同时给我一杯可乐”,他只能先做汉堡,再倒可乐,或者反过来。他不能一边煎肉饼一边倒饮料。
在代码里,这就像这样:
console.log("第一步");
console.log("第二步");
setTimeout(() => {
console.log("第三步(异步)");
}, 1000);
console.log("第四步");
输出结果是:
第一步
第二步
第四步
第三步(异步)
你看,setTimeout
虽然写在第三步,但它并没有立刻执行,而是被“推迟”了。为什么?因为主线程要先把当前的任务做完,才能回头处理它。
这就是“单线程”的核心:同一时间,只能做一件事。
二、那异步是怎么实现的?总不能一直卡着吧?
既然 JS 是单线程的,那像 setTimeout
、fetch
、addEventListener
这些异步操作是怎么做到不阻塞主线程的呢?
答案是:它们不是 JS 自己做的,而是浏览器(或 Node.js 环境)帮我们做的。
继续用快餐店的比喻:
- 服务员(JS 主线程)负责点单和出餐。
- 但厨房里的烤箱、冰箱、饮料机……这些是“浏览器提供的能力”。
- 当你点了一个汉堡,服务员不会自己去煎,而是把订单交给厨房(异步任务),然后继续服务下一位顾客。
- 等厨房做好了,会通知服务员:“你的汉堡好了”,服务员再把汉堡端给你。
在技术上,这个“通知”机制就是通过 任务队列(Task Queue) 实现的。
三、Event Loop 的三大核心:调用栈、任务队列、事件循环
要理解 Event Loop,你需要知道三个关键角色:
1. 调用栈(Call Stack)
这是 JS 执行函数的地方。你可以把它想象成一个“待办事项清单”,从上到下依次执行。
比如这段代码:
function a() {
b();
console.log("a 执行完了");
}
function b() {
console.log("b 开始执行");
}
a();
执行过程就像这样:
a()
被推入调用栈a
里面调用 b()
,b()
被推入栈b()
执行完,从栈中弹出a()
继续执行,打印“a 执行完了”,然后弹出
调用栈是“同步任务”的执行场所。
2. 任务队列(Task Queue / Callback Queue)
当异步任务(比如 setTimeout
、setInterval
、DOM 事件
、Ajax 请求
)完成时,它们的回调函数不会立刻执行,而是被放进一个“等待区”——这就是任务队列。
任务队列是一个先进先出(FIFO) 的队列。先进来的回调,先被执行。
3. 事件循环(Event Loop)
这才是真正的“调度员”。它的工作非常简单:
不断检查调用栈是否为空。如果为空,就从任务队列里取出第一个回调,推入调用栈执行。
就这么简单!它像个永不停歇的循环,一直盯着:
- “栈空了吗?”
- “空了?好,看看队列里有没有任务。”
- “有?拿一个过来执行。”
这就是“事件循环”名字的由来:它在循环地处理事件(回调)。
四、宏任务 vs 微任务:你必须知道的细节
到这里,你以为 Event Loop 就完了?不,还有一个更精细的划分:宏任务(Macrotask)和微任务(Microtask)。
1. 宏任务(Macrotask)
常见的宏任务包括:
setTimeout
setInterval
setImmediate
(Node.js)- I/O 操作
- UI 渲染(浏览器)
script
标签中的整体代码
2. 微任务(Microtask)
微任务的优先级更高,常见的有:
Promise.then/catch/finally
MutationObserver
(监听 DOM 变化)queueMicrotask()
process.nextTick()
(Node.js)
关键区别:执行时机
Event Loop 的执行顺序是这样的:
- 执行一个宏任务(比如整个
script
代码) - 执行过程中,遇到异步操作,把回调放进对应的队列:
setTimeout
→ 宏任务队列Promise.then
→ 微任务队列
- 当前宏任务执行完,立即清空微任务队列(全部执行完)
- 然后去宏任务队列取下一个宏任务
- 重复这个过程
举个例子,彻底搞懂
来看这段经典代码:
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
Promise.resolve().then(() => {
console.log("3");
});
console.log("4");
输出顺序是什么?
我们一步步分析:
执行全局脚本(宏任务)
- 打印 "1"
- 遇到
setTimeout
,把回调 () => console.log("2")
放入宏任务队列 - 遇到
Promise.then
,把回调 () => console.log("3")
放入微任务队列 - 打印 "4"
- 当前宏任务执行完毕
清空微任务队列
- 执行
Promise.then
的回调,打印 "3"
取下一个宏任务
所以输出是:1 → 4 → 3 → 2
注意:setTimeout(fn, 0)
并不是“立即执行”,而是“等当前所有同步和微任务执行完后,再执行”。
五、更复杂的例子:嵌套 Promise 和 setTimeout
再看一个稍微复杂点的例子:
console.log("start");
setTimeout(() => {
console.log("timeout1");
Promise.resolve().then(() => {
console.log("promise in timeout");
});
}, 0);
Promise.resolve().then(() => {
console.log("promise1");
setTimeout(() => {
console.log("timeout in promise");
}, 0);
});
console.log("end");
来,我们一步步走:
执行全局宏任务:
- 打印 "start"
setTimeout
→ 宏任务队列Promise.then
→ 微任务队列- 打印 "end"
- 宏任务结束
清空微任务队列:
- 执行
Promise.then
,打印 "promise1" - 在
then
里又遇到 setTimeout
,把它加入宏任务队列 - 微任务队列清空
取下一个宏任务(第一个 setTimeout
):
- 执行,打印 "timeout1"
- 遇到
Promise.then
,加入微任务队列
清空微任务队列:
- 执行
Promise.then
,打印 "promise in timeout"
取下一个宏任务(Promise.then
里的 setTimeout
):
- 执行,打印 "timeout in promise"
最终输出:
start
end
promise1
timeout1
promise in timeout
timeout in promise
是不是有点绕?多看几遍,画个流程图,就清楚了。
六、为什么要有微任务?它有什么用?
你可能会问:既然有宏任务就够了,为啥还要搞个微任务?
答案是:为了更精细的控制和性能优化。
比如:
- Promise 的链式调用:
.then().then().then()
,我们希望这些回调能尽快执行,而不是等一轮完整的 Event Loop。 - 避免 UI 卡顿:微任务在当前任务结束后立即执行,不会触发页面重绘,适合做数据更新、状态同步等操作。
- DOM 观察:
MutationObserver
用微任务来批量处理 DOM 变化,避免频繁重排。
简单说:微任务 = 高优先级、立即执行的小任务。
七、实际开发中的影响
理解 Event Loop 不只是应付面试,它对实际开发也有帮助。
1. 避免长时间同步任务阻塞 UI
for (let i = 0; i < 1000000; i++) {
}
这种长时间运行的同步代码会阻塞 Event Loop,导致页面无响应。
解决方案:拆分成小任务,用 setTimeout
或 requestIdleCallback
分批执行。
2. 正确处理异步依赖
let data;
fetch("/api/data").then(res => res.json()).then(d => data = d);
console.log(data);
因为 fetch
是异步的,console.log
是同步的,它先执行了。
正确做法:用 async/await
或确保在回调中使用数据。
3. setTimeout(fn, 0)
的用途
虽然它不是“立即执行”,但可以用来:
- 将任务推迟到下一轮 Event Loop
- 让 UI 有机会先更新
- 实现简单的“异步批处理”
let queue = [];
function addTask(task) {
queue.push(task);
setTimeout(processQueue, 0);
}
function processQueue() {
if (queue.length > 0) {
queue.forEach(task => task());
queue = [];
}
}
八、总结:Event Loop 的完整流程
最后,我们来梳理一下浏览器中 Event Loop 的完整流程:
- 执行一个宏任务(如整个
script
) - 执行过程中:
- 遇到
setTimeout
→ 加入宏任务队列 - 遇到
Promise.then
→ 加入微任务队列 - 遇到 DOM 事件 → 加入宏任务队列
- 当前宏任务执行完毕
- 立即执行所有微任务(清空微任务队列)
- 尝试渲染页面(如果需要)
- 取下一个宏任务,回到第1步
记住这个口诀:
一个宏任务,清空微任务,再来下一个宏任务。
写在最后
Event Loop 是 JavaScript 异步编程的基石。它看似复杂,但核心思想很简单:用一个循环不断检查任务队列,按顺序执行任务。
转自https://juejin.cn/post/7534907614394482723
该文章在 2025/8/8 10:56:38 编辑过