前端死磕:一文彻底搞懂 JS 事件循环 (Event Loop) 与宏微任务

0 阅读6分钟

继之前梳理了 this 指向、原型链机制以及防抖节流后,今天咱们来啃下 JS 另外一块绝对绕不开,且在面试中必问的硬骨头——事件循环(Event Loop)

很多同学在面对复杂的 setTimeoutasync/await 混合嵌套时,经常会被输出顺序搞晕。别急,看完这篇文章,你会发现它的底层逻辑其实非常清晰。

一、为什么 JS 是单线程的?

在了解事件循环之前,我们要先明白一个大前提:JavaScript 默认只启用一个线程执行。

很多同学可能会问,既然有耗时任务,为什么不设计成多线程并发执行呢?原因很简单,这和 JS 的宿主环境(浏览器)有关:

  1. DOM 渲染的安全性:JS 是能够直接操作 DOM 结构的。如果存在多线程,线程 A 正在给 DOM 节点添加样式,线程 B 却要把这个节点删除,这就可能造成非常不安全的渲染冲突。
  2. 性能与复杂度:一旦引入多线程,就需要引入“锁”的机制来处理状态同步。这极大地增加了语言的复杂度和开发难度,同时也会增加设备额外的性能开销。

💡 重点避坑: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 步循环:

  1. 执行同步任务:主线程从上到下执行代码(注意,整个 script 本身就是一个宏任务)。在这个过程中,遇到异步任务,就按类别存入对应的“宏任务队列”或“微任务队列”中。
  2. 清空微任务队列:同步代码执行完后,主线程立刻去微任务队列中查找,并将里面所有的微任务全部取出来一次性执行完毕。
  3. 页面渲染:微任务清空后,在有需要的情况下,浏览器会趁着这个空隙去渲染页面。
  4. 执行下一个宏任务:去宏任务队列中查找,取出一个排在最前面的宏任务执行。 (注意:这标志着下一次事件循环的开始,执行完后又会回到第 2 步)。

四、两个容易踩坑的细节考点

1. setTimeout 的执行时机

我们经常会把 setTimeout 当作宏任务来排队,但有个细节必须注意:所有的 setTimeout 共用同一份时间。 先进宏任务队列的,不一定先被取出来执行。事件循环在处理定时器宏任务时,是根据你设定的计时时间长短,来判断到底谁先到期、谁先执行的。

2. 看透 async/await 的本质

随着 ES6+ 的普及,async/await 成了异步编程的新宠,但它本质上只是 Promise 的语法糖:

  • 关于 async:在函数前面加 async,等同于在这个函数内部隐式 return 了一个 Promise 对象。
  • 关于 await:这是最容易迷糊的地方!当我们写 await xxx 时,xxx 本身的代码会被看成同步代码立即执行。而 await 真正做的,是将它后续的代码全部挤入微任务队列中,并让出当前主线程。

五、终极实战:一段代码看透宏微任务执行流

理论说完了,我们直接上真实战。下面是一道非常经典的面试题,请看下方代码和执行顺序标注图 b65bbec7f915852a2ebc22c93fe00eb8.png

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

b65bbec7f915852a2ebc22c93fe00eb8.png

很多初学者看到这种嵌套就头大,但只要套用我们前面讲的“4步循环法”“await 切割法”,一步步拆解,逻辑就会像水面一样清澈。下面是图解中 1 到 8 的详细执行推导:

🕒 第一阶段:走完主线程同步任务

  1. 打印 1 (script start) :代码从上往下走,遇到第一个同步代码,直接打印。

  2. 打印 2 (async2 end) :执行 async1(),遇到 await async2()。记住上面说的,await 右侧的代码会立即同步执行!所以进入 async2 直接打印。

    • 关键转折:执行完 async2 后,await 发威,将它后面的代码(即 console.log('async1 end'))打包装进微任务队列,然后强制让出主线程!
  3. 宏任务排队:遇到 setTimeout,将其回调函数扔进宏任务队列,假装没看见,继续往下走。

  4. 打印 3 (promise) :遇到 new Promise,其内部的 executor 函数是同步执行的,直接打印。紧接着调用了 resolve(),这会激活后面的 .then,将 console.log('then1') 扔进微任务队列

  5. 打印 4 (script end) :执行到最后一行,至此,主线程的同步代码全部执行完毕。

当前队列状态盘点:

  • 微任务队列[执行 async1 剩余代码, 执行 then1]
  • 宏任务队列[执行 setTimeout]

🕒 第二阶段:清空微任务队列 (大决战)

主线程空闲了,Event Loop 铁律:只要微任务队列有东西,必须全部榨干才准看宏任务一眼。

  1. 打印 5 (async1 end) :从微任务队列取出第一个任务,回到刚才 async1 挂起的地方继续执行。

  2. 打印 6 (then1) :取出第二个微任务执行。

    • 隐藏连招:执行完 then1 后,触发了它后面的第二个 .then,于是又动态产生了一个新的微任务 console.log('then2'),被追加到微任务队列尾部。
  3. 打印 7 (then2) :主线程发现微任务队列又有了新活,拿出来执行。此时微任务队列彻底清空。

🕒 第三阶段:执行下一个宏任务

  1. 打印 8 (setTimeout) :微任务清空完毕,终于轮到在宏任务队列里等到长蘑菇的 setTimeout 出场。这也是下一次事件循环的开始。

结语

掌握事件循环,是我们从“能写业务代码”向“写出高性能、无 Bug 代码”进阶的必经之路。把宏微任务的队列机制刻在脑子里,以后无论是排查复杂的异步 Bug,还是手撕面试题,你都能像 JS 引擎一样在脑海里单步调试了。

如果这篇文章对你有帮助,欢迎点赞收藏!下期想看什么底层原理解析,欢迎在评论区留言交流!