深入理解 Event Loop:JavaScript 的“心脏起搏器”

深入理解 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 是单线程的,那为什么我们能:

  1. 发起网络请求(axios.get)而不卡死页面?
  2. 设置定时器(setTimeout)在几秒后执行?
  3. 响应用户的点击事件,同时还在播放视频?

答案:因为 JavaScript 运行环境(浏览器或 Node.js)不仅仅是 JS 引擎,它还提供了Web APIs(如定时器、DOM 事件、网络请求)。

Event Loop(事件循环) 就是协调 JS 主线程 和 Web APIs 之间工作的"调度员"。它确保了异步任务能在合适的时机回到主线程执行,而不会阻塞主线程。

二、Event Loop 的核心组件

  1. 📚 调用栈 (Call Stack)

    • 定义:一个后进先出(LIFO)的栈结构,用于存储当前正在执行的函数。
    • 行为:
      • 函数被调用 → 压入栈顶。
      • 函数执行完毕 → 弹出栈顶。
      • 只有当调用栈为空时,Event Loop 才会去任务队列里取任务。
  2. 🌐 Web APIs (浏览器提供的能力)

    • 定义:浏览器内核提供的功能,不属于 JS 引擎本身。
    • 例子:setTimeout, setInterval, fetch/Ajax, DOM 事件监听, IntersectionObserver。
    • 行为:当 JS 代码调用这些 API 时,它们会被交给浏览器处理,JS 主线程继续往下走,不会等待。
  3. 🚦 任务队列 (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 在执行完当前宏任务后,会清空整个微任务队列,然后再去取下一个宏任务。
  4. 🔄 Event Loop (事件循环本身)

    • 工作流程(简化版):
    1. 执行调用栈中的所有同步代码。
    2. 当调用栈为空时,检查微任务队列。
    3. 如果有微任务:依次执行所有微任务,直到微任务队列为空。(注意:微任务执行中产生的新微任务也会被立刻执行!)
    4. 如果微任务队列空了:尝试进行 UI 渲染(如果需要)。
    5. 从宏任务队列中取出第一个任务,放入调用栈执行。
    6. 重复步骤 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,浏览器会卡死。

实际上:

  1. 循环同步执行,1000 次 push 完成。
  2. Vue 标记这 1000 次变化为"待更新"。
  3. 同步代码结束。
  4. 微任务触发,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 定时器、异步请求回调
性能影响 阻塞主线程 过多会导致宏任务饥饿 正常调度
相关推荐
GIS程序猿2 小时前
批量出图工具,如何使用C#实现动态文本
开发语言·arcgis·c#·arcgis插件·gis二次开发
dzl843942 小时前
mac 安装python
开发语言·python·macos
北风toto2 小时前
JDK8(JAVA)供应商说明
java·开发语言
量子物理学2 小时前
四、C#高级进阶语法——委托(Delegate)
开发语言·c#
WebInfra2 小时前
Modern.js 3.0 发布:聚焦 Web 框架,拥抱生态发展
前端·javascript·前端框架
上下求索,莫负韶华2 小时前
java-(double,BigDecimal),sql-(decimal,nuermic)
java·开发语言·sql
敲敲了个代码2 小时前
浏览器时间管理大师:深度拆解 5 大核心调度 API
前端·javascript·学习·web
JobDocLS2 小时前
C++重要知识点相关代码
开发语言·c++
张飞飞飞飞飞2 小时前
python——Nuitka打包
开发语言·python