事件循环EventLoop —— JS单线程的天生短板,浏览器用循环机制补齐

0 阅读8分钟

学了事件循环:终于明白代码为什么会“循环执行”

最开始学JS执行机制时,心里一直有个巨大的疑问:

JS代码明明是从上到下顺序执行的,线性跑完就结束了,凭什么要叫「事件循环」?循环到底在哪里?

我之前看了几篇掘金文章,越看越懵,评论区各种高深讨论、天花乱坠的术语,有人讲宏微任务、有人讲多队列、有人讲Node阶段,看的有点混乱。

直到我去区分学习了解后,才理解: 根本没有什么神秘,事件循环的诞生,完全是被「异步代码」逼出来的!

今天这篇文章,基于W3C最新标准的现代浏览器环境,从「为什么出现→核心原理→执行流程→经典面试题复盘」,一次性讲透事件循环,希望能帮大家解决一些困惑。

一、先定标准:杜绝90%的认知混乱

在开始讲解之前,必须明确本文的适用范围:

本文讲解的 JS 事件循环 Event Loop

适用环境 :所有现代主流浏览器(Chrome、Edge、Firefox),遵循统一的W3C官方标准

不适用环境:Node.js、低版本老旧浏览器、小程序内嵌webview

重点区分: Node.js的事件循环是六阶段独立机制,和浏览器完全是两套逻辑! (这里区分一下也是看到有一些文章下面观点冲突、评论区的争论,都是把浏览器和Node的规则混为一谈,并不是事件循环本身规则混乱。)

我们前端日常开发、面试绝大多数的场景,用的都是浏览器标准事件循环。

二、溯源:为什么JS需要事件循环?

1. JS的天生属性:单线程

JavaScript是单线程非阻塞语言,核心特点:同一时刻,主线程只能执行一段代码。

如果JS只有同步代码,世界非常简单: 代码从上到下、逐行执行,执行完一行走下一行,线性执行、跑完即结束。

这种纯同步场景下,根本不需要任何“循环”机制!

2. 问题的根源:异步代码

真正打破线性执行、逼出「事件循环」的,是异步任务。

日常开发中大量异步代码:

  • 定时器: setTimeout 
  • 网络请求:Ajax
  • 微任务: Promise.then 、 async/await 
  • DOM事件监听

这些代码不会立刻执行,不会阻塞主线程,但又需要在未来某个时刻触发执行。

这就出现了一个矛盾:

JS只有一个主线程,既要实时执行用户的同步代码、保证页面不卡顿,又要排队处理后续的异步任务

为了解决这个矛盾,浏览器官方设计了一套持续调度、循环检查、有序执行任务的机制——这就是事件循环

终极三句话总结本质

没有异步代码,就没有事件循环;

事件循环,就是浏览器专门调度异步代码的执行规则!

所谓的“循环”,不是代码本身循环,而是浏览器主线程不断循环检查、清空任务队列的过程。

三、W3C标准:任务分类与优先级

浏览器将所有代码任务,严格分为同步任务、异步任务两大类,异步任务又细分微任务、宏任务,优先级层层递减。

1. 同步任务(最高优先级)

自上而下立即执行,直接进入主线程调用栈,优先于所有异步任务。

2. 微任务 Microtask(异步·高优先级)

队列优先级高于宏任务,同步代码执行完毕后,必须一次性全部清空

(包含: Promise.then/catch/finally 、 await 后续代码 、 queueMicrotask )

3. 宏任务 Macrotask(异步·最低优先级)

队列优先级最低,每一轮循环只执行一个

(包含:整体script全局代码、 setTimeout 、 setInterval 、Ajax请求、DOM事件)

优先级铁律:同步任务 > 微任务 > 宏任务

四、浏览器标准事件循环完整执行流程

这是W3C官方规定、所有现代浏览器统一遵守的唯一执行流程,也是所有面试题的核心底层逻辑:

1. 执行所有同步任务:主线程从上到下,跑完所有同步代码,清空调用栈

2. 清空全部微任务队列:同步代码结束后,一次性执行队列中所有微任务,直到微任务队列彻底清空(包含执行微任务过程中新增的微任务)

3. 执行单个宏任务:从宏任务队列头部,取出唯一一个宏任务执行

4. 循环校验:当前宏任务执行完毕后,立刻重新检查并清空新增的微任务

5. 往复循环:重复「清空微任务→执行单个宏任务」的流程,这就是完整的事件循环

核心口诀(永久记住): 同步先行,微任务清零,单次宏任务执行,循环往复

(这也就完美解释了“循环”二字: 主线程不是执行完代码就终止,而是不断循环处理「微任务、宏任务」,直到所有任务队列全部清空。)

