深入理解Event Loop:从原理图到代码实战,小白也能看懂的 JS 执行机制

0 阅读9分钟

深入理解Event Loop:从原理图到代码实战,小白也能看懂的 JS 执行机制

为什么你的网页会“卡死”?

想象一下,你正在浏览一个精美的网页,突然点击了一个按钮,页面却像被冻结了一样,转圈圈半天没反应。或者,你在看视频时,画面一顿一顿的,极其不流畅。

这背后的罪魁祸首,往往就是 JavaScript 的执行机制 没有处理好。要解决这个问题,我们就必须理解浏览器中一个核心的概念—— Event Loop(事件循环)

很多初学者觉得 Event Loop 很难,满脑子都是“宏任务”、“微任务”、“调用栈”。别怕,今天我们像讲故事一样把这个机制彻底讲透。

一、宏观视角——浏览器的“工厂流水线”

首先,我们要建立一个宏观的认知。浏览器并不是只有一个线程在工作,但在处理 JavaScript 代码和页面渲染时,主要依赖的是 渲染进程的主线程

5e2875cbe8dfd535312456d9aadd9288.png

图解分析:谁在干活?

看着这张图,我们可以把浏览器想象成一个繁忙的工厂:

  1. IO 线程(底部蓝色箭头)

    • 这是工厂的“采购部”或“后勤部”。它负责处理网络请求(下载图片、获取数据)、文件读写等耗时操作。
    • 关键点:这些工作很慢,如果让主线程去等,整个工厂就停工了。所以 IO 线程独立工作,一旦任务完成(比如图片下载好了),它就会发个信号:“嘿,任务完成了!”
  2. 消息队列(中间长方形框)

    • 这是工厂的“待办事项清单”。
    • 当 IO 线程完成任务,或者用户点击了鼠标、定时器时间到了,这些事件不会立刻被执行,而是变成一个个“任务包”(任务 1, 任务 2...),排队放进这个盒子里。
    • 注意顺序:这是一个先进先出(FIFO)的队列,先来的任务排在前面。
  3. 渲染主线程(顶部蓝色循环箭头)

    • 这是工厂的“唯一主厨”。它非常忙碌,既要执行 JavaScript 代码,又要计算样式、绘制页面(渲染)。
    • Event Loop 的核心:主厨不能同时做两件事(单线程)。他只能不停地做一个动作:从消息队列里取出一个任务,执行它,然后再取下一个。 这个“取任务 - 执行 - 再取任务”的循环过程,就是 Event Loop

总结图示逻辑: IO 线程产生事件 -> 放入消息队列 -> 渲染主线程空闲时 -> 从队列取出任务 -> 执行任务(可能包含渲染)。

二、微观视角——任务的“三六九等”

知道了主线程会从队列取任务,但你可能会有疑问: “如果有 100 个任务在排队,是不是要等前面的 99 个都做完,第 100 个才能开始?”

答案是:不一定! 因为任务分等级。在 JavaScript 的世界里,任务被严格分为两类:宏任务(Macro Task)微任务(Micro Task)

这就引出了我们今天的重头戏——代码实战。

📝 代码案例:一场混乱的任务派对

让我们来看一段经典的“面试题”代码,这段代码混合了同步代码、Promise、async/await 和定时器。

<script>
// 1. 同步代码最先执行
console.log('同步代码 1');

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

// 3. 同步代码:Promise 构造函数
const promise1 = new Promise((resolve) => {
  console.log('Promise 构造函数');
  resolve();
  console.log('Promise 构造函数内 resolve 后');
});

// 4. 微任务注册:promise1.then
promise1.then(() => {
  console.log('Promise.then 1');
  setTimeout(() => {
    console.log('Promise.then 1 内部 setTimeout');
  }, 0);
});

// 5. 异步函数定义与调用
async function asyncFn() {
  console.log('async 函数同步部分');
  await Promise.resolve(); 
  console.log('await 后微任务');
}
asyncFn();

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

// 6. 原生微任务
queueMicrotask(() => {
  console.log('queueMicrotask 微任务');
});

// 7. DOM 监听微任务
const observer = new MutationObserver(() => {
  console.log('MutationObserver 微任务');
});
const div = document.createElement('div');
observer.observe(div, { attributes: true });
div.setAttribute('data-test', '1'); 
</script>

66abdee05e726e73deb2fcf2f452c434.png

深度解析:为什么是这个顺序?

看着控制台的输出截图,我们来一步步拆解执行过程。请记住 Event Loop 的黄金法则

执行栈清空后,先执行完所有【微任务】,再去执行下一个【宏任务】。

第一阶段:同步代码(主线程直线执行)

当脚本开始加载,主线程从上往下执行,遇到异步代码先“挂起”,注册到对应的队列中。

  1. console.log('同步代码 1') -> 立即打印

  2. setTimeout(...) -> 这是一个宏任务。主线程把它扔给浏览器的定时器模块,告诉它:“0 秒后把回调函数放到宏任务队列里去。”然后主线程继续往下走,不等待。

  3. new Promise(...) -> Promise 的构造函数是同步执行的!

    • console.log('Promise 构造函数') -> 立即打印
    • resolve() -> 状态改变。
    • console.log('Promise 构造函数内 resolve 后') -> 立即打印
  4. promise1.then(...) -> .then 里的回调是微任务。主线程把它放入微任务队列

  5. asyncFn() 调用 ->

    • console.log('async 函数同步部分') -> 立即打印
    • await Promise.resolve() -> 这里的 await 相当于把后面的代码包装成了一个微任务,放入微任务队列
  6. console.log('同步代码 2') -> 立即打印

  7. queueMicrotask(...) -> 显式添加一个微任务,放入微任务队列

  8. MutationObserver -> DOM 变化触发的回调也是微任务,放入微任务队列

