各位同仁,各位对JavaScript异步编程充满好奇的开发者们,大家好!
今天,我们将深入探讨一个在JavaScript世界中经常令人感到困惑,但又至关重要的概念:微任务(Microtask)和宏任务(Macrotask)。它们是理解JavaScript事件循环(Event Loop)机制,掌握异步代码执行顺序的关键。许多开发者,即使是经验丰富的,也常常在这两者之间摇摆不定,导致对代码行为的预测出现偏差。
我的目标是,通过这次深入的讲座,彻底剖析微任务和宏任务的本质,揭示它们在事件循环中的优先级和执行机制,让大家能够清晰、准确地预判任何异步JavaScript代码的执行流程。我们将从最基础的概念开始,逐步深入到复杂的场景,并辅以大量的代码示例进行演示。
一切的根源:JavaScript的单线程特性
在深入微任务和宏任务之前,我们必须先巩固一个最基本的概念:JavaScript是单线程的。这意味着在任何给定时刻,JavaScript引擎只能执行一个任务。它只有一个调用栈(Call Stack),用于跟踪当前正在执行的函数。
1. 调用栈 (Call Stack)
想象一下一个堆叠的盘子。当你调用一个函数时,它被“推入”栈顶。当函数执行完毕并返回时,它被“弹出”栈。JavaScript引擎总是执行栈顶的函数。
function third() {
console.log('3: third function');
}
function second() {
console.log('2: second function');
third(); // third() 被推入栈
}
function first() {
console.log('1: first function');
second(); // second() 被推入栈
}
first(); // first() 被推入栈
console.log('4: script end');
执行顺序与调用栈变化:
first()进入栈console.log('1: first function')执行second()进入栈console.log('2: second function')执行third()进入栈console.log('3: third function')执行third()完成,弹出栈second()完成,弹出栈first()完成,弹出栈console.log('4: script end')执行- 主脚本完成,弹出栈
输出:
1: first function
2: second function
3: third function
4: script end
这就是同步代码的执行方式。然而,在现代Web应用中,我们需要处理网络请求、定时器、用户交互等异步操作。如果这些操作都阻塞主线程,用户界面就会卡死,用户体验将不堪设想。这就是为什么我们需要异步机制。
异步的基石:事件循环 (Event Loop)
为了解决单线程阻塞问题,JavaScript引入了事件循环(Event Loop)机制。事件循环是JavaScript运行时(如浏览器或Node.js环境)中的一个核心组件,它不断地检查是否有任务需要执行,并协调任务的顺序。
事件循环的组成部分:
- 调用栈 (Call Stack): 我们已经讨论过,用于执行同步代码。
- Web APIs (或宿主环境提供的API): 浏览器(或Node.js)提供的一些异步功能,例如:
setTimeout,setInterval(定时器)fetch,XMLHttpRequest(网络请求)- DOM事件监听 (
addEventListener) requestAnimationFrame
- 任务队列 (Task Queue / Callback Queue / Macrotask Queue): 当Web API完成其异步操作后,会将对应的回调函数放入这个队列。
- 微任务队列 (Microtask Queue): 这是一个优先级更高的队列,专门用于存放微任务的回调。
事件循环的基本流程(简化版):
- 执行主线程上的同步代码,直到调用栈清空。
- 调用栈清空后,事件循环开始工作。
- 它首先会检查微任务队列。如果微任务队列中有任务,它会清空并执行所有这些微任务,直到队列为空。
- 微任务队列清空后,如果浏览器要进行渲染,会执行渲染操作。
- 然后,它会检查宏任务队列(即任务队列)。如果宏任务队列中有任务,它会取出一个宏任务来执行。
- 执行完一个宏任务后,事件循环会再次回到步骤3,检查并清空微任务队列,然后进行渲染,再取下一个宏任务。
这个循环会一直持续下去,直到所有任务都被处理完毕(或者页面关闭)。
宏任务 (Macrotasks):重量级选手
宏任务是构成事件循环“大循环”的基本单位。每次事件循环迭代(称为一个“tick”或“turn”)都会从宏任务队列中取出一个宏任务来执行。
常见的宏任务包括:
- 主脚本 (Initial Script): 整个JavaScript文件本身就是第一个宏任务。
setTimeout的回调函数setInterval的回调函数- I/O 操作 (如文件读写,网络请求的回调)
- UI 渲染事件 (如浏览器重绘/回流)
MessageChannel的回调requestAnimationFrame的回调 (虽然与渲染紧密相关,但它通常在宏任务之后、渲染之前执行)setImmediate(Node.js 独有)
宏任务的特点:
- 优先级较低: 每次事件循环迭代只会执行一个宏任务。
- 独立性: 每个宏任务执行完毕后,都会给JavaScript引擎一个“喘息”的机会,让它去检查微任务队列,执行渲染等。
- 可能引入延迟: 定时器(
setTimeout,setInterval)的延迟时间只是一个最小延迟,实际执行时间可能会更长,因为它必须等待当前宏任务执行完毕,并且微任务队列被清空。
示例:setTimeout 作为宏任务
console.log('1: Start Script');
setTimeout(() => {
console.log('4: setTimeout callback');
}, 0); // 尽管延迟为0,它仍然是一个宏任务
console.log('2: End Script');
// 预期输出:
// 1: Start Script
// 2: End Script
// 4: setTimeout callback
解释:
console.log('1: Start Script')同步执行。setTimeout被调用。它的回调函数被注册到Web API中,等待0ms后被放入宏任务队列。console.log('2: End Script')同步执行。- 主脚本(第一个宏任务)执行完毕,调用栈清空。
- 事件循环检查微任务队列(此时为空)。
- 事件循环检查宏任务队列,发现
setTimeout的回调函数。 - 取出并执行
console.log('4: setTimeout callback')。
微任务 (Microtasks):高优先级的插队者
微任务是在当前宏任务执行结束后,但在下一个宏任务开始之前执行的任务。它们可以被认为是“在当前事件循环迭代中尽快执行”的任务。
常见的微任务包括:
Promise.then(),Promise.catch(),Promise.finally()的回调函数async/await中的await之后的代码 (本质上是 Promise)MutationObserver的回调函数queueMicrotask()方法process.nextTick()(Node.js 独有,优先级高于所有微任务,非常特殊)
微任务的特点:
- 高优先级: 在一个宏任务执行完毕后,事件循环会立即检查并清空整个微任务队列,然后再进行渲染或处理下一个宏任务。这意味着所有排队的微任务都会在一个宏任务结束后立即执行,不会等到下一个事件循环迭代。
- 快速响应: 适合需要尽快执行的异步操作,例如Promise链式调用,确保Promise的状态变化能够迅速反映。
- 可能导致UI卡顿: 如果微任务队列中存在大量或耗时的任务,它们会长时间阻塞UI渲染和下一个宏任务的执行,导致页面无响应。
示例:Promise 作为微任务
console.log('1: Start Script');
Promise.resolve().then(() => {
console.log('3: Promise microtask');
});
console.log('2: End Script');
// 预期输出:
// 1: Start Script
// 2: End Script
// 3: Promise microtask
解释:
console.log('1: Start Script')同步执行。Promise.resolve()返回一个已解决的Promise。.then()回调函数被调度为一个微任务,放入微任务队列。console.log('2: End Script')同步执行。- 主脚本(第一个宏任务)执行完毕,调用栈清空。
- 事件循环检查微任务队列,发现
Promise.then()的回调函数。 - 清空微任务队列,执行
console.log('3: Promise microtask')。
宏任务与微任务的执行顺序:核心机制
理解宏任务与微任务如何协同工作的关键在于这个循环:
一个事件循环周期(一次“tick”)的简化流程:
- 从宏任务队列中取出一个宏任务并执行它。 (通常是主脚本,或者一个
setTimeout回调,一个DOM事件回调等)。 - 执行过程中,如果遇到异步代码:
setTimeout/setInterval等回调被注册到Web API,待条件满足后,进入宏任务队列。Promise.then()/catch/finally()等回调被注册到Web API,待条件满足后,进入微任务队列。
- 当前宏任务执行完毕,调用栈清空。
- 检查微任务队列。 如果非空,则清空并执行所有微任务队列中的任务,直到微任务队列为空。
- (浏览器环境特有)执行浏览器渲染。 重新计算样式、布局和绘制页面。
- 回到步骤1,从宏任务队列中取出下一个宏任务。
表格总结:宏任务 vs 微任务
| 特性 | 宏任务 (Macrotask) | 微任务 (Microtask) |
|---|---|---|
| 队列名称 | 任务队列 (Task Queue / Callback Queue) | 微任务队列 (Microtask Queue) |
| 执行时机 | 在一个事件循环周期中,每次只取出一个宏任务执行。 | 在每个宏任务执行完毕后,下一个宏任务开始前,清空所有微任务。 |
| 优先级 | 较低 | 较高 |
| 典型示例 | script (整体代码), setTimeout, setInterval, I/O, UI渲染 |
Promise.then/catch/finally, async/await, MutationObserver, queueMicrotask |
| 对渲染影响 | 执行完一个宏任务后,有机会进行UI渲染。 | 大量微任务会阻塞UI渲染,直到所有微任务执行完毕。 |
| Node.js 特有 | setImmediate |
process.nextTick (优先级最高) |
实战演练:逐步提升复杂度
现在,让我们通过一系列代码示例来加深理解。请大家尝试预测输出,然后对照解释。
示例 1:宏任务与微任务混合
console.log('1: Script Start'); // 宏任务 (主脚本)
setTimeout(() => {
console.log('5: setTimeout 0ms'); // 宏任务
}, 0);
Promise.resolve().then(() => {
console.log('3: Promise microtask 1'); // 微任务
});
Promise.resolve().then(() => {
console.log('4: Promise microtask 2'); // 微任务
});
console.log('2: Script End'); // 宏任务 (主脚本)
预测输出:
1: Script Start
2: Script End
3: Promise microtask 1
4: Promise microtask 2
5: setTimeout 0ms
解释:
- 主脚本作为第一个宏任务开始执行。
console.log('1: Script Start')同步执行。setTimeout被注册到Web API,回调进入宏任务队列。Promise.resolve().then(...)的回调进入微任务队列。- 第二个
Promise.resolve().then(...)的回调进入微任务队列。 console.log('2: Script End')同步执行。
- 第一个宏任务(主脚本)执行完毕,调用栈清空。
- 事件循环检查微任务队列。 发现两个Promise微任务,按顺序执行:
console.log('3: Promise microtask 1')console.log('4: Promise microtask 2')- 微任务队列清空。
- 事件循环检查宏任务队列。 发现
setTimeout的回调。 - 执行
setTimeout的回调。console.log('5: setTimeout 0ms')- 宏任务队列清空。
- 所有任务执行完毕。
示例 2:链式 Promise 与 setTimeout
console.log('1: Global Start');
setTimeout(() => {
console.log('8: setTimeout 1'); // 宏任务
Promise.resolve().then(() => {
console.log('9: Promise in setTimeout'); // 微任务 (属于setTimeout宏任务内部)
});
}, 0);
Promise.resolve().then(() => {
console.log('3: Promise 1'); // 微任务
setTimeout(() => {
console.log('7: setTimeout in Promise'); // 宏任务 (属于Promise微任务内部)
}, 0);
}).then(() => {
console.log('4: Promise 2'); // 微任务 (链式Promise)
});
console.log('2: Global End');
预测输出:
1: Global Start
2: Global End
3: Promise 1
4: Promise 2
7: setTimeout in Promise
8: setTimeout 1
9: Promise in setTimeout
解释:
- 主脚本(第一个宏任务)开始执行。
console.log('1: Global Start')同步执行。- 第一个
setTimeout被注册,回调进入宏任务队列[setTimeout1]。 - 第一个
Promise.then()被注册,回调进入微任务队列[Promise1]。 console.log('2: Global End')同步执行。
- 主脚本执行完毕,调用栈清空。
- 清空微任务队列。
- 执行
Promise 1的回调:console.log('3: Promise 1')- 内部的
setTimeout被注册,回调进入宏任务队列[setTimeout1, setTimeoutInPromise]。 - 由于
Promise 1的.then()返回了一个非Promise值(或者隐式返回undefined),它会立即调度下一个.then()回调。
- 执行
Promise 2的回调(链式调用):console.log('4: Promise 2')
- 微任务队列清空。
- 执行
- 事件循环检查宏任务队列。 取出
setTimeout1。- 执行
setTimeout1的回调:console.log('8: setTimeout 1')- 内部的
Promise.resolve().then(...)的回调进入微任务队列[PromiseInSetTimeout]。
- 执行
setTimeout1宏任务执行完毕,调用栈清空。- 清空微任务队列。
- 执行
Promise in setTimeout的回调:console.log('9: Promise in setTimeout')
- 微任务队列清空。
- 执行
- 事件循环检查宏任务队列。 取出
setTimeoutInPromise。- 执行
setTimeoutInPromise的回调:console.log('7: setTimeout in Promise')
- 宏任务队列清空。
- 执行
- 所有任务执行完毕。
注意: 这里的 7: setTimeout in Promise 为什么在 8: setTimeout 1 之后才执行?因为 setTimeoutInPromise 是在 Promise 1 这个微任务中调度的,而 Promise 1 是在 Global End 之后,setTimeout 1 之前执行的。所以 setTimeoutInPromise 晚于 setTimeout 1 进入宏任务队列。
示例 3:async/await 与 queueMicrotask
async/await 是 ES2017 引入的异步语法糖,它建立在 Promise 之上。await 关键字会暂停 async 函数的执行,并等待其后的 Promise 解决。当 Promise 解决后,await 之后的代码将作为微任务被重新调度执行。
queueMicrotask() 是一个直接将函数放入微任务队列的方法。
console.log('1: Script Start');
async function asyncFunc() {
console.log('4: Async Func Start'); // 微任务 (await 之前的同步代码)
await Promise.resolve(); // 暂停 asyncFunc,await 后面的代码进入微任务队列
console.log('6: Async Func Await End'); // 微任务
}
asyncFunc();
Promise.resolve().then(() => {
console.log('5: Promise Then'); // 微任务
});
queueMicrotask(() => {
console.log('3: queueMicrotask'); // 微任务
});
setTimeout(() => {
console.log('7: setTimeout'); // 宏任务
}, 0);
console.log('2: Script End');
预测输出:
1: Script Start
2: Script End
3: queueMicrotask
4: Async Func Start
5: Promise Then
6: Async Func Await End
7: setTimeout
解释:
- 主脚本(第一个宏任务)开始执行。
console.log('1: Script Start')同步执行。asyncFunc()被调用。console.log('4: Async Func Start')同步执行(这是asyncFunc内部await之前的同步代码)。await Promise.resolve():asyncFunc暂停,await之后的console.log('6: Async Func Await End')被封装成一个微任务,进入微任务队列。
Promise.resolve().then(...)的回调进入微任务队列。queueMicrotask()的回调进入微任务队列。setTimeout被注册,回调进入宏任务队列。console.log('2: Script End')同步执行。
- 主脚本执行完毕,调用栈清空。
- 清空微任务队列。 此时微任务队列中有三个任务:
queueMicrotask的回调Promise.then的回调await之后的回调(Async Func Await End)- 执行顺序取决于它们进入队列的顺序:
console.log('3: queueMicrotask')console.log('5: Promise Then')console.log('6: Async Func Await End')
- 微任务队列清空。
- 事件循环检查宏任务队列。 取出
setTimeout的回调。console.log('7: setTimeout')- 宏任务队列清空。
- 所有任务执行完毕。
注意 asyncFunc 中 4: Async Func Start 和 6: Async Func Await End 的区别。 await 之前的代码是同步执行的,它仍然是当前宏任务的一部分。而 await 之后的代码,只有在被 await 的 Promise 解决后,才会被作为微任务调度。
Node.js 环境下的特例:process.nextTick 和 setImmediate
在Node.js环境中,除了浏览器中的宏任务和微任务,还有两个特殊的API:process.nextTick 和 setImmediate,它们对任务队列的优先级有额外的影响。
-
process.nextTick(callback):- 这不是标准的微任务或宏任务,但它的行为与微任务非常相似,甚至优先级更高。
- 它会在当前执行栈清空后,立即执行,在任何微任务之前执行。可以看作是“超级微任务”。
- 如果在一个宏任务中调用了
process.nextTick,它的回调会在该宏任务结束之后,微任务队列清空之前执行。 - 如果在微任务中调用了
process.nextTick,它的回调也会在当前微任务结束后,其他微任务之前执行。
-
setImmediate(callback):- 这是一个宏任务。
- 它的优先级低于
setTimeout(fn, 0)。 - 它会在当前事件循环迭代的Check阶段被执行,通常在I/O轮询之后,
setTimeout之前。
Node.js 事件循环阶段(简化):
- timers: 执行
setTimeout和setInterval的回调。 - pending callbacks: 执行一些系统操作的回调。
- idle, prepare: 内部使用。
- poll: 等待新的I/O事件,执行I/O相关的回调。
- check: 执行
setImmediate的回调。 - close callbacks: 执行
close事件的回调。
注意: 在每个阶段之间,Node.js 都会检查并清空 process.nextTick 队列和微任务队列。process.nextTick 队列总是最先被清空。
Node.js 示例:
console.log('1: Script Start');
setTimeout(() => {
console.log('4: setTimeout 0ms');
}, 0);
setImmediate(() => {
console.log('5: setImmediate');
});
Promise.resolve().then(() => {
console.log('3: Promise microtask');
});
process.nextTick(() => {
console.log('2: process.nextTick');
});
console.log('6: Script End');
Node.js 环境下的预测输出:
1: Script Start
6: Script End
2: process.nextTick
3: Promise microtask
4: setTimeout 0ms
5: setImmediate
解释 (Node.js):
- 主脚本作为第一个宏任务开始执行。
console.log('1: Script Start')同步执行。setTimeout被注册,回调进入 timers 阶段队列。setImmediate被注册,回调进入 check 阶段队列。Promise.then()的回调进入微任务队列。process.nextTick()的回调进入nextTick队列。console.log('6: Script End')同步执行。
- 主脚本执行完毕,调用栈清空。
- 清空
process.nextTick队列。console.log('2: process.nextTick')
- 清空微任务队列。
console.log('3: Promise microtask')
- 进入事件循环的 timers 阶段。
- 执行
setTimeout的回调:console.log('4: setTimeout 0ms')
- 执行
- 进入事件循环的 check 阶段。
- 执行
setImmediate的回调:console.log('5: setImmediate')
- 执行
- 所有任务执行完毕。
常见误区与最佳实践
误区一:setTimeout(fn, 0) 会立即执行
setTimeout(fn, 0) 只是将回调函数推迟到当前宏任务执行完毕,并且微任务队列清空之后,才会在下一个宏任务周期中执行。它不保证立即执行。
误区二:认为宏任务是并行执行的
JavaScript是单线程的,宏任务和微任务都是在主线程上串行执行的。所谓的“异步”只是将任务调度到未来的某个时间点,而不是同时执行。
误区三:微任务队列无限膨胀导致UI卡顿
如果在一个微任务中又调度了新的微任务(例如一个无限循环的Promise链),那么微任务队列将永远无法清空,导致JavaScript引擎一直忙于处理微任务,无法进行UI渲染,也无法处理下一个宏任务。这会使页面完全卡死。
// 示例:无限微任务循环 (慎用,会导致浏览器卡死)
function createInfiniteMicrotask() {
Promise.resolve().then(() => {
console.log('Infinite microtask');
createInfiniteMicrotask(); // 递归调用,不断添加微任务
});
}
// createInfiniteMicrotask(); // 不要轻易在生产环境或浏览器中运行
最佳实践:
- 理解异步流: 在编写异步代码时,始终在脑海中模拟事件循环的执行过程。
- 优先使用 Promise/async-await: 它们基于微任务,响应更快,代码更具可读性。
- 避免在微任务中进行大量耗时操作: 如果有大量计算或长时间运行的任务,考虑使用
Web Workers或将其拆分为多个宏任务,以避免阻塞UI。 - 正确使用
queueMicrotask: 当你需要确保某个任务在当前同步代码执行完毕后,但在任何新的宏任务或UI渲染之前执行时,queueMicrotask是一个很好的选择。例如,某些库需要在DOM更新后立即执行清理或验证操作。 - 谨慎处理
setTimeout(fn, 0): 了解其执行时机,不要期望它能立即响应。如果需要更精细的控制,可以考虑requestAnimationFrame用于动画,或者queueMicrotask用于更快的非渲染相关更新。
拓展视野:浏览器渲染与 requestAnimationFrame
我们多次提到UI渲染。在浏览器环境中,UI渲染通常发生在每个宏任务执行完毕,并且微任务队列被清空之后。
requestAnimationFrame(callback) 是另一个特殊的API,用于优化动画和视觉更新。它的回调函数会在浏览器下一次重绘之前执行。它既不是宏任务也不是微任务,但它的执行时机通常在所有微任务之后,但在浏览器实际渲染之前。这使得它成为进行DOM操作和动画的最佳场所,因为它能与浏览器的渲染周期同步,避免不必要的重绘和卡顿。
执行顺序(包含渲染和 requestAnimationFrame):
- 执行一个宏任务。
- 清空微任务队列。
- 执行
requestAnimationFrame的回调。 - 浏览器进行渲染。
- 回到步骤1,执行下一个宏任务。
console.log('1: Script Start');
setTimeout(() => {
console.log('6: setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('3: Promise microtask');
});
requestAnimationFrame(() => {
console.log('4: requestAnimationFrame'); // 在渲染前执行
Promise.resolve().then(() => {
console.log('5: Promise in rAF'); // 微任务 (属于rAF宏任务内部)
});
});
console.log('2: Script End');
预测输出:
1: Script Start
2: Script End
3: Promise microtask
4: requestAnimationFrame
5: Promise in rAF
6: setTimeout
解释:
- 主脚本执行,
1: Script Start和2: Script End同步输出。 setTimeout回调进入宏任务队列。Promise.then回调进入微任务队列。requestAnimationFrame回调注册。- 主脚本完成,调用栈清空。
- 清空微任务队列:
3: Promise microtask输出。 - 执行
requestAnimationFrame回调:4: requestAnimationFrame输出。在此回调内部,又有一个Promise.then被调度,进入微任务队列。 - 清空微任务队列 (第二次):
5: Promise in rAF输出。 - 浏览器可能会进行渲染。
- 取下一个宏任务:
setTimeout回调执行,6: setTimeout输出。
深入理解,提升代码质量
微任务和宏任务的区分,以及它们在事件循环中的精确执行顺序,是JavaScript异步编程的基石。掌握这一点,你将能够:
- 准确预测代码行为: 不再为异步代码的输出感到困惑。
- 编写高性能代码: 避免因不当的异步调度而导致的UI卡顿和响应延迟。
- 调试更轻松: 知道任务在哪里被阻塞,在哪里被执行。
- 深入理解框架原理: 许多现代JavaScript框架(如React, Vue)的调度机制都与事件循环、微任务和宏任务息息相关。
这是一个需要反复实践和思考的知识点。我鼓励大家多写代码,多做实验,亲手去验证这些执行顺序。只有这样,你才能真正将这些理论内化为自己的编程直觉。
希望这次讲座能彻底解开大家对JavaScript微任务和宏任务的疑惑。理解并熟练运用这些概念,将是你成为一名优秀JavaScript开发者的重要一步。