当前位置: 首页 > news >正文

JavaScript 事件循环(Event Loop) 的运作流程(附:queueMicrotask() 将一个回调函数立即排队到微任务队列中)

JavaScript通过事件循环机制实现异步操作,核心在于调用栈、宏任务和微任务的协同工作。


同步代码优先执行,宏任务(如setTimeout)进入宏任务队列,微任务(如Promise.then)进入微任务队列。


每次事件循环先执行一个宏任务,随后清空所有微任务队列,再执行UI渲染(浏览器环境)。


关键特点是微任务优先级高于宏任务,且会立即全部执行完毕。


典型执行顺序为:同步代码→微任务→UI渲染→下一个宏任务。


记忆口诀如下:


同步代码先跑完,宏任务微任务排好队;
一次一个宏任务,微任务插队宏任务后;
微任务清空重渲染,下个宏任务接着搞。


掌握这一机制可准确预测异步代码执行顺序。


queueMicrotask() 将一个回调函数立即排队到微任务队列(Microtask Queue)中。


JavaScript 事件循环(Event Loop) 的运作流程


JavaScript 是单线程语言,这意味着它同一时间只能执行一个任务。


为了处理耗时操作(如网络请求、定时器、文件读写)而不阻塞主线程导致页面卡死,JavaScript 引入了事件循环(Event Loop)机制。


理解事件循环的核心在于掌握调用栈(Call Stack)宏任务(Macro Task)微任务(Micro Task)的执行顺序。


1. 核心概念组件

在深入流程前,先了解三个关键角色:

组件作用特点
调用栈 (Call Stack)执行同步代码的地方。后进先出 (LIFO)。函数调用入栈,执行完毕出栈。栈清空代表同步代码执行完。
宏任务队列 (Macrotask Queue)存放宏任务的队列。每次事件循环只取出一个宏任务执行。
微任务队列 (Microtask Queue)存放微任务的队列。优先级更高。在当前宏任务结束后,会一次性清空队列中所有微任务。

2. 任务分类:宏任务 vs 微任务

正确区分任务是预测执行顺序的关键。


🟢 宏任务 (Macro Tasks)

通常由宿主环境(浏览器或 Node.js)发起,粒度较大。

  • 包含
    • 整体代码脚本 (<script>)
    • setTimeout
    • setInterval
    • I/O 操作 (文件读写、网络请求回调)
    • UI 渲染 (浏览器特有,通常在微任务后、下一个宏任务前)
    • setImmediate(Node.js 特有)
    • postMessage

🔵 微任务 (Micro Tasks)

通常由 JavaScript 引擎内部发起,粒度小,优先级高。

  • 包含
    • Promise.then/Promise.catch/Promise.finally
    • MutationObserver(监听 DOM 变化)
    • queueMicrotask()
    • process.nextTick(Node.js 特有,优先级甚至高于其他微任务)

注意async/await本质上是Promise的语法糖,await后面的代码相当于放在Promise.then中,因此属于微任务


3. 事件循环的详细运作流程

事件循环是一个无限循环,其标准执行步骤如下:

  1. 执行同步代码

    • 将整体脚本作为第一个宏任务推入调用栈。
    • 依次执行栈中的同步代码。
    • 遇到宏任务(如setTimeout):将其回调函数注册到宏任务队列
    • 遇到微任务(如Promise.then):将其回调函数注册到微任务队列
  2. 调用栈清空

    • 当当前宏任务(包括其内部所有同步代码)执行完毕,调用栈变为空。
  3. 检查并执行微任务(关键步骤)

    • 事件循环检查微任务队列
    • 如果队列不为空,则依次取出所有微任务并推入调用栈执行,直到微任务队列完全清空
    • 注意:如果在执行微任务过程中又产生了新的微任务,它们也会被立即执行(只要队列没空)。
  4. UI 渲染(浏览器环境)

    • 微任务清空后,浏览器可能会进行一次 UI 渲染(更新页面视图)。
    • 注:这一步不是规范强制的,但通常发生在宏任务之间。
  5. 取出下一个宏任务

    • 宏任务队列中取出最早进入的一个任务,推入调用栈执行。
  6. 重复循环

    • 回到步骤 2,周而复始。

4. 图解执行顺序

[开始] ↓ 执行当前宏任务 (同步代码) ├─ 遇到宏任务 → 放入 [宏任务队列] └─ 遇到微任务 → 放入 [微任务队列] ↓ [当前宏任务执行完毕,调用栈清空] ↓ 检查 [微任务队列] 是否为空? ├─ 否 → 依次执行所有微任务 (若产生新微任务则继续执行),直到清空 └─ 是 → 跳过 ↓ (可选) 浏览器进行 UI 渲染 ↓ 检查 [宏任务队列] 是否为空? ├─ 否 → 取出队首的一个宏任务执行,回到上方 └─ 是 → 等待新任务 (空闲)

5. 经典代码示例与解析

让我们通过一段代码来验证上述流程:

