🚀 深入浅出 Event Loop:带你彻底搞懂 JS 执行机制

3 阅读10分钟

你好呀,掘金的各位小伙伴们!👋

今天我们要来聊一个老生常谈,但每次提起都能让人“掉几根头发”的话题 —— Event Loop(事件循环)

你是不是也曾在面试中被面试官那迷离的眼神注视着,问道:

“同学,你能告诉我 setTimeoutPromiseasync/await 到底谁先执行吗?为什么?” 🤯

或者在写代码时,发现打印出来的日志顺序和你想的完全不一样,怀疑人生?🤔

别担心!今天这篇文章,我们就把这些“玄学”问题一次性讲清楚!我们将不再只是死记硬背“宏任务”、“微任务”的定义,而是结合浏览器底层原理,用最轻松愉快的语气,带你啃下这块最硬的骨头!🍖

准备好了吗?我们要发车啦!🚗💨


🧐 第一部分:浏览器的“打工”日常 —— 进程与线程

在深入 Event Loop 之前,我们得先了解一下 JS 代码运行的“环境” —— 浏览器。

大家常说“JS 是单线程的”,但这其实是说 JS 的主线程 是单线程的。现代浏览器(比如 Chrome)其实是一个多进程的架构。你可以把浏览器想象成一个大型工厂 🏭。

1.1 浏览器的核心“部门”(进程)

这个工厂里有几个核心部门(进程),它们各司其职:

  • Browser 进程 🧠:工厂的“厂长”。负责界面显示、用户交互、子进程管理等。
  • GPU 进程 🎨:负责 3D 绘制等图形工作。
  • Network 进程 📡:负责网络资源加载。
  • Plugin 进程 🧩:负责插件运行。
  • Renderer 进程(渲染进程) 🏗️:重点来了! 这是我们要关注的主角。每个 Tab 页通常都有自己独立的渲染进程。

1.2 渲染进程的“繁忙”生活

渲染进程的主要职责是把 HTML、CSS、JS 变成用户看得到的网页。这个部门里有一个超级忙碌的员工,名叫 “渲染主线程” (Main Thread)

这个主线程有多忙呢?来看看它的 To-Do List:

  1. 解析 HTML 📄:生成 DOM 树。
  2. 计算样式 💅:构建 CSSOM 树。
  3. 布局 (Layout) 📐:计算元素的位置和大小,生成 Layout Tree。
  4. 分层 (Layer) 🍰:处理图层。
  5. 绘制 (Paint) 🖌️:生成绘制指令。
  6. 执行 JavaScript ⚡:处理业务逻辑、交互等。

划重点:⚠️ 渲染和 JS 执行是互斥的! 也就是说,主线程在执行 JS 的时候,就不能进行渲染;在渲染的时候,就不能执行 JS。它就像一个单核 CPU,同一时间只能做一件事。


🤔 第二部分:为什么 JS 必须是单线程?

你可能会问:“既然主线程这么忙,为什么不多招几个人(多线程)一起干呢?”

想象一下,如果 JS 是多线程的:

  • 线程 A 想把某个 DOM 节点删掉 ❌。
  • 线程 B 想给同一个 DOM 节点添加子元素 ➕。
  • 这时候浏览器该听谁的?🤷‍♂️

为了避免这种复杂的同步问题,JS 从诞生之初就设计为单线程。简单、高效,但副作用就是——容易堵车。🚗🚕🚙


🔄 第三部分:Event Loop —— 永不休止的循环

既然是单线程,如果遇到耗时的任务怎么办?比如:

  • 网络请求(要等几秒钟)⏳
  • 定时器(要等几秒钟)⏲️
  • 用户点击(不知道什么时候点)🖱️

如果主线程傻傻地等着这些任务完成,那页面早就卡死了!😱

为了解决这个问题,浏览器引入了 消息队列 (Message Queue)事件循环 (Event Loop) 机制。

3.1 浏览器的“排队”策略

我们可以把主线程看作是一个永不停歇的检票员 👮‍♂️。

  1. 同步任务:就像是买了 VIP 票的乘客,直接在主线程上执行,立即处理。
  2. 异步任务
    • 主线程发起异步任务(比如 setTimeoutfetch)。
    • 相应的其他线程(如定时器线程、网络线程)去处理这些耗时操作。
    • 一旦处理完成(比如时间到了、数据回来了),这些线程会把回调函数包装成一个任务,扔进消息队列里排队。

3.2 循环机制 (The Loop)

主线程(检票员)的工作逻辑是这样的:

// 伪代码模拟 Event Loop
for (;;) {
    // 1. 看看消息队列里有没有任务
    Task task = message_queue.take();
    
    if (task) {
        // 2. 有任务,拿出来执行
        Process(task);
    } else {
        // 3. 没任务,休息一下,等待新任务(休眠)
        Sleep();
    }
}

