JavaScript 的事件循环(Event Loop)是其实现异步编程的核心机制。即使 JavaScript 是单线程的,事件循环允许它处理非阻塞 I/O 操作、定时器、网络请求等异步任务。以下是详细解释:
核心概念
-
调用栈(Call Stack)
- 一个后进先出(LIFO)的结构,用于跟踪当前正在执行的函数。
- 当函数被调用时,它会被推入栈顶;执行完毕后弹出。
-
堆(Heap)
- 内存中存储对象(如变量、函数等)的区域。
-
任务队列(Task Queue)
- 一个先进先出(FIFO)的队列,用于存放待处理的异步任务回调。
- 分为两种类型:
- 宏任务队列(MacroTask Queue):如
setTimeout
、setInterval
、DOM 事件、I/O 操作等。 - 微任务队列(MicroTask Queue):如
Promise.then()
、MutationObserver
、queueMicrotask()
。
- 宏任务队列(MacroTask Queue):如
事件循环的工作流程
事件循环按以下步骤循环执行:
-
执行同步代码
- 所有同步代码(调用栈中的任务)会优先执行,直到调用栈清空。
-
处理微任务队列
- 每次调用栈清空后,事件循环会立即依次执行微任务队列中的所有任务,直到队列为空。
- 微任务具有高优先级,会在下一个宏任务执行前被处理。
-
处理宏任务队列
- 从宏任务队列中取出一个任务(如最早的
setTimeout
回调),推入调用栈执行。 - 执行过程中可能产生新的微任务或宏任务。
- 从宏任务队列中取出一个任务(如最早的
-
重复循环
- 重复步骤 1~3,直到所有队列为空。
关键规则
-
微任务优先于宏任务
- 每次事件循环迭代中,微任务会全部执行完毕,才会处理下一个宏任务。
setTimeout(() => console.log('宏任务'), 0); Promise.resolve().then(() => console.log('微任务')); // 输出顺序:微任务 → 宏任务
-
渲染(UI Update)的时机
- 浏览器会在微任务队列清空后、下一个宏任务执行前进行页面渲染。
-
避免阻塞事件循环
- 长时间运行的同步代码会阻塞事件循环,导致页面卡顿。
比喻理解
家庭版事件循环
角色设定:
- 你:JavaScript 主线程(单线程,只能同时做一件事)
- 你媳妇:事件循环的监督者(负责调度任务)
- 宏任务:你想玩的游戏(比如一局 LOL,需要较长时间)
- 微任务:琐碎的家务(必须立即完成的小事,比如洗碗、倒垃圾)
规则细化:
-
初始状态:
- 你坐在电脑前,游戏还没开始(事件循环启动前的初始状态)。
- 你媳妇会不断检查任务清单(事件循环的持续运行)。
-
执行阶段:
-
第一步:处理「同步任务」(虽然没有直接比喻,但可以理解为):
- 你突然想起今天要交水电费,立刻起身去缴费(同步代码立即执行,比如
console.log
)。
- 你突然想起今天要交水电费,立刻起身去缴费(同步代码立即执行,比如
-
第二步:处理所有「微任务」:
- 缴费回来后,你媳妇说:“想打游戏?先做完所有家务!”
- 你开始洗碗(微任务1)→ 洗到一半发现垃圾满了 → 顺手倒垃圾(新微任务2)→ 倒完垃圾继续洗碗 → 最终所有家务完成(清空微任务队列)。
-
第三步:处理一个「宏任务」:
- 家务做完后,你终于能打一局 LOL(执行一个宏任务,比如
setTimeout
回调)。
- 家务做完后,你终于能打一局 LOL(执行一个宏任务,比如
-
第四步:循环检查:
- 打完一局后,你媳妇立刻出现:“还想再玩?再检查有没有新家务!”
- 如果这局游戏中你点了外卖(宏任务中产生新微任务,如
Promise.then
),你必须先去取外卖(处理新微任务),才能开下一局。
-
关键细节强化:
-
微任务的「插队」特性:
- 即使你正在倒垃圾(处理微任务2),突然发现猫没喂(新微任务3),也必须立刻喂猫,再继续倒垃圾。
- 只要家务没做完,游戏永远不能开始。
-
宏任务的「批处理」特性:
- 你媳妇允许你一次只打一局游戏(每次事件循环处理一个宏任务),打完必须重新检查家务。
- 如果你同时预约了晚上8点和9点的游戏(多个
setTimeout
),你媳妇会按预约时间让你一局一局玩。
-
不可抗拒的规则:
- 如果你在打游戏时(执行宏任务),又产生了新家务(微任务),必须在本局游戏结束后立刻处理,即使下一局游戏已经到预约时间。
// 类比代码 setTimeout(() => { console.log("打第一局游戏"); // 宏任务 Promise.resolve().then(() => console.log("取外卖")); // 微任务 }, 0); setTimeout(() => console.log("打第二局游戏"), 0); // 输出顺序:打第一局游戏 → 取外卖 → 打第二局游戏
比喻 vs 事件循环的对照表
比喻场景 | 事件循环机制 |
---|---|
你只能单线程做事 | JavaScript 主线程单线程执行 |
必须做完所有家务才能打游戏 | 微任务队列清空后才执行下一个宏任务 |
打一局游戏后重新检查家务 | 每次宏任务执行后重新处理微任务队列 |
做家务时产生新家务必须处理 | 微任务可嵌套,且会持续清空队列 |
总结:家庭生存法则
- 优先级:媳妇的指令(微任务) > 你的游戏(宏任务)。
- 代价:如果家务太多(微任务无限循环),你永远打不了游戏(宏任务被阻塞)。
- 启示:写代码时要像处理夫妻关系一样,避免微任务爆炸,才能让事件循环和谐运转!
核心总结
- 执行顺序优先级
- 每次事件循环(Event Loop)的一次完整迭代中:
同步代码 → 所有微任务 → 一个宏任务 → 所有微任务 → 下一个宏任务 → …
- 每次事件循环(Event Loop)的一次完整迭代中:
- 关键点
- 微任务队列会在 当前宏任务结束 后、下一个宏任务开始前 被清空。
- 如果微任务中又产生了新的微任务,这些新微任务也会 立即执行,直到队列为空。
- 宏任务队列每次只处理一个任务,处理完后会再次检查微任务队列。