继之前梳理了 this 指向、原型链机制以及防抖节流后,今天咱们来啃下 JS 另外一块绝对绕不开,且在面试中必问的硬骨头------事件循环(Event Loop) 。
很多同学在面对复杂的 setTimeout 和 async/await 混合嵌套时,经常会被输出顺序搞晕。别急,看完这篇文章,你会发现它的底层逻辑其实非常清晰。
一、为什么 JS 是单线程的?
在了解事件循环之前,我们要先明白一个大前提:JavaScript 默认只启用一个线程执行。
很多同学可能会问,既然有耗时任务,为什么不设计成多线程并发执行呢?原因很简单,这和 JS 的宿主环境(浏览器)有关:
- DOM 渲染的安全性:JS 是能够直接操作 DOM 结构的。如果存在多线程,线程 A 正在给 DOM 节点添加样式,线程 B 却要把这个节点删除,这就可能造成非常不安全的渲染冲突。
- 性能与复杂度:一旦引入多线程,就需要引入"锁"的机制来处理状态同步。这极大地增加了语言的复杂度和开发难度,同时也会增加设备额外的性能开销。
💡 重点避坑 :JS 引擎线程和 GUI 渲染线程是互斥的。这意味着当 JS 执行复杂逻辑时,页面渲染会被阻塞;反之亦然。这也是为什么我们不能让 JS 主线程卡死的原因。
二、任务分类:同步与异步,宏任务与微任务
我们的代码中,一定会有耗时任务 (比如定时器、HTTP 请求)和不耗时任务。 为了防止耗时任务卡死主线程,JS 引擎会将耗时任务(异步任务)先存放到队列中排队,优先执行不耗时的同步任务。
当我们谈论异步任务时,它们并不是平等排队的,而是被严格分成了两路大军:
1. 宏任务 (Macrotask)
这属于"普通 VIP",包含:
- 整体代码
script(这也是最大的一个宏任务) setTimeout()/setInterval()- 网络请求(Ajax)
- I/O 操作
- UI 渲染 (UI-rendering)
2. 微任务 (Microtask)
这属于"超级 VIP",只要微任务队列里有任务,主线程就会优先处理它们。包含:
Promise.then()process.nextTick()(Node.js 环境)MutationObserver
三、核心运转轴:事件循环 (Event Loop) 机制
代码到底是怎么执行的?其实只要记住下面这雷打不动的 4 步循环:
- 执行同步任务 :主线程从上到下执行代码(注意,整个
script本身就是一个宏任务)。在这个过程中,遇到异步任务,就按类别存入对应的"宏任务队列"或"微任务队列"中。 - 清空微任务队列 :同步代码执行完后,主线程立刻去微任务队列中查找,并将里面所有的微任务全部取出来一次性执行完毕。
- 页面渲染:微任务清空后,在有需要的情况下,浏览器会趁着这个空隙去渲染页面。
- 执行下一个宏任务 :去宏任务队列 中查找,取出一个排在最前面的宏任务执行。 (注意:这标志着下一次事件循环的开始,执行完后又会回到第 2 步)。
四、两个容易踩坑的细节考点
1. setTimeout 的执行时机
我们经常会把 setTimeout 当作宏任务来排队,但有个细节必须注意:所有的 setTimeout 共用同一份时间 。 先进宏任务队列的,不一定先被取出来执行。事件循环在处理定时器宏任务时,是根据你设定的计时时间长短,来判断到底谁先到期、谁先执行的。
2. 看透 async/await 的本质
随着 ES6+ 的普及,async/await 成了异步编程的新宠,但它本质上只是 Promise 的语法糖:
- 关于
async:在函数前面加async,等同于在这个函数内部隐式return了一个 Promise 对象。 - 关于
await:这是最容易迷糊的地方!当我们写await xxx时,xxx本身的代码会被看成同步代码立即执行 。而await真正做的,是将它后续的代码全部挤入微任务队列中,并让出当前主线程。
五、终极实战:一段代码看透宏微任务执行流
理论说完了,我们直接上真实战。下面是一道非常经典的面试题,请看下方代码和执行顺序标注图 b65bbec7f915852a2ebc22c93fe00eb8.png:
JavaScript
console.log('script start'); // 步骤 1
async function async1() {
await async2()
console.log('async1 end'); // 步骤 5
}
async function async2() {
console.log('async2 end'); // 步骤 2
}
async1()
setTimeout(() => {
console.log('setTimeout'); // 步骤 8
}, 0)
new Promise((resolve, reject) => {
console.log('promise'); // 步骤 3
resolve()
})
.then(() => {
console.log('then1'); // 步骤 6
})
.then(() => {
console.log('then2'); // 步骤 7
});
console.log('script end'); // 步骤 4

很多初学者看到这种嵌套就头大,但只要套用我们前面讲的"4步循环法"和"await 切割法",一步步拆解,逻辑就会像水面一样清澈。下面是图解中 1 到 8 的详细执行推导:
🕒 第一阶段:走完主线程同步任务
-
打印 1 (
script start) :代码从上往下走,遇到第一个同步代码,直接打印。 -
打印 2 (
async2 end) :执行async1(),遇到await async2()。记住上面说的,await右侧的代码会立即同步执行 !所以进入async2直接打印。- 关键转折 :执行完
async2后,await发威,将它后面的代码(即console.log('async1 end'))打包装进微任务队列,然后强制让出主线程!
- 关键转折 :执行完
-
宏任务排队 :遇到
setTimeout,将其回调函数扔进宏任务队列,假装没看见,继续往下走。 -
打印 3 (
promise) :遇到new Promise,其内部的executor函数是同步执行 的,直接打印。紧接着调用了resolve(),这会激活后面的.then,将console.log('then1')扔进微任务队列。 -
打印 4 (
script end) :执行到最后一行,至此,主线程的同步代码全部执行完毕。
当前队列状态盘点:
- 微任务队列 :
[执行 async1 剩余代码, 执行 then1]- 宏任务队列 :
[执行 setTimeout]
🕒 第二阶段:清空微任务队列 (大决战)
主线程空闲了,Event Loop 铁律:只要微任务队列有东西,必须全部榨干才准看宏任务一眼。
-
打印 5 (
async1 end) :从微任务队列取出第一个任务,回到刚才async1挂起的地方继续执行。 -
打印 6 (
then1) :取出第二个微任务执行。- 隐藏连招 :执行完
then1后,触发了它后面的第二个.then,于是又动态产生了一个新的微任务console.log('then2'),被追加到微任务队列尾部。
- 隐藏连招 :执行完
-
打印 7 (
then2) :主线程发现微任务队列又有了新活,拿出来执行。此时微任务队列彻底清空。
🕒 第三阶段:执行下一个宏任务
- 打印 8 (
setTimeout) :微任务清空完毕,终于轮到在宏任务队列里等到长蘑菇的setTimeout出场。这也是下一次事件循环的开始。
结语
掌握事件循环,是我们从"能写业务代码"向"写出高性能、无 Bug 代码"进阶的必经之路。把宏微任务的队列机制刻在脑子里,以后无论是排查复杂的异步 Bug,还是手撕面试题,你都能像 JS 引擎一样在脑海里单步调试了。
如果这篇文章对你有帮助,欢迎点赞收藏!下期想看什么底层原理解析,欢迎在评论区留言交流!