console.log('1. 脚本开始 (同步)'); setTimeout(() => { console.log('2. setTimeout 回调 (宏任务)'); Promise.resolve().then(() => { console.log('3. setTimeout 内部的 Promise (微任务)'); }); }, 0); Promise.resolve().then(() => { console.log('4. 外层 Promise (微任务)'); }); console.log('5. 脚本结束 (同步)');

执行推导过程:

  1. 同步执行

    • 打印1. 脚本开始 (同步)
    • 遇到setTimeout:将其回调(包含日志2和3的逻辑)放入宏任务队列
    • 遇到Promise.then:将其回调(日志4)放入微任务队列
    • 打印5. 脚本结束 (同步)
    • 同步代码执行完毕,调用栈清空。
  2. 检查微任务队列

    • 发现有一个微任务(日志4)。
    • 执行它:打印4. 外层 Promise (微任务)
    • 微任务队列清空。
  3. 准备下一个宏任务

    • 从宏任务队列取出setTimeout的回调。
    • 执行该回调(作为一个新的宏任务)。
    • 打印2. setTimeout 回调 (宏任务)
    • 在该回调内部遇到Promise.then:将其回调(日志3)放入微任务队列
    • 该宏任务内部同步代码执行完毕,调用栈清空。
  4. 再次检查微任务队列

    • 发现有一个微任务(日志3)。
    • 执行它:打印3. setTimeout 内部的 Promise (微任务)
    • 微任务队列清空。
  5. 宏任务队列为空,循环等待。

最终输出顺序:

1. 脚本开始 (同步) 5. 脚本结束 (同步) 4. 外层 Promise (微任务) 2. setTimeout 回调 (宏任务) 3. setTimeout 内部的 Promise (微任务)

6. 常见误区与注意事项

  1. 微任务会“插队”
    微任务不是在“所有宏任务之后”执行,而是在每一个宏任务执行完毕后、下一个宏任务开始前立即执行。这意味着如果一个宏任务内部不断产生微任务,后续的宏任务会被一直推迟。

  2. UI 渲染的时机
    在浏览器中,JS 执行完一个宏任务并清空微任务后,浏览器有机会进行渲染。

    • 如果你在一个宏任务中修改了 DOM,紧接着又在微任务中修改了 DOM,浏览器通常只会渲染最后一次的状态(因为微任务执行完才渲染),这有助于性能优化。
    • 如果需要强制在微任务执行前渲染(极少见),可以使用requestAnimationFrame(它通常被视为一种特殊的宏任务或在该阶段触发)。
  3. Node.js 的特殊性

    • Node.js 中process.nextTick的优先级高于Promise微任务。
    • Node.js 的事件循环阶段(Timers, Pending Callbacks, Poll, Check 等)比浏览器更复杂,但在“宏任务间隙清空微任务”这一核心规则上与浏览器一致

总结

记住这句口诀:

同步代码先跑完,宏任务微任务排好队;
一次一个宏任务,微任务插队宏任务后;
微任务清空重渲染,下个宏任务接着搞。

掌握这个流程,你就能准确预测任何异步 JavaScript 代码的执行顺序。


queueMicrotask()


queueMicrotask()是 JavaScript 中的一个全局方法,用于将一个回调函数立即排队到微任务队列(Microtask Queue)中


它是现代 JavaScript(ES2020+ 标准)提供的一种更简洁、更直接的方式来调度微任务,无需像以前那样必须通过Promise.resolve().then(...)来“曲线救国”。


1. 核心作用

当你调用queueMicrotask(callback)时:

  1. 立即排队callback函数会被放入当前的微任务队列。
  2. 延迟执行:它不会立即运行,而是等到当前同步代码执行完毕,且当前宏任务结束后,在下一个宏任务开始之前执行。
  3. 高优先级:它的执行优先级高于setTimeout等宏任务,与Promise.then处于同一梯队。

2. 语法

queueMicrotask(callback);
  • callback: 当微任务队列被刷新时要执行的函数。该函数不接受任何参数,也没有返回值(返回值会被忽略)。

3. 与Promise.resolve().then()的对比

queueMicrotask出现之前,开发者通常使用Promise来创建微任务

特性queueMicrotask(fn)Promise.resolve().then(fn)
语义清晰度。明确表明“我要调度一个微任务”。。看起来像是在处理异步结果,实则是为了利用其微任务机制。
性能开销略低。直接操作微任务队列,无需创建 Promise 对象。略高。需要实例化一个 Promise 对象,产生少量内存和计算开销。
错误处理如果回调抛出错误,会作为未捕获异常处理(类似宏任务中的错误)。可以通过.catch()优雅地捕获错误。
兼容性现代浏览器 (Chrome 71+, Firefox 69+, Safari 12.1+) 和 Node.js 11+。所有支持 ES6 的环境。

结论:如果你只需要调度一个微任务而不涉及 Promise 链式调用或状态管理,queueMicrotask是更优的选择


4. 代码示例

