JavaScript中Microtask与Macrotask的执行机制详解
本文深入剖析JavaScript的事件循环(Event Loop)机制,重点区分Microtask(微任务)与Macrotask(宏任务)的执行顺序。通过经典代码案例拆解Promise、async/await与setTimeout的交互逻辑,帮助开发者理清异步任务的队列管理规则,避免常见的执行顺序误区。
从一道“面试真题”看异步执行结果
在理解复杂的异步逻辑之前,我们先通过一段典型的JavaScript代码来直观感受现象:
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2 start');
return new Promise((resolve, reject) => {
resolve();
console.log('async2 promise');
})
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
}).then(function() {
console.log('promise3');
});
console.log('script end')
这段代码在Node.js环境中运行或在浏览器控制台执行,其输出结果完全一致。我们将结果展示如下:
如果在浏览器环境中打开该页面并查看控制台,输出效果与Node.js无异:
核心问题:为什么输出顺序是这样的?
宏观背景:Event Loop与双队列机制
JavaScript是单线程语言。为了处理异步操作,它并不像同步代码那样线性执行,而是依赖“事件循环”(Event Loop)模型。在这个模型中,异步任务被分为两大类别:Macrotask(宏任务)和Microtask(微任务)。
当主线程执行当前脚本时:
- 收集阶段:如果遇到Macrotask(如定时器),将其放入Macrotask队列;如果遇到Microtask(如Promise回调),将其放入Microtask队列。
注意:此时仅将任务加入队列,并不立即执行。 - 清空阶段:当前同步代码执行完毕后,优先取出所有Microtask队列中的任务依次执行。
在执行Microtask期间,若产生新的异步任务,同样遵循放入对应队列的原则。 - 渲染与下一步:当Microtask队列为空后,再从Macrotask队列中取出一个任务进行执行(例如执行一次setTimeout的回调),随后可能触发UI重绘,接着再次检查Microtask。
任务分类清单
- 属于Macrotask的任务:
setTimeout、setInterval、I/O、UI渲染、完整的script标签代码块。 - 属于Microtask的任务:
Promise.then/catch/finally、async/await后续逻辑、process.nextTick(Node.js特有)、MutationObserver。
第1步:分析题目代码的执行顺序
让我们回到开头的示例,逐步拆解每一行代码的去向:
- 执行全局作用域初始化
代码从上往下执行。首先打印console.log('script start');。 - 注册Macrotask
遇到setTimeout,这是一个标准的宏任务。其回调被放入Macrotask队列,等待下一轮循环。目前状态:Macrotask中有【setTimeout】。 - 调用async1函数(同步执行部分)
调用async1()。
- 打印async1 start。
- 执行await async2()。
· 进入async2,打印async2 start。
·async2内部返回一个new Promise。Promise构造函数内的代码是同步执行的,因此立即打印async2 promise。
· 由于遇到了await,console.log('async1 end')这一行将被包装成一个Microtask,放入Microtask队列。目前状态:Microtask中有【第4行的async1 end】。 - 同步Promise实例化
创建新的new Promise,立即打印promise1。
执行其后的.then()。根据规则,第一个.then的回调会被推入微任务队列。目前状态:Microtask中有【第4行的async1 end】,【第26行的promise2】。 - 执行剩余的全局同步代码
打印console.log('script end');。
第2步:进入Event Loop的Microtask清理阶段
此时,所有的同步代码执行完毕,开始扫描Microtask队列:
- 取出队列中第一项(来自async1)
执行console.log('async1 end');。打印async1 end。 - 取出队列中第二项(来自第一层Promise链)
执行console.log('promise2');。打印promise2。
重要细节:因为promise2所在的回调后面还有.then(()=>{console.log('promise3')}),所以这个第三层的回调会被推入到当前的Microtask队列末尾。目前状态:Microtask中有【第28行的promise3】。 - 取出最后一项Microtask
执行console.log('promise3');。打印promise3。
第3步:执行下一个Macrotask
Microtask队列已清空,引擎转而执行Macrotask队列中的下一个任务:
- 执行setTimeout回调
取出之前注册的宏任务,打印console.log('setTimeout');。
至此,整个执行流程结束。最终的控制台输出顺序为:start → async1 start → async2 start → async2 promise → promise1 → script end → async1 end → promise2 → promise3 → setTimeout。
常见误区澄清
误区一:Promise.resolve的位置影响Microtask触发时机
许多人误以为 resolve() 写在不同位置会导致不同的行为。事实上,只要是在 new Promise 的同步执行范围内调用了 resolve,其触发的异步回调都会在同一轮结束后进入队列。
以下两种写法的效果完全一致:
// 写法A
new Promise((resolve, reject) => {
console.log('1111');
resolve(); // 同步调用resolve
console.log('2222'); // 这行依然会同步打印
});
// 写法B
new Promise((resolve, reject) => {
console.log('1111');
console.log('2222');
resolve();
});
无论怎么写,1111 和 2222 都会在微任务排队之前打印出来。
误区二:链式调用是否会一次性塞满队列
对于 Promise.then().then() 这种链式调用,引擎并不会一次性将所有后续的 then 都丢进队列。它采取的是串行生成的策略:先放入第一个 .then 的回调;等它被执行完,它内部生成的下一个 .then 才会被放入队列。这保证了执行流的可控性,但本质上它们都在同一轮Microtask中被处理。
以上就是JavaScript中Microtask与Macrotask的执行机制及鲜为人知的知识点的详细内容,更多关于JavaScript异步编程的资料请关注本站其它相关文章!
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