五、经典题目逐层拆解

结合上面的标准流程,我们拆解前端最常考、最容易踩坑的4类真题,彻底固化逻辑。

案例1:基础入门题(同步+宏+微)3

console.log(1)
setTimeout(()=>console.log(2))
Promise.resolve().then(()=>console.log(3))
console.log(4)

输出结果:1 → 4 → 3 → 2

流程拆解:

1. 执行同步代码,依次打印 1、4

2. 同步执行完毕,清空微任务队列,打印 3

3. 微任务清空,执行唯一的宏任务,打印 2

案例2:微任务嵌套宏任务(高频易错题)

console.log('start')

setTimeout(() => {
  console.log('宏1')
  Promise.resolve().then(() => {
    console.log('宏里面的微任务')
  })
})

Promise.resolve().then(() => {
  console.log('微1')
  setTimeout(() => {
    console.log('微里面的宏任务')
  })
})

console.log('end')

输出结果:start → end → 微1 → 宏1 → 宏里面的微任务 → 微里面的宏任务

核心逻辑: 宏任务执行过程中新增的微任务,会立刻优先执行,永远遵循「微任务优先」原则。

这两题都相对比较简单,下面这两题就有点坑了

案例3:async/await 重难点题

console.log(1)

async function fn() {
  console.log(2)
  await Promise.resolve()
  console.log(3)
}

fn()

setTimeout(() => {
  console.log(4)
})

Promise.resolve().then(() => {
  console.log(5)
})

console.log(6)

这道题是事件循环重灾区,大部分人都会踩中两个典型误区,产生两种错误答案:

错误答案1:1 6 2 5 3 4

错误答案2:1 2 6 3 5 4

你有没有中招呢 很多人第一眼都会答错,其实题目里刚好藏着两个核心考点大坑

1. 不清楚  await  前面的代码属于同步代码,误以为整个async函数都是异步,导致打印2和6顺序错乱

2. 知道await前同步,但分不清微任务入队时机,凭代码书写顺序判断,认为3一定比5先执行

详细分步解析

1. 执行同步代码打印 1,调用 fn 函数

2. 函数内部 await 之前代码同步执行,直接打印 2

3. 遇到 await,整个 async 函数暂停挂起 await 后面的  console.log(3)  不会立刻进入微任务队列

4. JS 继续向下执行外部代码,遇到普通 Promise.then 直接将打印5的回调当场丢入微任务队列

5. 同步代码走完,打印 6

6. 所有同步执行完毕,引擎才把 await 后半段代码加入微任务队列

(微任务入队顺序:5 在前,3 在后队列先进先出,所以先打印5,再打印3。)

正确输出结果

 1 → 2 → 6 → 5 → 3 → 4 

核心总结

1. await 之前同步执行,await 之后才会变成微任务

2. 普通  .then  立即入队,await 后续代码延后入队 不要看代码上下书写位置,看微任务什么时候进入队列

案例4:多层then链式嵌套

console.log('A')

setTimeout(() => {
  console.log('B')
  Promise.resolve().then(() => {
    console.log('C')
  })
})

Promise.resolve().then(() => {
  console.log('D')
  setTimeout(() => {
    console.log('E')
  })
}).then(()=>{
  console.log('F')
})

console.log('G')

输出结果:A → G → D → F → B → C → E

解答唯一一个疑惑点就是F的位置

then().then()  链式调用 第二个 then 不是新的微任务队列,是上一个微任务内部的同步回调

  • 第一个 then 执行完
  • 在当前这个微任务内部,同步直接调用下一个 then
  • 根本不需要重新排队、不需要再进一遍微任务队列
  • 所以 D 一出,F 紧跟着瞬间就出来了

六、一些认知误区

1. 误区1:事件循环是JS自带的机制

错! JS本身没有事件循环,事件循环是浏览器宿主环境为JS提供的任务调度机制。

2. 误区2:宏任务和微任务是交替批量执行

错! 标准规则是:一次只执行一个宏任务,执行完必清一次微任务,不是批量宏任务。

3. 误区3:看到网上不同答案感觉是规则会变化错! 全部是环境混淆,浏览器W3C标准十几年未变,混乱均来自混用Node规则、老旧浏览器规则。

七、文末总结

1. 事件循环的诞生根源:JS单线程 + 异步代码,为了解决主线程阻塞问题

2. 循环的本质:浏览器反复「清空微任务→执行单个宏任务」的调度循环

3. 标准范围:仅适用于现代浏览器,和Node机制完全无关

4. 现代浏览器标准执行铁律:同步优先、微任务清零、宏任务单次执行、循环往复