你好呀,掘金的各位小伙伴们!👋
今天我们要来聊一个老生常谈,但每次提起都能让人“掉几根头发”的话题 —— Event Loop(事件循环)。
你是不是也曾在面试中被面试官那迷离的眼神注视着,问道:
“同学,你能告诉我
setTimeout、Promise、async/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:
- 解析 HTML 📄:生成 DOM 树。
- 计算样式 💅:构建 CSSOM 树。
- 布局 (Layout) 📐:计算元素的位置和大小,生成 Layout Tree。
- 分层 (Layer) 🍰:处理图层。
- 绘制 (Paint) 🖌️:生成绘制指令。
- 执行 JavaScript ⚡:处理业务逻辑、交互等。
划重点:⚠️ 渲染和 JS 执行是互斥的! 也就是说,主线程在执行 JS 的时候,就不能进行渲染;在渲染的时候,就不能执行 JS。它就像一个单核 CPU,同一时间只能做一件事。
🤔 第二部分:为什么 JS 必须是单线程?
你可能会问:“既然主线程这么忙,为什么不多招几个人(多线程)一起干呢?”
想象一下,如果 JS 是多线程的:
- 线程 A 想把某个 DOM 节点删掉 ❌。
- 线程 B 想给同一个 DOM 节点添加子元素 ➕。
- 这时候浏览器该听谁的?🤷♂️
为了避免这种复杂的同步问题,JS 从诞生之初就设计为单线程。简单、高效,但副作用就是——容易堵车。🚗🚕🚙
🔄 第三部分:Event Loop —— 永不休止的循环
既然是单线程,如果遇到耗时的任务怎么办?比如:
- 网络请求(要等几秒钟)⏳
- 定时器(要等几秒钟)⏲️
- 用户点击(不知道什么时候点)🖱️
如果主线程傻傻地等着这些任务完成,那页面早就卡死了!😱
为了解决这个问题,浏览器引入了 消息队列 (Message Queue) 和 事件循环 (Event Loop) 机制。
3.1 浏览器的“排队”策略
我们可以把主线程看作是一个永不停歇的检票员 👮♂️。
- 同步任务:就像是买了 VIP 票的乘客,直接在主线程上执行,立即处理。
- 异步任务:
- 主线程发起异步任务(比如
setTimeout或fetch)。 - 相应的其他线程(如定时器线程、网络线程)去处理这些耗时操作。
- 一旦处理完成(比如时间到了、数据回来了),这些线程会把回调函数包装成一个任务,扔进消息队列里排队。
- 主线程发起异步任务(比如
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(整体代码)、setTimeout、setInterval、I/O、UI 交互事件、postMessage等。 - 特点:每次 Event Loop 循环只执行一个宏任务。
4.2 🐇 微任务 (Micro Task)
微任务是 VIP 中的 VIP,它不需要去普通的消息队列排队,而是有一个专门的微任务队列。
- 来源:
Promise.then/catch/finally、async/await、MutationObserver、queueMicrotask。 - 特点:在当前宏任务执行结束后,下一次渲染之前,会立即清空所有的微任务! 🧹
4.3 🔄 完整的 Event Loop 流程
- 执行一个宏任务(最开始是
script整体代码)。 - 遇到同步代码:直接执行。
- 遇到微任务:放入微任务队列。
- 遇到宏任务:交给其他模块处理,处理完放入宏任务队列。
- 当前宏任务执行完毕。
- 检查微任务队列:
- 如果有微任务,依次执行所有微任务,直到队列为空。(如果在执行微任务的过程中又产生了新的微任务,也会在这一轮里被执行掉!无限套娃警告 ⚠️)
- 尝试进行页面渲染 (UI Rendering) 🎨。(并不是每次循环都会渲染,通常 60Hz 频率下每 16.6ms 渲染一次)。
- 开始下一轮 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>
🕵️♂️ 详细执行步骤解析
我们将整个执行过程分为三个阶段:
- 第一轮宏任务(Script 整体代码)执行
- 清空微任务队列
- 第二轮宏任务(如果有)
🎬 第一阶段:执行主线程同步代码(Script 宏任务)
- Line 10:
console.log('同步代码 1')- 👉 输出:
'同步代码 1'
- 👉 输出:
- Line 12:
setTimeout(..., 0)- 这是一个宏任务。浏览器将回调函数交给定时器线程。因为是 0ms,它会尽快被放入宏任务队列。
- 🏗️ 宏任务队列:
[setTimeout1_callback]
- Line 19:
new Promise(...)- 注意:
Promise的构造函数是同步执行的! console.log('Promise 构造函数')👉 输出:'Promise 构造函数'resolve():Promise 状态变为 Resolved。console.log('Promise 构造函数内 resolve 后')👉 输出:'Promise 构造函数内 resolve 后'
- 注意:
- Line 25:
promise1.then(...)- 这是一个微任务。因为
promise1已经 resolve 了,回调函数被放入微任务队列。 - 🧬 微任务队列:
[promise1_then_callback]
- 这是一个微任务。因为
- 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]
- Line 41:
console.log('同步代码 2')- 👉 输出:
'同步代码 2'
- 👉 输出:
- Line 43:
queueMicrotask(...)- 直接添加一个微任务。
- 🧬 微任务队列:
[promise1_then_callback, async_await_callback, queueMicrotask_callback]
- Line 48-53:
MutationObserverdiv.setAttribute修改了 DOM,触发了观察者。这是一个微任务。- 🧬 微任务队列:
[..., queueMicrotask_callback, mutation_observer_callback]
🏁 第一阶段小结: 控制台输出:
同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2
当前队列状态:
- 微任务队列:
[promise1.then, async_await, queueMicrotask, MutationObserver] - 宏任务队列:
[setTimeout1]
🧹 第二阶段:清空微任务队列
Script 宏任务执行完了,现在主线程空了。Event Loop 检查微任务队列,发现一大堆任务,开始依次执行。
- 执行
promise1.then回调:console.log('Promise.then 1')👉 输出:'Promise.then 1'setTimeout(..., 0):产生一个新的宏任务!放入宏任务队列。- 🏗️ 宏任务队列:
[setTimeout1, setTimeout_inside_then]
- 执行
asyncFn中await后的代码:console.log('await 后微任务')👉 输出:'await 后微任务'
- 执行
queueMicrotask回调:console.log('queueMicrotask 微任务')👉 输出:'queueMicrotask 微任务'
- 执行
MutationObserver回调:console.log('MutationObserver 微任务')👉 输出:'MutationObserver 微任务'
🏁 第二阶段小结: 微任务队列清空了!🎉 控制台新增输出:
Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务
🎬 第三阶段:执行下一个宏任务
微任务清空后,浏览器可能会进行渲染(Render)。然后 Event Loop 再次转动,去宏任务队列取任务。
-
取出
setTimeout1的回调:console.log('setTimeout 1')👉 输出:'setTimeout 1'Promise.resolve().then(...):注意! 这里又产生了一个微任务!- 这个微任务会立刻被加入微任务队列。
- 🧬 微任务队列:
[setTimeout1_microtask] - 当前宏任务执行完毕。
-
再次检查微任务队列(你以为完了?并没有!):
- 发现刚才新产生的微任务
[setTimeout1_microtask]。 - 立即执行!
console.log('setTimeout 1 内部微任务')👉 输出:'setTimeout 1 内部微任务'
- 发现刚才新产生的微任务
-
取出
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
📝 第六部分:总结与避坑指南
通过上面的分析,我们可以总结出几条黄金法则:
- JS 主线程是单线程的,依靠 Event Loop 搞定异步。
- 同步代码优先:所有同步代码都在第一个宏任务(Script)中执行。
- 微任务插队:宏任务执行完,必须清空微任务队列,才能去执行下一个宏任务。
- Promise 构造函数是同步的,
then才是微任务。 await是分水岭:await右边是同步,下面是微任务。
💡 为什么懂这个很重要?
- 性能优化:如果你在微任务里写了死循环或者巨量计算,会导致页面卡死,因为宏任务(如渲染、点击响应)永远没机会执行!这叫“微任务阻塞”。
- Bug 排查:理解执行顺序,才能知道为什么你的数据没更新,或者为什么 DOM 还没渲染出来代码就报错了。
希望这篇文章能帮你彻底打通 Event Loop 的任督二脉!下次面试,请自信地告诉面试官:“我不仅知道结果,我还知道浏览器底层是怎么跑的!” 😎
本文代码示例基于 Chrome 浏览器环境,不同浏览器或 Node.js 版本可能存在细微差异,但标准模型大同小异。