JavaScript 是一门单线程语言,这意味着它在任意时刻只能执行一个任务。然而,在实际开发中,我们却经常需要处理异步操作,比如网络请求、定时器、用户交互等。这些异步操作之所以能够非阻塞地运行,背后依赖的正是**事件循环(Event Loop)**机制。理解事件循环,尤其是宏任务与微任务的执行顺序,是每一位 JavaScript 开发者进阶的必修课。
为什么需要事件循环?
JavaScript 最初被设计为运行在浏览器中的脚本语言,主要用来操作 DOM。如果 JavaScript 是多线程的,那么两个线程同时修改同一个 DOM 元素就会产生竞态条件,导致页面渲染混乱。因此,JavaScript 被设计为单线程语言,所有任务都在一个主线程上排队执行。
但单线程并不意味着“卡死”。事件循环机制让 JavaScript 可以处理异步任务:当遇到异步操作时,主线程不会等待它完成,而是将其交给浏览器的其他线程(如定时器线程、网络线程)处理,并在合适的时机将回调函数放入任务队列中,等待主线程空闲时执行。
任务队列:宏任务与微任务
在事件循环中,任务被分为两类:宏任务(MacroTask)和微任务(MicroTask)。
宏任务
宏任务代表一个个离散的、独立的工作单元。常见的宏任务包括:
setTimeoutsetIntervalsetImmediate(Node.js 环境)- I/O 操作
- UI 渲染
MessageChannel- 整个
<script>脚本的初始执行
每次事件循环从宏任务队列中取出一个宏任务执行,执行完毕后,会检查微任务队列。
微任务
微任务是在当前宏任务执行结束后、下一个宏任务开始前执行的任务。常见的微任务包括:
Promise.then/Promise.catch/Promise.finallyqueueMicrotaskMutationObserverprocess.nextTick(Node.js 环境,优先级高于 Promise)
微任务的特点是:一旦开始执行,就会一直执行到微任务队列为空。这意味着如果在微任务中又产生了新的微任务,它会继续执行,直到队列彻底清空。
事件循环的执行顺序
事件循环的核心规则可以用以下步骤概括:
- 执行当前宏任务(通常是初始的
<script>脚本)。 - 宏任务执行完毕后,检查微任务队列。
- 依次执行所有微任务,直到微任务队列为空。
- 如果此时需要进行 UI 渲染,浏览器会进行渲染。
- 从宏任务队列中取出下一个宏任务,重复上述过程。
用一句话总结:每个宏任务执行完毕后,都会清空所有微任务,然后才进入下一个宏任务。
经典示例分析
让我们通过几个示例来验证上述规则。
示例一:基本顺序
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise then');
});
console.log('script end');
输出结果:
script start
script end
promise then
setTimeout
分析:
- 初始
<script>作为第一个宏任务开始执行。 - 同步代码
console.log('script start')和console.log('script end')依次输出。 setTimeout的回调被放入宏任务队列。Promise.then的回调被放入微任务队列。- 宏任务(初始脚本)执行完毕,检查微任务队列,输出
promise then。 - 微任务清空后,取出下一个宏任务,输出
setTimeout。
示例二:微任务中产生微任务
console.log('start');
setTimeout(() => {
console.log('timeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise1');
Promise.resolve().then(() => {
console.log('promise2');
});
});
console.log('end');
输出结果:
start
end
promise1
promise2
timeout
分析:
- 同步代码输出
start和end。 - 微任务队列中有
promise1的回调。 - 执行
promise1回调时,又产生了一个新的微任务promise2。 - 因为微任务队列会一直执行到空,所以
promise2紧接着执行。 - 最后才执行宏任务
timeout。
示例三:async/await 的本质
async/await 本质上是 Promise 的语法糖,理解这一点对分析执行顺序至关重要。
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
async1();
new Promise((resolve) => {
console.log('promise1');
resolve();
}).then(() => {
console.log('promise2');
});
console.log('script end');
输出结果:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
分析:
- 同步代码依次输出
script start。 - 调用
async1,输出async1 start。 - 执行
await async2(),先执行async2函数体,输出async2。await之后的代码console.log('async1 end')被放入微任务队列。 - 继续执行同步代码,
new Promise的执行器函数是同步的,输出promise1,then回调被放入微任务队列。 - 输出
script end。 - 宏任务(初始脚本)结束,清空微任务队列:先输出
async1 end,再输出promise2。 - 最后执行宏任务
setTimeout。
宏任务与微任务的关键区别
| 特性 | 宏任务 | 微任务 |
|---|---|---|
| 执行时机 | 每个事件循环取一个 | 当前宏任务结束后全部执行 |
| 是否阻塞渲染 | 可能阻塞 | 会阻塞渲染,需谨慎使用 |
| 常见 API | setTimeout、setInterval、I/O | Promise.then、queueMicrotask |
| 队列清空方式 | 每次取一个 | 一次性清空 |
实际开发中的注意事项
-
避免在微任务中无限递归:由于微任务队列会一直执行到空,如果在微任务中不断产生新的微任务,会导致页面无法渲染,甚至卡死。
-
Promise 回调不一定是微任务:只有
then、catch、finally的回调是微任务,Promise 的执行器函数是同步执行的。 -
setTimeout(fn, 0)并不立即执行:它只是将回调放入宏任务队列,需要等待当前宏任务和所有微任务执行完毕。 -
Node.js 与浏览器的差异:Node.js 中有
process.nextTick,它的优先级高于 Promise 微任务;此外 Node.js 的事件循环分为多个阶段,宏任务的执行顺序与浏览器略有不同。
总结
JavaScript 事件循环是理解异步编程的基石。核心规则可以归纳为:
- 同步代码在宏任务中执行。
- 每个宏任务执行完毕后,清空所有微任务。
- 微任务执行过程中产生的新微任务,会在当前轮次继续执行。
- 微任务全部清空后,才进入下一个宏任务。
掌握宏任务与微任务的执行顺序,不仅能帮助你在面试中游刃有余,更能让你在实际开发中写出可预测、高性能的异步代码。下次遇到 setTimeout 和 Promise 混用的场景时,不妨在脑海中模拟一遍事件循环的流程,答案自然会清晰浮现。
未经允许不得转载:任鹏个人博客 » 深入理解 JavaScript 事件循环机制:宏任务与微任务的执行顺序

