深入理解 Event Loop:JavaScript 的"心脏起搏器" -- pd的前端笔记
文章目录
-
- [深入理解 Event Loop:JavaScript 的"心脏起搏器" -- pd的前端笔记](#深入理解 Event Loop:JavaScript 的“心脏起搏器” -- pd的前端笔记)
- [一、为什么需要 Event Loop?(单线程的困境)](#一、为什么需要 Event Loop?(单线程的困境))
- [二、Event Loop 的核心组件](#二、Event Loop 的核心组件)
- [三、示例流程:一次完整的 Event Loop](#三、示例流程:一次完整的 Event Loop)
- 四、深度陷阱:微任务的"滚雪球"效应
- [五、Vue 中的 Event Loop 实战](#五、Vue 中的 Event Loop 实战)
-
- [1. 为什么 nextTick 这么快?](#1. 为什么 nextTick 这么快?)
- [2. 场景:批量更新与性能优化](#2. 场景:批量更新与性能优化)
- 常见面试题与误区
-
- [❓ 问题 1:async/await 是宏任务还是微任务?](#❓ 问题 1:async/await 是宏任务还是微任务?)
- [❓ 问题 2:setTimeout(fn, 0) 真的能"立即"执行吗?](#❓ 问题 2:setTimeout(fn, 0) 真的能“立即”执行吗?)
- [❓ 问题 3:UI 渲染什么时候发生?](#❓ 问题 3:UI 渲染什么时候发生?)
一、为什么需要 Event Loop?(单线程的困境)
JavaScript 是单线程的。
这意味着,同一时间,JS 只能做一件事。就像你只有一个大脑,不能一边写代码一边炒菜(除非你像章鱼一样有多个触手,但 JS 没有)。
🤔 灵魂拷问:
如果 JS 是单线程的,那为什么我们能:
- 发起网络请求(axios.get)而不卡死页面?
- 设置定时器(setTimeout)在几秒后执行?
- 响应用户的点击事件,同时还在播放视频?
答案:因为 JavaScript 运行环境(浏览器或 Node.js)不仅仅是 JS 引擎,它还提供了Web APIs(如定时器、DOM 事件、网络请求)。
Event Loop(事件循环) 就是协调 JS 主线程 和 Web APIs 之间工作的"调度员"。它确保了异步任务能在合适的时机回到主线程执行,而不会阻塞主线程。
二、Event Loop 的核心组件
-
📚 调用栈 (Call Stack)
- 定义:一个后进先出(LIFO)的栈结构,用于存储当前正在执行的函数。
- 行为:
- 函数被调用 → 压入栈顶。
- 函数执行完毕 → 弹出栈顶。
- 只有当调用栈为空时,Event Loop 才会去任务队列里取任务。
-
🌐 Web APIs (浏览器提供的能力)
- 定义:浏览器内核提供的功能,不属于 JS 引擎本身。
- 例子:setTimeout, setInterval, fetch/Ajax, DOM 事件监听, IntersectionObserver。
- 行为:当 JS 代码调用这些 API 时,它们会被交给浏览器处理,JS 主线程继续往下走,不会等待。
-
🚦 任务队列 (Task Queues)
- 当 Web APIs 完成任务(如定时器时间到、网络请求返回)后,它们的回调函数会被放入任务队列。这里有一个关键的分野:任务队列分为两种!
- A. 宏任务队列 (Macrotask Queue / Task Queue)
- 包含:setTimeout, setInterval, I/O 操作, UI 渲染, setImmediate (Node.js)。
- 特点:每次 Event Loop 只取一个宏任务执行。
- B. 微任务队列 (Microtask Queue / Job Queue)
- 包含:Promise.then/catch/finally, MutationObserver, queueMicrotask, Vue 的 nextTick。
- 特点:优先级高于宏任务。每次 Event Loop 在执行完当前宏任务后,会清空整个微任务队列,然后再去取下一个宏任务。
-
🔄 Event Loop (事件循环本身)
- 工作流程(简化版):
- 执行调用栈中的所有同步代码。
- 当调用栈为空时,检查微任务队列。
- 如果有微任务:依次执行所有微任务,直到微任务队列为空。(注意:微任务执行中产生的新微任务也会被立刻执行!)
- 如果微任务队列空了:尝试进行 UI 渲染(如果需要)。
- 从宏任务队列中取出第一个任务,放入调用栈执行。
- 重复步骤 2-5。
三、示例流程:一次完整的 Event Loop
js
console.log('1. 同步代码开始')
setTimeout(() => {
console.log('2. setTimeout 回调 (宏任务)')
}, 0)
Promise.resolve().then(() => {
console.log('3. Promise.then 回调 (微任务)')
})
console.log('4. 同步代码结束')
🖨️ 最终输出顺序:
1. 同步代码开始
4. 同步代码结束
3. Promise.then 回调 (微任务)
2. setTimeout 回调 (宏任务)
四、深度陷阱:微任务的"滚雪球"效应
微任务队列有一个特性:如果在执行微任务时,又产生了新的微任务,它们会被立即执行,直到队列彻底清空。
js
console.log('Start')
Promise.resolve().then(() => {
console.log('Promise 1')
// 在微任务中又产生了一个微任务
Promise.resolve().then(() => {
console.log('Promise 1.1')
})
})
setTimeout(() => {
console.log('Timeout 1')
}, 0)
console.log('End')
执行顺序:
text
Start
End
Promise 1
Promise 1.1
Timeout 1
⚠️ 警告:如果在微任务中无限递归产生新微任务,会导致主线程卡死,页面无法响应(因为宏任务和 UI 渲染永远没机会执行)!
五、Vue 中的 Event Loop 实战
理解了 Event Loop,就能看懂很多 Vue 的"黑魔法"。
1. 为什么 nextTick 这么快?
Vue 的 nextTick 本质上是利用 微任务 实现的。
js
// Vue 内部简化逻辑
let pending = false
const p = Promise.resolve()
function nextTick(cb) {
if (!pending) {
pending = true
// 将刷新 DOM 的任务放入微任务队列
p.then(() => {
flushJobs() // 执行所有待处理的 DOM 更新
pending = false
})
}
if (cb) callbacks.push(cb)
}
使用nextTick的vue示例
js
import { ref, onMounted, nextTick } from 'vue'
const count = ref(0)
const message = ref('初始')
const updateAndLog = async () => {
count.value++
message.value = '更新后的内容'
// ✅ 推荐:等待 Vue 完成 DOM 更新
await nextTick()
// 此时 DOM 绝对已经是最新的状态了
console.log(document.querySelector('.msg').textContent)
}
当你修改数据 count.value++ 时,Vue 不会立刻更新 DOM,而是把更新任务放入一个队列,然后调用 nextTick 安排一个微任务。
- 等到当前同步代码执行完。
- 微任务执行,统一更新 DOM。
- 你的 await nextTick() 回调在这个微任务之后执行(或者作为同一个微任务的一部分)。
对比 setTimeout:
- setTimeout 要等下一个宏任务周期,中间可能还隔着 UI 渲染和其他宏任务。
- nextTick 紧贴在当前同步代码之后,最快拿到更新后的 DOM。
2. 场景:批量更新与性能优化
假设你在一个循环中修改了 1000 次数据:
js
for (let i = 0; i < 1000; i++) {
list.value.push(i)
}
如果没有 Event Loop 机制,每 push 一次就重绘一次 DOM,浏览器会卡死。
实际上:
- 循环同步执行,1000 次 push 完成。
- Vue 标记这 1000 次变化为"待更新"。
- 同步代码结束。
- 微任务触发,Vue 一次性计算差异,只重绘一次 DOM。
这就是 Event Loop 带来的批处理(Batching)优势。
常见面试题与误区
❓ 问题 1:async/await 是宏任务还是微任务?
答案:async/await 是语法糖,底层基于 Promise。
- await 后面的代码,相当于放在了 .then() 回调里。
- 所以,await 之后的代码属于微任务。
js
async function test() {
console.log('A')
await Promise.resolve()
console.log('B') // 微任务
}
test()
console.log('C')
// 输出:A -> C -> B
❓ 问题 2:setTimeout(fn, 0) 真的能"立即"执行吗?
答案:不能。
- HTML5 规范规定,setTimeout 的最小延迟通常是 4ms(老版本浏览器可能是 10ms)。
- 即使设为 0,它也要排队等宏任务。
- 如果你需要"尽可能快",请用 Promise.resolve().then(fn) 或 queueMicrotask(fn)。
❓ 问题 3:UI 渲染什么时候发生?
答案:通常在 微任务队列清空后,下一个宏任务开始前。
- 这意味着,如果你在微任务里修改了 DOM 数据,浏览器会在微任务结束后统一渲染。
- 这也是为什么 nextTick 能拿到最新 DOM 的原因:因为它是在 DOM 更新逻辑执行完后才触发的(或者说它就是触发 DOM 更新的那个微任务的一部分)。
| 特性 | 同步代码 | 微任务 (Microtask) | 宏任务 (Macrotask) |
|---|---|---|---|
| 代表 API | 普通函数调用 | Promise, nextTick, MutationObserver |
setTimeout, setInterval, I/O |
| 执行时机 | 立即 | 当前宏任务结束后,立刻清空队列 | 当前宏任务 + 所有微任务结束后,取一个 |
| 优先级 | 最高 | ⭐⭐⭐ (高) | ⭐ (低) |
| Vue 应用 | 数据修改 | DOM 更新、nextTick |
定时器、异步请求回调 |
| 性能影响 | 阻塞主线程 | 过多会导致宏任务饥饿 | 正常调度 |