在上一篇关于 JS 事件循环的文章中,我们提到 “微任务优先级高于宏任务” 这一核心结论,但对于微任务本身的细节并未展开。作为事件循环中 “优先级最高的异步任务”,微任务的执行机制直接影响代码逻辑的正确性,比如Promise.then
的触发时机、async/await
的阻塞逻辑等,都与微任务密切相关。今天我们就聚焦微任务,从本质、类型、执行机制到实战误区,进行全方位拆解。
一、微任务的本质:为什么它比宏任务 “更快”?
首先要明确一个核心问题:同样是异步任务,为什么微任务的优先级高于宏任务?这需要从微任务的设计初衷和执行时机说起。
1. 微任务的定义
微任务(Microtask)是 JS 事件循环中一种特殊的异步任务,它的核心特征是:在当前宏任务执行完毕后、下一个宏任务开始前执行,且会 “阻塞” 下一个宏任务,直到所有微任务执行完毕。
简单来说,微任务是为了处理 “需要在当前同步代码结束后、页面重新渲染前快速执行的轻量级异步操作”,比如 Promise 的状态回调、DOM 更新后的后续处理等。相比宏任务(如setTimeout
,需要等待浏览器的定时器模块触发),微任务的执行更 “急切”,不需要等待额外的浏览器模块调度,直接在 JS 引擎内部完成排队和执行。
2. 微任务的 “快” 体现在哪里?
我们用一个对比案例直观感受:
// 宏任务:setTimeoutsetTimeout(() => {console.log("macro task"); // 宏任务回调}, 0);// 微任务:Promise.thenPromise.resolve().then(() => {console.log("micro task"); // 微任务回调});console.log("sync code"); // 同步代码
最终输出顺序是:sync code
→ micro task
→ macro task
。
原因在于:
-
同步代码执行完毕后,调用栈为空;
-
事件循环先检查微任务队列,执行
Promise.then
回调; -
微任务队列清空后,才检查宏任务队列,执行
setTimeout
回调。
这就是微任务 “快” 的本质:它穿插在两个宏任务之间,优先占用 “宏任务间隙” 的执行时间。
二、常见微任务类型:这些操作都属于微任务
在实际开发中,我们常用的微任务主要有以下 4 类,需要准确识别,避免混淆:
1. Promise 相关回调(最常用)
Promise
的then
、catch
、finally
方法注册的回调,是最典型的微任务。需要注意的是:Promise 构造函数内部的代码是同步的,只有回调函数才是微任务。
示例:
new Promise((resolve, reject) => {console.log("同步代码:Promise构造函数内"); // 同步执行resolve("成功"); // 触发then回调}).then((res) => {console.log("微任务:", res); // 微任务,同步代码执行完后触发}).catch((err) => {console.log("微任务:", err); // 微任务,仅在reject时触发});
2. async/await(语法糖本质)
async/await
是 ES2017 引入的异步语法糖,其本质是基于 Promise 实现的,因此await
后面的代码也属于微任务。
需要重点理解await
的执行逻辑:
-
await
会 “暂停” 当前async
函数的执行,先执行await
后面的表达式; -
如果表达式返回一个 Promise,会等待 Promise resolve 后,将
await
后续的代码(即 “恢复执行” 的逻辑)加入微任务队列; -
如果表达式返回非 Promise 值,会直接将后续代码加入微任务队列(相当于
Promise.resolve(非Promise值).then(后续代码)
)。
示例:
async function asyncFn() {console.log("1:async函数内同步代码");// await后面是Promise,后续代码(console.log(3))加入微任务await Promise.resolve().then(() => {console.log("2:await内部的微任务");});console.log("3:await后续代码(微任务)");}asyncFn();console.log("4:外部同步代码");
输出顺序:1
→ 4
→ 2
→ 3
。
解析:await
会先让外部同步代码执行(输出 4),再执行内部微任务(输出 2),最后执行await
后续的微任务(输出 3)。
3. queueMicrotask(显式创建微任务)
queueMicrotask
是 ES2022 引入的 API,用于显式地将一个函数加入微任务队列,功能与Promise.resolve().then(函数)
一致,但代码更简洁,语义更明确。
示例:
console.log("同步代码");queueMicrotask(() => {console.log("显式创建的微任务");});// 输出:同步代码 → 显式创建的微任务
使用场景:当你需要确保一段代码在当前同步代码结束后、下一个宏任务前执行,且不想通过 Promise 间接实现时,queueMicrotask
是更优选择。
4. MutationObserver(DOM 监听相关)
MutationObserver
用于监听 DOM 元素的变化(如节点新增、属性修改、文本变化等),当 DOM 发生变化时,它的回调函数会被加入微任务队列。
示例:
// 创建一个DOM元素const div = document.createElement("div");// 监听div的文本变化const observer = new MutationObserver((mutations) => {console.log("微任务:DOM发生变化", mutations[0].target.textContent);});observer.observe(div, { childList: true, characterData: true, subtree: true });// 修改DOM文本(同步操作)div.textContent = "Hello Microtask";console.log("同步代码:DOM修改完成");
输出顺序:同步代码:DOM修改完成
→ 微任务:DOM发生变化 Hello Microtask
。
解析:DOM 修改是同步操作,但MutationObserver
的回调会延迟到微任务中执行,避免频繁触发回调导致性能问题。
三、微任务的执行机制:3 个核心规则
理解微任务的执行机制,需要记住 3 个核心规则,这是解决复杂异步问题的关键:
规则 1:微任务队列 “先进先出”,且会一次性清空
当调用栈为空时,事件循环会依次取出微任务队列中的任务执行,直到队列完全为空,不会中途切换到宏任务。即使在执行微任务的过程中新增了新的微任务,也会加入当前队列的末尾,等待本次 “微任务清空阶段” 执行。
示例:
Promise.resolve().then(() => {console.log("微任务1");// 执行微任务1时,新增微任务2Promise.resolve().then(() => {console.log("微任务2");});});Promise.resolve().then(() => {console.log("微任务3");});console.log("同步代码");
输出顺序:同步代码
→ 微任务1
→ 微任务3
→ 微任务2
。
解析:
-
同步代码执行完后,微任务队列初始有两个任务:[微任务 1, 微任务 3];
-
执行微任务 1 时,新增微任务 2,队列变为 [微任务 3, 微任务 2];
-
继续执行队列中的微任务 3,最后执行微任务 2,直到队列清空。
规则 2:微任务在 “当前宏任务结束后” 执行
这里的 “当前宏任务” 指的是:
-
如果是全局代码,“当前宏任务” 就是整个
script
标签的代码; -
如果是宏任务回调(如
setTimeout
回调),“当前宏任务” 就是该回调函数的代码。
简单来说:一个宏任务执行完毕后,必须先清空所有微任务,才能开始下一个宏任务。
示例:
// 宏任务1:script标签全局代码console.log("宏任务1:同步代码");// 微任务1:在宏任务1内注册Promise.resolve().then(() => {console.log("微任务1:宏任务1结束后执行");});// 宏任务2:setTimeout回调setTimeout(() => {console.log("宏任务2:同步代码");// 微任务2:在宏任务2内注册Promise.resolve().then(() => {console.log("微任务2:宏任务2结束后执行");});}, 0);
输出顺序:宏任务1:同步代码
→ 微任务1:宏任务1结束后执行
→ 宏任务2:同步代码
→ 微任务2:宏任务2结束后执行
。
解析:宏任务 1 执行完后,先清空微任务 1,再执行宏任务 2;宏任务 2 执行完后,再清空微任务 2。
规则 3:微任务不会阻塞当前同步代码
微任务虽然优先级高,但它仍然是 “异步任务”,不会阻塞当前同步代码的执行。只有当当前同步代码执行完毕、调用栈为空时,微任务才会开始执行。
示例:
console.log("同步代码1");Promise.resolve().then(() => {console.log("微任务");});console.log("同步代码2");
输出顺序:同步代码1
→ 同步代码2
→ 微任务
。
解析:注册微任务后,JS 引擎会继续执行后续的同步代码(输出 “同步代码 2”),直到同步代码执行完、调用栈为空,才会执行微任务。
四、微任务与宏任务的核心差异(对比表)
为了更清晰地理解微任务,我们将它与宏任务的关键差异整理成表格,方便对比记忆:
对比维度 | 微任务(Microtask) | 宏任务(Macrotask) |
---|---|---|
常见类型 | Promise.then/catch/finally、async/await、queueMicrotask、MutationObserver | setTimeout、setInterval、DOM 事件、script 标签、postMessage、fetch(回调) |
执行时机 | 当前宏任务结束后、下一个宏任务开始前 | 所有微任务清空后 |
执行优先级 | 高(先于宏任务) | 低(后于微任务) |
队列处理方式 | 一次性清空所有任务 | 每次只执行一个任务,执行后检查微任务 |
是否阻塞页面渲染 | 可能(微任务执行时,页面会等待其完成再渲染) | 不会(宏任务执行前,页面可能已完成渲染) |
五、实战避坑:微任务的 3 个常见误区
在实际开发中,很多开发者会因为对微任务的理解不深入,写出不符合预期的代码。以下是 3 个最常见的误区,需要重点规避:
误区 1:认为 “await 会阻塞所有代码”
await
的 “暂停” 是局部的,只会暂停当前async
函数的执行,不会阻塞外部的同步代码或其他宏任务。
错误示例(预期输出:a→b→c,实际输出:a→c→b):
async function fn() {console.log("a");await Promise.resolve(); // 此处暂停fn函数,但不阻塞外部代码console.log("b"); // 微任务:需等待外部同步代码执行完}fn();console.log("c"); // 外部同步代码:先于b执行
解析:await
暂停fn
函数后,JS 引擎会继续执行外部的同步代码(输出 “c”),直到同步代码执行完,才会执行await
后续的微任务(输出 “b”)。
误区 2:混淆 “Promise 构造函数” 与 “then 回调” 的执行时机
Promise 构造函数内部的代码是同步执行的,只有then
/catch
/finally
回调才是微任务。
错误示例(预期输出:1→3→2,实际输出:1→2→3):
console.log("1:同步代码");new Promise((resolve) => {console.log("2:Promise构造函数内(同步)");resolve();}).then(() => {console.log("3:then回调(微任务)");});
解析:构造函数内的 “2” 是同步代码,会在 “1” 之后直接执行;“3” 是微任务,需等待同步代码执行完后才触发。
误区 3:认为 “多个微任务队列会按类型优先级执行”
有些开发者误以为 “不同类型的微任务有不同优先级”(如Promise.then
比queueMicrotask
先执行),但实际上,所有微任务都在同一个队列中,按 “注册顺序” 执行,与类型无关。
示例:
// 先注册queueMicrotaskqueueMicrotask(() => {console.log("微任务1:queueMicrotask");});// 后注册Promise.thenPromise.resolve().then(() => {console.log("微任务2:Promise.then");});
输出顺序:微任务1:queueMicrotask
→ 微任务2:Promise.then
。
解析:微任务队列按 “注册时间” 排序,先注册的先执行,与类型无关。
六、总结:微任务的核心要点
-
本质:微任务是 “宏任务间隙” 执行的轻量级异步任务,优先级高于宏任务,旨在快速处理后续逻辑;
-
常见类型:Promise 回调、async/await 后续代码、queueMicrotask、MutationObserver;
-
执行机制:
-
一个宏任务结束后,必须清空所有微任务,再执行下一个宏任务;
-
微任务队列按 “先进先出” 执行,执行过程中新增的微任务会追加到当前队列末尾;
-
微任务不会阻塞当前同步代码,仅在调用栈为空时执行;
- 避坑关键:区分 Promise 构造函数(同步)与回调(微任务),理解
await
的局部暂停特性,牢记微任务按注册顺序执行。
掌握微任务的核心逻辑,不仅能解决 “代码执行顺序” 问题,更能在处理复杂异步场景(如并发请求、DOM 更新后的数据处理)时,写出更高效、更可靠的代码。如果对某个微任务类型或执行场景还有疑问,不妨动手写几个示例测试,实践是理解异步逻辑的最佳方式!