在 JavaScript 中,理解微任务(microtasks)
和宏任务 (macrotasks)
是掌握异步编程和事件循环(Event Loop)机制的关键。这两个概念影响了代码的执行顺序,特别是在涉及异步操作(如 setTimeout
、Promise
、async/await
等)时。为了深刻理解它们的差异及其在事件循环中的表现,我们将从最基础的概念出发,逐步解释这些任务的执行机制。
1. JavaScript 的单线程与异步模型
首先,JavaScript 是一门单线程语言,意味着它在同一时刻只能执行一个任务。这一特性使得 JavaScript 在执行同步代码时很容易理解:代码按顺序一行一行地执行,直到所有代码执行完毕。然而,现代应用往往需要处理异步任务,例如网络请求、定时器回调、用户输入等。为了解决这一问题,JavaScript 引入了异步机制,通过事件循环(Event Loop)
调度和管理异步任务。
2. 事件循环(Event Loop)的基本概念
JavaScript 的事件循环是处理异步操作的核心机制。事件循环的工作流程如下:
- 执行同步任务:JavaScript 引擎首先执行所有的同步任务,这些任务会立即进入调用栈(Call Stack)并被逐个执行。
- 处理异步任务:当遇到异步任务时(例如
setTimeout
、Promise、I/O 操作等),这些任务不会立即执行,而是被放入相应的任务队列中(宏任务队列或微任务队列)。 - 检查微任务队列:当所有同步任务执行完毕,调用栈为空时,事件循环会优先检查并执行微任务队列中的所有任务。
- 执行宏任务:在微任务队列清空后,事件循环从宏任务队列中取出下一个宏任务并执行。每次执行完一个宏任务后,事件循环会再次检查微任务队列,依此反复进行。
3. 宏任务(Macrotasks)与微任务(Microtasks)
在 JavaScript 中,任务分为宏任务(macrotask)和微任务(microtask)。它们的区别主要体现在任务调度和执行顺序上。
宏任务(Macrotasks)
宏任务是事件循环的主任务,包括所有涉及异步操作的主要任务。宏任务由浏览器或 Node.js 环境调度,并在每轮事件循环中处理一个宏任务。常见的宏任务包括:
setTimeout
和setInterval
- I/O 操作(例如网络请求回调)
- DOM 事件(如
click
、keydown
等事件) postMessage
(在多个窗口或 iframe 之间通信)MessageChannel
requestAnimationFrame
这些任务通常需要一定的等待时间(即使是 setTimeout(fn, 0)
),并会在事件循环的某个阶段被处理。
微任务(Microtasks)
微任务的执行优先级比宏任务高。每当一个宏任务完成后,事件循环会立即执行微任务队列中的所有任务,只有在微任务队列清空后,才会继续执行下一个宏任务。常见的微任务包括:
Promise.then()
、Promise.catch()
、Promise.finally()
MutationObserver
(观察 DOM 变化)queueMicrotask()
(用于将函数显式添加到微任务队列)
微任务的优势在于它们可以在当前事件循环周期内尽快执行,确保任务能够快速完成,尤其适合异步操作的回调处理。
4. 事件循环中的任务调度机制
同步任务执行流程
事件循环的第一步是执行所有的同步任务,即将同步代码依次压入调用栈(Call Stack),并按照顺序执行。当调用栈为空时,事件循环将开始处理异步任务。此时,事件循环会检查两个任务队列:
- 微任务队列(Microtask Queue)
- 宏任务队列(Macrotask Queue)
微任务优先原则
当同步任务执行完毕后,事件循环会首先检查微任务队列。如果微任务队列中有任务,事件循环会一次性执行所有的微任务(即清空微任务队列)。只有当微任务队列为空时,事件循环才会执行宏任务队列中的第一个任务。
宏任务执行流程
每次从宏任务队列中取出一个任务执行后,事件循环会再次检查微任务队列。如果微任务队列不为空,则会立即执行所有的微任务。在微任务队列为空后,才会回到宏任务队列继续处理下一个宏任务。
因此,微任务的执行优先级高于宏任务。即使一个宏任务已经准备好执行,微任务队列中的任务仍会先被执行。
5. 例子:理解微任务与宏任务的执行顺序
console.log('start'); // 同步任务
setTimeout(() => {
console.log('setTimeout'); // 宏任务
}, 0);
Promise.resolve().then(() => {
console.log('promise1'); // 微任务
}).then(() => {
console.log('promise2'); // 微任务
});
console.log('end'); // 同步任务
执行顺序解析:
- 同步任务:首先执行
console.log('start')
,输出start
。 - 同步任务:继续执行
console.log('end')
,输出end
。 - 微任务队列:
Promise.resolve()
的回调被放入微任务队列。事件循环执行完同步任务后,开始执行微任务队列中的任务,先输出promise1
,再输出promise2
。 - 宏任务队列:
setTimeout()
的回调被放入宏任务队列。所有微任务完成后,事件循环执行宏任务,输出setTimeout
。
最终输出顺序为:
start
end
promise1
promise2
setTimeout
6. queueMicrotask()
与 Promise.then()
的区别
queueMicrotask()
和 Promise.then()
都会将任务加入微任务队列,但它们有一些细微的差别:
queueMicrotask()
:这是一个直接将任务添加到微任务队列的函数,适用于需要在当前事件循环周期内尽快执行的任务。Promise.then()
:当 Promise 被 resolve 时,其.then()
回调会被添加到微任务队列。
两者的执行时机基本一致,但 queueMicrotask()
提供了显式控制微任务的能力。
queueMicrotask(() => {
console.log('microtask');
});
setTimeout(() => {
console.log('timeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise');
});
输出顺序为:
microtask
promise
timeout
尽管 setTimeout
的延迟是 0
,但微任务 microtask
和 promise
仍会优先于宏任务 timeout
执行。
7. 在 Node.js 环境中的区别
在浏览器环境中,微任务和宏任务的执行顺序如上所述。然而,在 Node.js 中,事件循环的机制略有不同。Node.js 的事件循环包括多个阶段,每个阶段处理特定类型的任务。
Node.js 中有一个特殊的微任务机制:process.nextTick()
,它的优先级甚至高于微任务。process.nextTick()
的回调会在微任务之前执行,因此它可以用于需要更高优先级的任务。
process.nextTick(() => {
console.log('nextTick');
});
Promise.resolve().then(() => {
console.log('promise');
});
setTimeout(() => {
console.log('timeout');
}, 0);
输出顺序为:
nextTick
promise
timeout
process.nextTick()
的回调会在 Promise.then()
回调之前执行。
8. 常见的微任务和宏任务陷阱
1. setTimeout(fn, 0)
并不会立即执行
setTimeout(fn, 0)
并不会在 0 毫秒后立即执行,而是会将回调放入宏任务队列中。由于微任务具有更高优先级,Promise.then()
或 queueMicrotask()
等微任务会先于 setTimeout
执行。
2. 微任务过多可能导致性能问题
由于微任务总是会在每个事件循环周期内被优先执行,因此如果有过多的微任务被不断添加,它们可能会阻塞宏任务的执行,导致浏览器无法响应用户的交互。
总结
- 同步任务:优先执行。
- 微任务:每当同步任务执行完
毕或宏任务结束后,立即执行所有微任务。
3. 宏任务:微任务执行完后,才会执行下一个宏任务。
4. 微任务优先于宏任务:在每次事件循环中,微任务总是先于宏任务执行。
5. Node.js 中的 process.nextTick()
:其回调优先级比微任务更高。