这就是 Event Loop!主线程不断地从消息队列中取出任务执行,执行完一个,再去取下一个。


⚖️ 第四部分:宏任务 vs 微任务 —— 优先级的博弈

但是!事情并没有那么简单。队列不只有一个! 为了更精细地控制任务的执行时机,浏览器把异步任务分成了两类:

4.1 🐢 宏任务 (Macro Task)

通常我们说的“任务”就是指宏任务。消息队列里的每一个任务本质上都是宏任务。

  • 来源script (整体代码)、setTimeoutsetInterval、I/O、UI 交互事件、postMessage 等。
  • 特点:每次 Event Loop 循环只执行一个宏任务。

4.2 🐇 微任务 (Micro Task)

微任务是 VIP 中的 VIP,它不需要去普通的消息队列排队,而是有一个专门的微任务队列

  • 来源Promise.then/catch/finallyasync/awaitMutationObserverqueueMicrotask
  • 特点在当前宏任务执行结束后,下一次渲染之前,会立即清空所有的微任务! 🧹

4.3 🔄 完整的 Event Loop 流程

  1. 执行一个宏任务(最开始是 script 整体代码)。
  2. 遇到同步代码:直接执行。
  3. 遇到微任务:放入微任务队列。
  4. 遇到宏任务:交给其他模块处理,处理完放入宏任务队列。
  5. 当前宏任务执行完毕
  6. 检查微任务队列
    • 如果有微任务,依次执行所有微任务,直到队列为空。(如果在执行微任务的过程中又产生了新的微任务,也会在这一轮里被执行掉!无限套娃警告 ⚠️)
  7. 尝试进行页面渲染 (UI Rendering) 🎨。(并不是每次循环都会渲染,通常 60Hz 频率下每 16.6ms 渲染一次)。
  8. 开始下一轮 Event Loop:从宏任务队列取下一个任务执行。

⚔️ 第五部分:硬核实战 —— 代码执行全解析

光说不练假把式。我们拿一段包含各种情况的复杂代码来“解剖”一下!🔪

这是我们的测试代码:

// 1.html 源码解析
<script>
    // ------------------- 代码开始 -------------------
    console.log('同步代码 1'); // line 10

    setTimeout(() => { // line 12
        console.log('setTimeout 1');
        Promise.resolve().then(() => {
            console.log('setTimeout 1 内部微任务');
        });
    }, 0);

    const promise1 = new Promise((resolve) => { // line 19
        console.log('Promise 构造函数');
        resolve();
        console.log('Promise 构造函数内 resolve 后');
    });

    promise1.then(() => { // line 25
        console.log('Promise.then 1');
        setTimeout(() => {
            console.log('Promise.then 1 内部 setTimeout');
        }, 0);
    });

    async function asyncFn() { // line 32
        console.log('async 函数同步部分');
        // await 后面的所有代码 作为 promise.then 的回调函数里面的代码
        await Promise.resolve(); // 异步变同步的语法糖,本质还是异步的
        console.log('await 后微任务');
    }

    asyncFn(); // line 39

    console.log('同步代码 2'); // line 41

    // html5 标准 微任务队列
    queueMicrotask(() => { // line 43
        console.log('queueMicrotask 微任务');
    });

    // 额外增加 DOM 监听类微任务(前端特有)
    const observer = new MutationObserver(() => { // line 48
        console.log('MutationObserver 微任务');
    });
    const div = document.createElement('div');
    observer.observe(div, { attributes: true });
    div.setAttribute('data-test', '1'); // 触发 MutationObserver
    // ------------------- 代码结束 -------------------
</script>

🕵️‍♂️ 详细执行步骤解析

我们将整个执行过程分为三个阶段:

  1. 第一轮宏任务(Script 整体代码)执行
  2. 清空微任务队列
  3. 第二轮宏任务(如果有)