🔴 第一阶段结束时的状态:

  • 控制台已输出:同步代码 1, Promise 构造函数, Promise 构造函数内 resolve 后, async 函数同步部分, 同步代码 2。
  • 微任务队列:[promise1.then, await 后续代码, queueMicrotask, MutationObserver]
  • 宏任务队列:[setTimeout 1]
第二阶段:清空微任务队列

同步代码执行完毕,调用栈空了。Event Loop 检查微任务队列,发现里面有 4 个任务,于是依次全部执行,直到队列为空。

  1. 执行 promise1.then -> 打印 Promise.then 1

    • 注意:在这个回调里又遇到了 setTimeout。这又是一个新的宏任务,被扔进了宏任务队列的末尾。
  2. 执行 await 后续代码 -> 打印 await 后微任务

  3. 执行 queueMicrotask -> 打印 queueMicrotask 微任务

  4. 执行 MutationObserver -> 打印 MutationObserver 微任务

** 第二阶段结束时的状态:**

  • 微任务队列:空。
  • 宏任务队列:[setTimeout 1 (最初的), setTimeout (来自 promise.then)]
第三阶段:执行下一个宏任务

微任务清空了,Event Loop 再次启动,从宏任务队列头部取出一个任务执行。

  1. 取出最初的 setTimeout 1

    • 执行回调 -> 打印 setTimeout 1
    • 内部遇到 Promise.resolve().then(...) -> 这是一个微任务,再次放入微任务队列

🔴 第三阶段结束时的状态:

  • 微任务队列:[setTimeout 1 内部的 then]
  • 宏任务队列:[setTimeout (来自 promise.then)]
第四阶段:再次清空微任务

宏任务执行完了一轮,Event Loop 惯例检查微任务队列。

  1. 执行 setTimeout 1 内部的 then -> 打印 setTimeout 1 内部微任务

🔴 第四阶段结束时的状态:

  • 微任务队列:空。
  • 宏任务队列:[setTimeout (来自 promise.then)]
第五阶段:执行最后一个宏任务
  1. 取出剩下的 setTimeout

    • 执行回调 -> 打印 Promise.then 1 内部 setTimeout

至此,所有任务执行完毕。对比一下控制台的截图,顺序完全一致!


三、核心概念提炼

为了让你记得更牢,我们把复杂的机制简化为三个关键点:

1. 同步 vs 异步

  • 同步代码:就像排队买票,你必须等前面的人买完,才能轮到你。JS 引擎一行行执行,遇到函数调用就压入栈,返回就弹出栈。
  • 异步代码:就像你去餐厅点餐。你点了菜(发起异步请求),拿到号牌(注册回调),然后可以去玩手机(执行其他代码)。等菜做好了(异步完成),服务员叫号(事件触发),你再去取餐(执行回调)。

2. 宏任务 (MacroTask) vs 微任务 (MicroTask)

这是最容易混淆的地方,请记住这个比喻:

  • 宏任务是“大老板交代的日常事务”(如:定时打卡、接收邮件、UI 渲染)。
  • 微任务是“老板突然插队的紧急指令”(如:Promise 结果出来了、DOM 变了)。

规则: 每当一个大老板的事务(宏任务)做完,在去做下一个大老板的事务之前,必须先把所有插队的紧急指令(微任务)全部处理完。 这就是为什么 Promise 的 .then 总是比 setTimeout 先执行的原因(在同一个事件循环周期内)。

四、理解Event Loop 对前端开发的意义

理解了 Event Loop对实际开发有巨大的指导意义:

1. 避免页面卡顿

如果你在同步代码里写了一个巨大的 for 循环,或者复杂的计算,主线程会被一直占用。这时候,消息队列里的“鼠标点击”、“页面渲染”任务都进不来,用户就会看到页面无响应。 解决方案:将大任务拆分成多个小任务,利用 setTimeoutrequestAnimationFrame 分段执行,给主线程喘息的机会去处理渲染和用户交互。

2. 精确控制执行顺序

有时候我们需要确保某些数据加载完成后,再更新界面。

  • 如果用 setTimeout,无法保证精确的时间,且优先级低。
  • 使用 Promiseasync/await,可以利用微任务的高优先级,确保数据一回来,立刻更新 DOM,减少闪烁。

3. 理解 React/Vue 的渲染机制

现代框架(如 React 18 的 Concurrent Mode 或 Vue 的 nextTick)底层大量运用了 Event Loop 的原理。

  • Vue 的 nextTick 本质上就是利用微任务,确保在 DOM 更新完成后执行回调。
  • 理解了这个,你才知道为什么在修改数据后,不能立刻获取到更新后的 DOM 高度,而需要用 nextTick 包裹。

总结

Event Loop是JavaScript处理异步任务的核心机制,理解它对于编写高效、响应式的代码至关重要。关键点包括:

  1. 同步代码优先执行
  2. 微任务在当前宏任务结束后立即执行
  3. 宏任务按顺序执行,每个宏任务后都会检查微任务队列
  4. 合理选择任务类型可以优化性能

掌握Event Loop不仅有助于理解代码执行顺序,还能帮助我们更好地进行性能优化和错误调试。在实际开发中,我们应该根据具体场景选择合适的异步处理方式,确保应用的流畅性和响应性。