场景:在同步代码后、渲染前执行逻辑
console.log('1. 同步代码开始'); // 使用 queueMicrotask queueMicrotask(() => { console.log('2. queueMicrotask 回调 (微任务)'); }); // 等价于上面的 Promise 写法 Promise.resolve().then(() => { console.log('3. Promise.then 回调 (微任务)'); }); console.log('4. 同步代码结束'); // 输出顺序: // 1. 同步代码开始 // 4. 同步代码结束 // 2. queueMicrotask 回调 (微任务) // 3. Promise.then 回调 (微任务)

注意:微任务队列是先进先出(FIFO)的,所以queueMicrotask注册的回调会比后面注册的Promise.then先执行。


5. 实际应用场景

  1. 批量更新优化
    当你有多个地方可能触发同一个耗时的更新操作(如重新计算布局、发送网络请求),但你希望在一个事件循环中只执行一次

    let needsUpdate = false; function scheduleUpdate() { if (!needsUpdate) { needsUpdate = true; queueMicrotask(() => { performHeavyUpdate(); // 只执行一次 needsUpdate = false; }); } } // 无论调用多少次 scheduleUpdate,performHeavyUpdate 在本轮循环只跑一次 scheduleUpdate(); scheduleUpdate(); scheduleUpdate();
  2. 库开发
    框架(如 Vue, React)或工具库内部需要在当前操作完成后立即清理副作用或通知观察者,但又不想阻塞当前同步流程,使用queueMicrotasksetTimeout更高效且时机更精准。

  3. 避免竞态条件
    确保某些清理工作或状态同步在所有同步修改完成后立即发生,防止中间状态被读取。

6. 注意事项

  • 错误捕获如果queueMicrotask的回调函数中抛出错误,它不会像 Promise 那样可以通过.catch()捕获。它会变成一个未捕获的异常,通常会打印到控制台并可能终止脚本(取决于环境)。如果需要错误处理,建议在回调内部使用try...catch
    queueMicrotask(() => { try { riskyOperation(); } catch (e) { handleError(e); } });
  • 执行时机:它是在当前宏任务结束后立即执行。如果当前宏任务非常长,微任务也会相应推迟。

总结

queueMicrotask()是 JavaScript 事件循环机制中的一把“瑞士军刀”,它提供了一种轻量、语义明确的方式来安排高优先级的异步任务。对于现代前端开发,尤其是涉及复杂状态管理和性能优化的场景,它是一个非常有用的工具。

http://www.jsqmd.com/news/521330/

相关文章:

  • 别再瞎调了!手把手教你用ISO 376标准搞定力传感器校准(附完整流程与避坑点)
  • AVX指令集实战指南:从基础算术到高级向量操作(附中文函数速查表)
  • Qwen3-ForcedAligner-0.6B高性能调优:CUDA Graphs加速ForcedAligner推理
  • 小白也能玩转mPLUG视觉问答:本地图片分析,效果惊艳,操作简单
  • Qwen3-32B-Chat数学推理效果集:微积分推导、算法题解与步骤可解释性展示
  • 用Python从零实现占据栅格地图:逆传感器模型与对数概率的代码优化技巧
  • 信息学奥赛高频考点解析:从洛谷B2145题深入理解digit函数的设计技巧
  • 从零到一:IKFast插件配置的避坑指南与实战优化
  • VBA——02篇(实战篇——从语法到自动化第一步)
  • XantoI2C软件I²C库:Arduino多总线扩展与精准时序控制
  • 当SAR遇见光学:拆解一个顶会级云去除网络,看多模态融合如何成为遥感新宠
  • KiCad 6.0.x第二版编译结果
  • 黑丝空姐-造相Z-Turbo镜像体验:一键启动,专注创意而非配置
  • OpenClaw技能开发:为ollama-QwQ-32B编写自定义Python工具
  • 使用AIVideo和STM32CubeMX开发嵌入式视频监控系统
  • UE4导航网格实战:如何用NavMeshBoundsVolume和NavModifierVolume打造智能AI寻路系统
  • OneAPI向量数据库扩展:接入Milvus/PGVector实现RAG增强
  • 从原理到实战:Linux内核Tracepoint的深度剖析与应用指南
  • 业务数据分析选哪种?参数估计vs非参数估计的7个实战场景对比
  • FlaUI实战:如何高效捕获WinForm和WPF窗体(附避坑指南)
  • Rust入门避坑指南:新手用Cargo创建第一个项目常犯的5个错误及解决方法
  • 基于LSTM改进的CTC语音唤醒模型时序处理能力分析
  • Visual Studio项目打包实战:从代码到可安装客户端的完整指南
  • 别再手动填Token了!Knife4j 4.4.0集成OAuth2密码模式,实现一键授权
  • VIVADO 2023.1闪退后Launcher Time Out?360误杀恢复全记录
  • EZPROM:嵌入式EEPROM面向对象管理库
  • Qwen-VL效果实测分享:Qwen-Image镜像在OCR增强型图文问答任务中的准确率表现
  • Nanbeige 4.1-3B效果展示:流式渲染延迟测试(CPU/GPU/量化版)对比数据图
  • Python实战:手把手教你用cell2location分析空间单细胞转录组数据(附完整代码)
  • 嵌入式C语言底层机制与内存级优化实践