🎬 第一阶段:执行主线程同步代码(Script 宏任务)

  1. Line 10: console.log('同步代码 1')
    • 👉 输出: '同步代码 1'
  2. Line 12: setTimeout(..., 0)
    • 这是一个宏任务。浏览器将回调函数交给定时器线程。因为是 0ms,它会尽快被放入宏任务队列
    • 🏗️ 宏任务队列: [setTimeout1_callback]
  3. Line 19: new Promise(...)
    • 注意Promise 的构造函数是同步执行的!
    • console.log('Promise 构造函数') 👉 输出: 'Promise 构造函数'
    • resolve():Promise 状态变为 Resolved。
    • console.log('Promise 构造函数内 resolve 后') 👉 输出: 'Promise 构造函数内 resolve 后'
  4. Line 25: promise1.then(...)
    • 这是一个微任务。因为 promise1 已经 resolve 了,回调函数被放入微任务队列
    • 🧬 微任务队列: [promise1_then_callback]
  5. Line 39: 执行 asyncFn()
    • 进入函数体。
    • Line 33: console.log('async 函数同步部分') 👉 输出: 'async 函数同步部分'
    • Line 35: await Promise.resolve()
      • await 这一行右边的代码是同步执行的(这里是 Promise.resolve())。
      • 关键点await 就像一个分界线。它下面的代码(console.log('await 后微任务'))会被相当于放入一个 Promise.then 中。
      • 所以,await 后面的逻辑进入微任务队列
    • 🧬 微任务队列: [promise1_then_callback, async_await_callback]
  6. Line 41: console.log('同步代码 2')
    • 👉 输出: '同步代码 2'
  7. Line 43: queueMicrotask(...)
    • 直接添加一个微任务。
    • 🧬 微任务队列: [promise1_then_callback, async_await_callback, queueMicrotask_callback]
  8. Line 48-53: MutationObserver
    • div.setAttribute 修改了 DOM,触发了观察者。这是一个微任务。
    • 🧬 微任务队列: [..., queueMicrotask_callback, mutation_observer_callback]

🏁 第一阶段小结控制台输出

同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2

当前队列状态

  • 微任务队列[promise1.then, async_await, queueMicrotask, MutationObserver]
  • 宏任务队列[setTimeout1]

🧹 第二阶段:清空微任务队列

Script 宏任务执行完了,现在主线程空了。Event Loop 检查微任务队列,发现一大堆任务,开始依次执行。

  1. 执行 promise1.then 回调
    • console.log('Promise.then 1') 👉 输出: 'Promise.then 1'
    • setTimeout(..., 0):产生一个新的宏任务!放入宏任务队列。
    • 🏗️ 宏任务队列: [setTimeout1, setTimeout_inside_then]
  2. 执行 asyncFnawait 后的代码
    • console.log('await 后微任务') 👉 输出: 'await 后微任务'
  3. 执行 queueMicrotask 回调
    • console.log('queueMicrotask 微任务') 👉 输出: 'queueMicrotask 微任务'
  4. 执行 MutationObserver 回调
    • console.log('MutationObserver 微任务') 👉 输出: 'MutationObserver 微任务'

🏁 第二阶段小结: 微任务队列清空了!🎉 控制台新增输出

Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务

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

微任务清空后,浏览器可能会进行渲染(Render)。然后 Event Loop 再次转动,去宏任务队列取任务。

  1. 取出 setTimeout1 的回调

    • console.log('setTimeout 1') 👉 输出: 'setTimeout 1'
    • Promise.resolve().then(...)注意! 这里又产生了一个微任务!
    • 这个微任务会立刻被加入微任务队列。
    • 🧬 微任务队列: [setTimeout1_microtask]
    • 当前宏任务执行完毕
  2. 再次检查微任务队列(你以为完了?并没有!):

    • 发现刚才新产生的微任务 [setTimeout1_microtask]
    • 立即执行!
    • console.log('setTimeout 1 内部微任务') 👉 输出: 'setTimeout 1 内部微任务'
  3. 取出 setTimeout_inside_then 的回调(来自 promise1.then 内部):

    • console.log('Promise.then 1 内部 setTimeout') 👉 输出: 'Promise.then 1 内部 setTimeout'

✅ 最终输出结果

同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2
Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务
setTimeout 1
setTimeout 1 内部微任务
Promise.then 1 内部 setTimeout

📝 第六部分:总结与避坑指南

通过上面的分析,我们可以总结出几条黄金法则:

  1. JS 主线程是单线程的,依靠 Event Loop 搞定异步。
  2. 同步代码优先:所有同步代码都在第一个宏任务(Script)中执行。
  3. 微任务插队:宏任务执行完,必须清空微任务队列,才能去执行下一个宏任务。
  4. Promise 构造函数是同步的then 才是微任务。
  5. await 是分水岭await 右边是同步,下面是微任务。

💡 为什么懂这个很重要?

  • 性能优化:如果你在微任务里写了死循环或者巨量计算,会导致页面卡死,因为宏任务(如渲染、点击响应)永远没机会执行!这叫“微任务阻塞”。
  • Bug 排查:理解执行顺序,才能知道为什么你的数据没更新,或者为什么 DOM 还没渲染出来代码就报错了。

希望这篇文章能帮你彻底打通 Event Loop 的任督二脉!下次面试,请自信地告诉面试官:“我不仅知道结果,我还知道浏览器底层是怎么跑的!” 😎


本文代码示例基于 Chrome 浏览器环境,不同浏览器或 Node.js 版本可能存在细微差异,但标准模型大同小异。