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. 图解执行顺序

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

5. 经典代码示例与解析

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

javascript 复制代码
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. 宏任务队列为空,循环等待。

最终输出顺序:

javascript 复制代码
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. 语法

javascript 复制代码
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. 代码示例

场景:在同步代码后、渲染前执行逻辑
javascript 复制代码
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. 批量更新优化

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

    javascript 复制代码
    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

    javascript 复制代码
    queueMicrotask(() => {
      try {
        riskyOperation();
      } catch (e) {
        handleError(e);
      }
    });
  • 执行时机 :它是在当前宏任务结束后立即执行。如果当前宏任务非常长,微任务也会相应推迟。


总结

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

相关推荐
空中海1 小时前
01 React Native 基础、核心组件与布局体系
javascript·react native·react.js
前端之虎陈随易3 小时前
2年没用Nodejs了,Bun很香
linux·前端·javascript·vue.js·typescript
好运的阿财5 小时前
OpenClaw工具拆解之host_workspace_write+host_workspace_edit
前端·javascript·人工智能·机器学习·ai编程·openclaw·openclaw工具
XiYang-DING5 小时前
JavaScript
开发语言·javascript·ecmascript
空中海6 小时前
02 React Native状态、导航、数据流与设备能力
javascript·react native·react.js
空中海6 小时前
02 状态、Hooks、副作用与数据流
开发语言·javascript·ecmascript
空中海7 小时前
04 React Native工程化、质量、发布与生态选型
javascript·react native·react.js
杨超凡7 小时前
豆包收费了?我特么自己用“意念”搓了一个!
javascript
threelab8 小时前
Three.js 咖啡杯烟雾效果 | 三维可视化 / AI 提示词
开发语言·javascript·人工智能