目录

一、先搞懂三个核心角色(后厨版)

主厨(Call Stack —— 调用栈):

待办清单(Task Queue —— 宏任务队列):

插队急单(Microtask Queue —— 微任务队列):

二、事件循环的执行规则(“大厨”的作息表)

三、保姆级图解:一次完整的事件循环 Tick

四、实战推演:一段让你彻底顿悟的代码

五、核心误区澄清(新手必看)

误区一:setTimeout(fn, 0) 意思是“0毫秒后立刻执行”?

误区二:async/await 会阻塞事件循环?

误区三:微任务永远先于宏任务?

六、打通任督二脉:如何利用事件循环做性能优化?

七、终极记忆口诀(面试时直接背出来)

写在最后


JavaScript 是单线程的,意味着它一次只能做一件事。那它怎么做到一边执行代码,一边响应点击,还能抽空处理定时器?全靠事件循环(Event Loop)。

一、先搞懂三个核心角色(后厨版)

想象一家只有一个大厨(主线程)的餐厅,要处理来自四面八方的订单。

  1. 主厨(Call Stack —— 调用栈)
    • 他面前只有一个灶台(栈),一次只能炒一道菜。

    • 所有同步任务(console.logfor 循环、普通函数调用)都必须排着队,等他一道一道炒完。

  2. 待办清单(Task Queue —— 宏任务队列)
    • 顾客(浏览器)不断下新单:点击按钮、Ajax 请求成功、setTimeout 定时到了。

    • 这些单子被写在待办清单上,排队等着大厨有空了再做。

    • 常见的宏任务(MacroTask)setTimeoutsetInterval、I/O 操作、UI 渲染、事件回调(click/load)。

  3. 插队急单(Microtask Queue —— 微任务队列)
    • 总有 VIP 顾客(Promise)和着急的领班(MutationObserver)要求“插队”

    • 这个队列的优先级极高,必须插在普通待办清单前面

    • 常见的微任务(MicroTask)Promise.then/catch/finallyasync/await(本质是 Promise)、MutationObserverqueueMicrotask


二、事件循环的执行规则(“大厨”的作息表)

大厨(主线程)每天的工作流程极其规律,严格遵循以下铁律

1. 先清空灶台(调用栈)—— 只有当灶台完全空闲时,才会去看清单。
2. 先处理插队急单(微任务队列)—— 只要有微任务,坚决不看宏任务。
3. 微任务清空后,最多从待办清单(宏任务队列)里取 1 个来做。
4. 做完这 1 个宏任务,可能会去休息一下(执行渲染更新),然后立刻回到步骤 2(再次清空微任务)。


三、保姆级图解:一次完整的事件循环 Tick


四、实战推演:一段让你彻底顿悟的代码

我们来看一道经典面试题,按照上面的规则一步步推演:

console.log('1');  // 同步代码

setTimeout(() => {
  console.log('2'); // 宏任务
}, 0);

Promise.resolve().then(() => {
  console.log('3'); // 微任务
});

console.log('4'); // 同步代码

你的直觉可能觉得输出是 1,4,2,3?错! 我们严格按照大厨作息表来:

  1. 开始执行:大厨看灶台(调用栈),发现 console.log('1'),直接炒了,输出 1

  2. 遇到 setTimeout:这是个定时器,大厨把它扔给旁边的计时小弟(Web API),说:“时间到了提醒我”。小弟计时完,把回调函数放进待办清单(宏任务队列)

  3. 遇到 Promise.then:这是插队急单(微任务),直接放进微任务队列,优先级极高

  4. 遇到 console.log('4'):同步代码,直接炒,输出 4

  5. 此时灶台空了(调用栈清空):大厨开始检查插队急单(微任务队列),发现有 Promise.then,立刻执行,输出 3

  6. 插队急单清空完毕:大厨这才慢悠悠地看向待办清单(宏任务队列),取出 setTimeout 的回调,执行,输出 2

正确答案:1 -> 4 -> 3 -> 2


五、核心误区澄清(新手必看)

  • 误区一:setTimeout(fn, 0) 意思是“0毫秒后立刻执行”?
    • 真相:它表示“至少等待 0 毫秒后,将任务放入宏任务队列”。实际执行时间取决于大厨(主线程)是否空闲,以及前面有没有微任务在排队。所以哪怕写 0,也要等到所有同步代码和现有微任务跑完才轮到他。

  • 误区二:async/await 会阻塞事件循环?
    • 真相await 后面的代码相当于被 Promise.then 包裹,变成了微任务。它不会阻塞主线程,而是主动让出灶台,让大厨去做别的同步事,做完再来处理 await 后面的代码。

  • 误区三:微任务永远先于宏任务?
    • 更准确的表述:在当前宏任务执行完毕后,下一轮循环开始前,会把所有微任务清空。也就是说,微任务清空是每轮宏任务结束时的“强制打卡”环节。


六、打通任督二脉:如何利用事件循环做性能优化?

回到我们之前的“性能优化”话题,你会发现:

性能优化场景 事件循环的应用
首屏渲染卡顿(LCP 慢) 将不影响首屏的复杂计算(如埋点上报、大数据排序)用 setTimeout 或 requestIdleCallback 包裹,推入宏任务队列,让主线程优先渲染完首屏再慢慢算。
点击按钮没反应(FID 长) 把大循环计算放进 Web Worker,或拆分成多个微任务/宏任务(Time Slicing),避免长任务霸占主线程超过 50ms。
动画掉帧(卡顿) 浏览器的渲染通常发生在宏任务和微任务清空之后。如果微任务队列里有海量计算(比如递归 Promise),浏览器将永远无法走到渲染这一步,导致页面白屏或卡死。

七、终极记忆口诀(面试时直接背出来)

“一次宏任务,清空微任务,中途可渲染,循环往复。”

如果再深入一点点,可以补一句:

“宏任务包含整体脚本、setTimeout、UI 事件;微任务包含 Promise、MutationObserver。微任务的优先级永远高于宏任务。”


写在最后

事件循环是 JavaScript 异步编程的基石,也是理解浏览器“为什么有时快、有时慢”的钥匙。

它就像一个极其遵守规则的交警,在执行栈(主线程)微任务队列宏任务队列渲染线程之间精准指挥,确保页面既不会卡死,也不会放过任何一个点击事件。

理解了它,你就不会再被 setTimeout 和 Promise 的顺序搞晕,也能在面对性能瓶颈时,精准地判断是把任务“推到下一帧”还是“立刻插队执行”。

(PS:本文由deepseek辅助生成)

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