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>) setTimeoutsetInterval- I/O 操作 (文件读写、网络请求回调)
- UI 渲染 (浏览器特有,通常在微任务后、下一个宏任务前)
setImmediate(Node.js 特有)postMessage
- 整体代码脚本 (
🔵 微任务 (Micro Tasks)
通常由 JavaScript 引擎内部发起,粒度小,优先级高。
- 包含 :
Promise.then/Promise.catch/Promise.finallyMutationObserver(监听 DOM 变化)queueMicrotask()process.nextTick(Node.js 特有,优先级甚至高于其他微任务)
注意 :
async/await本质上是Promise的语法糖,await后面的代码相当于放在Promise.then中,因此属于微任务。
3. 事件循环的详细运作流程
事件循环是一个无限循环,其标准执行步骤如下:
-
执行同步代码:
- 将整体脚本作为第一个宏任务推入调用栈。
- 依次执行栈中的同步代码。
- 遇到宏任务 (如
setTimeout):将其回调函数注册到宏任务队列。 - 遇到微任务 (如
Promise.then):将其回调函数注册到微任务队列。
-
调用栈清空:
- 当当前宏任务(包括其内部所有同步代码)执行完毕,调用栈变为空。
-
检查并执行微任务(关键步骤):
- 事件循环检查微任务队列。
- 如果队列不为空,则依次取出所有微任务 并推入调用栈执行,直到微任务队列完全清空。
- 注意:如果在执行微任务过程中又产生了新的微任务,它们也会被立即执行(只要队列没空)。
-
UI 渲染(浏览器环境):
- 微任务清空后,浏览器可能会进行一次 UI 渲染(更新页面视图)。
- 注:这一步不是规范强制的,但通常发生在宏任务之间。
-
取出下一个宏任务:
- 从宏任务队列 中取出最早进入的一个任务,推入调用栈执行。
-
重复循环:
- 回到步骤 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. 脚本开始 (同步)。 - 遇到
setTimeout:将其回调(包含日志2和3的逻辑)放入宏任务队列。 - 遇到
Promise.then:将其回调(日志4)放入微任务队列。 - 打印
5. 脚本结束 (同步)。 - 同步代码执行完毕,调用栈清空。
- 打印
-
检查微任务队列:
- 发现有一个微任务(日志4)。
- 执行它:打印
4. 外层 Promise (微任务)。 - 微任务队列清空。
-
准备下一个宏任务:
- 从宏任务队列取出
setTimeout的回调。 - 执行该回调(作为一个新的宏任务)。
- 打印
2. setTimeout 回调 (宏任务)。 - 在该回调内部遇到
Promise.then:将其回调(日志3)放入微任务队列。 - 该宏任务内部同步代码执行完毕,调用栈清空。
- 从宏任务队列取出
-
再次检查微任务队列:
- 发现有一个微任务(日志3)。
- 执行它:打印
3. setTimeout 内部的 Promise (微任务)。 - 微任务队列清空。
-
宏任务队列为空,循环等待。
最终输出顺序:
javascript
1. 脚本开始 (同步)
5. 脚本结束 (同步)
4. 外层 Promise (微任务)
2. setTimeout 回调 (宏任务)
3. setTimeout 内部的 Promise (微任务)
6. 常见误区与注意事项
-
微任务会"插队":
微任务不是在"所有宏任务之后"执行,而是在每一个宏任务执行完毕后、下一个宏任务开始前立即执行。这意味着如果一个宏任务内部不断产生微任务,后续的宏任务会被一直推迟。 -
UI 渲染的时机 :
在浏览器中,JS 执行完一个宏任务并清空微任务后,浏览器有机会进行渲染。- 如果你在一个宏任务中修改了 DOM,紧接着又在微任务中修改了 DOM,浏览器通常只会渲染最后一次的状态(因为微任务执行完才渲染),这有助于性能优化。
- 如果需要强制在微任务执行前渲染(极少见),可以使用
requestAnimationFrame(它通常被视为一种特殊的宏任务或在该阶段触发)。
-
Node.js 的特殊性:
- Node.js 中
process.nextTick的优先级高于Promise微任务。 - Node.js 的事件循环阶段(Timers, Pending Callbacks, Poll, Check 等)比浏览器更复杂,但在"宏任务间隙清空微任务"这一核心规则上与浏览器一致。
- Node.js 中
总结
记住这句口诀:
同步代码先跑完,宏任务微任务排好队;
一次一个宏任务,微任务插队宏任务后;
微任务清空重渲染,下个宏任务接着搞。
掌握这个流程,你就能准确预测任何异步 JavaScript 代码的执行顺序。
queueMicrotask()
queueMicrotask() 是 JavaScript 中的一个全局方法,用于将一个回调函数立即排队到微任务队列(Microtask Queue)中。
它是现代 JavaScript(ES2020+ 标准)提供的一种更简洁、更直接 的方式来调度微任务,无需像以前那样必须通过 Promise.resolve().then(...) 来"曲线救国"。
1. 核心作用
当你调用 queueMicrotask(callback) 时:
- 立即排队 :
callback函数会被放入当前的微任务队列。 - 延迟执行 :它不会立即运行,而是等到当前同步代码执行完毕 ,且当前宏任务结束后,在下一个宏任务开始之前执行。
- 高优先级 :它的执行优先级高于
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. 实际应用场景
-
批量更新优化 :
当你有多个地方可能触发同一个耗时的更新操作(如重新计算布局、发送网络请求),但你希望在一个事件循环中只执行一次
javascriptlet needsUpdate = false; function scheduleUpdate() { if (!needsUpdate) { needsUpdate = true; queueMicrotask(() => { performHeavyUpdate(); // 只执行一次 needsUpdate = false; }); } } // 无论调用多少次 scheduleUpdate,performHeavyUpdate 在本轮循环只跑一次 scheduleUpdate(); scheduleUpdate(); scheduleUpdate(); -
库开发 :
框架(如 Vue, React)或工具库内部需要在当前操作完成后立即清理副作用或通知观察者,但又不想阻塞当前同步流程,使用queueMicrotask比setTimeout更高效且时机更精准。 -
避免竞态条件 :
确保某些清理工作或状态同步在所有同步修改完成后立即发生,防止中间状态被读取。
6. 注意事项
-
错误捕获 :如果
queueMicrotask的回调函数中抛出错误,它不会 像 Promise 那样可以通过.catch()捕获。它会变成一个未捕获的异常,通常会打印到控制台并可能终止脚本(取决于环境)。如果需要错误处理,建议在回调内部使用try...catch。javascriptqueueMicrotask(() => { try { riskyOperation(); } catch (e) { handleError(e); } }); -
执行时机 :它是在当前宏任务结束后立即执行。如果当前宏任务非常长,微任务也会相应推迟。
总结
queueMicrotask() 是 JavaScript 事件循环机制中的一把"瑞士军刀",它提供了一种轻量、语义明确的方式来安排高优先级的异步任务。对于现代前端开发,尤其是涉及复杂状态管理和性能优化的场景,它是一个非常有用的工具。