深入浅出JavaScript事件循环(event loop):宏任务与微任务的奇幻之旅
在JavaScript的世界里,事件循环就像一个神奇的魔法师,它能让单线程的语言处理各种异步任务。今天,让我们一起揭开这个魔法师的神秘面纱!
🌟 前言:单线程的挑战
JavaScript是单线程语言,这意味着它一次只能做一件事。想象一下,如果咖啡师只能一次服务一位顾客,那么当遇到需要长时间等待的任务(比如手冲咖啡)时,后面的顾客就会一直等待,整个队伍就停滞不前了。
javascript
console.log('开始点单'); // 同步任务立即执行
setTimeout(() => {
console.log('手冲咖啡完成'); // 耗时任务
}, 2000);
console.log('准备其他饮品'); // 同步任务立即执行
为了解决这个问题,JavaScript引入了事件循环(Event Loop机制,它就像一位聪明的咖啡店经理,能够合理安排任务的执行顺序。
🎢 事件循环的魔法舞台
事件循环的核心在于任务队列 和执行顺序。整个机制可以简化为以下步骤:
- 执行同步任务(调用栈中的任务)
- 执行所有微任务(直到微任务队列清空)
- 渲染页面(如果需要)
- 执行一个宏任务
- 重复上述过程
一个script就是一个宏任务的开始,我们的代码从一个宏任务开始执行,放到调用栈里面执行

📦 宏任务:大块头的工作
宏任务(MacroTask)是事件循环中的主要任务单位,包括:
<script>
标签内的整体代码setTimeout
和setInterval
- I/O操作(文件读写、网络请求等)
- UI渲染
- 事件监听器(click、scroll等)
javascript
console.log('script start'); // 宏任务开始
setTimeout(() => {
console.log('setTimeout'); // 宏任务
}, 0);
console.log('script end'); // 宏任务结束
每个事件循环周期只执行一个宏任务,然后就去检查微任务队列。
🧩 微任务:小而精的紧急任务
微任务(MicroTask)是在当前宏任务结束后立即执行的任务,它们具有"插队"特权:
Promise.then
/catch
/finally
MutationObserver
queueMicrotask
process.nextTick
(Node.js环境)
Promise小贴士
- Promise 本身是同步的:创建 Promise 实例和执行 executor 函数都是同步操作。
- Promise 的回调是异步的 :
then
/catch
/finally
会被放入微任务队列,在当前同步代码执行完毕后执行。- Promise 是类:它提供了一种优雅的方式来处理异步操作,避免回调地狱,同时保持代码的同步风格。
javascript
console.log('同步Start'); //宏任务开始,同步任务开始
Promise.resolve().then(() => {
console.log('Promise微任务');//微任务
});
queueMicrotask(() => {
console.log('queueMicrotask微任务');//微任务
});
console.log('同步End');//宏任务结束
⚖️ 为什么要有宏任务和微任务?
这种设计是为了平衡效率和响应速度:
- 微任务处理紧急、高优先级任务(如Promise回调)
- 宏任务处理非紧急任务(如定时器)
- 微任务在当前宏任务结束后立即执行,确保高优先级任务快速响应
- 宏任务保证浏览器有时间进行渲染
🧪 实战分析:执行顺序之谜
html
<script>
console.log('script start'); // 宏任务1-1
setTimeout(() => {
console.log('setTimeout'); // 宏任务2
Promise.resolve().then(() => {
console.log('promise in setTimeout'); // 宏任务2的微任务
});
}, 0);
Promise.resolve().then(() => {
console.log('promise1'); // 微任务1-1
}).then(() => {
console.log('promise2'); // 微任务1-2
});
console.log('script end'); // 宏任务1-2
</script>
执行顺序解析:
- 执行宏任务(整个script)
- 输出
script start
- 设置定时器(宏任务)
- 注册Promise微任务
- 输出
script end
- 输出
- 执行所有微任务
- 输出
promise1
- 注册新的微任务
- 输出
promise2
- 输出
- 执行下一个宏任务(定时器)
- 输出
setTimeout
- 注册新的微任务
- 输出
- 执行定时器产生的微任务
- 输出
promise in setTimeout
- 输出
多个宏任务内部和微任务内部的顺序是怎么排列的呢
宏任务
JavaScript 的 Event Loop 在处理多个宏任务时,执行顺序受任务类型 、添加时机 和运行环境(浏览器 / Node.js)的影响。以下是详细解析:
宏任务队列的基本规则
- 先进先出(FIFO) :同一类型的宏任务按添加顺序执行。
- 优先级差异:不同类型的宏任务可能有不同的执行阶段(Node.js 中更明显)。
- 单次循环执行一个 :Event Loop 每次只从宏任务队列中取出一个任务执行,执行后立即处理微任务队列,再处理下一个宏任务。
浏览器环境下的宏任务执行
在浏览器中,常见的宏任务(如 setTimeout
、setInterval
、UI 渲染)会被放入同一个宏任务队列(或按类型分组但顺序固定),执行规则如下:
- 示例 1:同类型宏任务的顺序
javascript
setTimeout(() => console.log('timer1'), 100);
setTimeout(() => console.log('timer2'), 0);
setTimeout(() => console.log('timer3'), 0);
执行顺序:
timer2
(虽设定延迟 0ms,但实际至少延迟 4ms,且先加入队列)timer3
(与timer2
延迟相同,但后加入队列)timer1
(延迟 100ms,最后触发)
- 示例 2:宏任务与微任务的穿插
javascript
setTimeout(() => {
console.log('timer1'); // 宏任务1
Promise.resolve().then(() => console.log('promise1')); // 微任务1
});
setTimeout(() => {
console.log('timer2'); // 宏任务2
Promise.resolve().then(() => console.log('promise2')); // 微任务2
});
执行顺序:
-
执行
timer1
(宏任务) -
清空微任务队列:执行
promise1
-
执行
timer2
(宏任务) -
清空微任务队列:执行
promise2
关键点:每个宏任务执行后,微任务队列会被立即清空。
Node.js 环境下的宏任务执行
Node.js 的 Event Loop 分为 6 个阶段,每个阶段对应一个宏任务队列:
- timers :执行
setTimeout
和setInterval
的回调。 - I/O callbacks:处理网络、流、TCP 等错误回调。
- idle, prepare:仅内部使用。
- poll:检索新的 I/O 事件,执行 I/O 相关回调。
- check :执行
setImmediate
的回调。 - close callbacks :执行关闭事件的回调(如
socket.on('close')
)。
- 示例 3:Node.js 中的宏任务阶段差异
javascript
setTimeout(() => console.log('timer'), 0);
setImmediate(() => console.log('immediate'));
可能的输出结果:
-
结果 1 :
timer → immediate
若代码在主模块中执行,且事件循环启动时间超过 1ms,
setTimeout
会先触发。 -
结果 2 :
immediate → timer
若代码在 I/O 回调中执行,
setImmediate
会在check
阶段优先执行。
差异原因:
setTimeout
在timers
阶段执行,需等待至少 1ms。setImmediate
在check
阶段执行,若事件循环已进入check
阶段,则优先执行。
浏览器与 Node.js 的核心差异
特性 | 浏览器 | Node.js |
---|---|---|
宏任务队列 | 通常合并为一个队列(先进先出) | 分为 6 个阶段队列,按阶段顺序执行 |
setTimeout 与 setImmediate |
setTimeout 可能先执行(取决于延迟时间) |
setImmediate 可能先执行(在 I/O 回调中) |
微任务执行时机 | 每个宏任务后立即执行 | 每个阶段结束后执行 |
总结
在浏览器环境中,宏任务按添加顺序(先进先出)执行,且每个宏任务执行后会立即清空微任务队列;Node.js 环境下,宏任务按timers
→I/O callbacks
→poll
→check
→close callbacks
的阶段顺序执行,setImmediate
和setTimeout
的执行顺序受代码位置和事件循环状态影响;通用规则是微任务优先级高于宏任务,每次宏任务执行后都会清空微任务队列,且应避免依赖宏任务的绝对执行顺序,优先用 Promise 和 async/await 处理异步逻辑。
微任务
微任务内部执行流程:
- 每次执行栈为空时 ,
Event Loop
会从宏任务队列中选择最前面的任务执行。这通常是一个完整的调用栈,比如一个setTimeout
回调或一次完整的脚本执行。 - 当这个宏任务执行完毕后 ,JavaScript 引擎会执行当前所有已注册的微任务。这意味着所有的微任务都会在这个阶段被依次执行完,不会中途插入新的宏任务。
- 执行完所有微任务后,浏览器可能会进行一些必要的 UI 更新(这取决于具体实现)。然后,Event Loop 将继续处理下一个宏任务。
- 如果在此期间有新的微任务被加入到微任务队列 中,这些新添加的微任务也会在这个周期内被执行。
微任务的执行顺序:
微任务队列遵循 FIFO
(先进先出)原则。这意味着最先被推入微任务队列的任务将首先被执行。例如:
js
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
输出结果将是:
arduino
script start
script end
promise1
promise2
setTimeout
解释如下:
- 脚本开始执行,遇到
setTimeout
,将其回调加入宏任务队列。 - 遇到第一个
Promise.resolve()
,立即执行,并将.then()
回调加入微任务队列。 - 脚本结束,控制台打印
script end
。 - 当前宏任务完成后,开始执行微任务队列中的所有任务。因此,先执行第一个
.then()
回调,打印promise1
;由于这个.then()
返回的也是一个 Promise,所以它的.then()
回调也被加入到了微任务队列尾部,在接下来的循环中执行,打印promise2
。 - 最后,执行宏任务队列中的
setTimeout
回调,打印setTimeout
。
关键点总结
- 在每个宏任务执行结束后,JavaScript 引擎会清空所有的微任务队列,按顺序执行每一个微任务。
- 新产生的微任务会在同一个事件循环周期内被执行,而不是等到下一个宏任务开始时。
- 这种设计保证了微任务能够在第一时间响应异步操作的结果,而不需要等待下一次事件循环迭代。
🔍 特殊微任务详解
1. MutationObserver:DOM变化的守护者
MutationObserver
被设计为微任务,是为了在页面渲染前 捕获DOM变化:MutationObserver
是浏览器提供的 API,用于监听 DOM 元素的变化(如属性修改、子节点增删等),其回调函数会在所有同步代码执行完毕后,以微任务形式触发。避免频繁触发回调影响性能,适合需要响应 DOM 动态变化的场景(如监听元素尺寸变化、内容更新等)。
html
<div id="target"></div>
<script>
const target = document.getElementById('target');
const observer = new MutationObserver(() => {
console.log('DOM变化啦!'); // 微任务
});
observer.observe(target, {
attributes: true,
childList: true
});
target.setAttribute('data-change', 'true'); // 触发变化
console.log('同步代码结束');
</script>
输出顺序:
同步代码结束
DOM变化啦!
2. queueMicrotask:微任务队列控制器
queueMicrotask
作用:
用于将函数加入微任务队列,在当前同步代码执行后、渲染前高优先级异步执行,适用于 DOM 操作优化(如批量更新后获取布局信息)、异步错误处理及实现响应式系统等需在渲染前完成的关键任务。
queueMicrotask
的核心价值在于:
允许开发者直接向 JavaScript 引擎的微任务队列添加任务,利用微任务 "在当前宏任务执行完毕后、页面渲染前执行" 的特性,实现更高效的任务调度 ,可将任务放入微任务队列,在当前宏任务后、渲染前执行。用于页面渲染前批量下载时 ,能避免被渲染打断 ,减少交叉阻塞 ,提升效率;处理DOM更新后,可在渲染前获取准确位置,此时浏览器已批量处理DOM变更,重排准备就绪,既能得到最新信息,又不会触发额外强制重排,降低性能消耗,优化操作体验。(如果直接在同步代码中获取 offsetHeight、scrollTop 等属性,浏览器会被迫 "立即执行重排" 以返回准确值(称为 "强制同步重排"),打破批量优化,增加性能开销。)
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>微任务</title>
</head>
<body>
<script>
console.log('同步')//宏任务
//批量更新
//DOM ,cssom,layout 树 ,图层的合并全部完成
queueMicrotask(()=>{//微任务队列,用来观察微任务DOM的api
//DOM更新了,但不是渲染完了,在渲染之前
//一个元素的高度 offsetHeight scrollTop getBoundingClientRect
//立即重绘重排,为了得到确切的位置 耗性能
//本来要等渲染完之后才能得到准确的位置,但是通过queueMicrotask 可以在批量更新-》渲染之前得到准确的位置
console.log('微任务:queueMicrotask')
})
console.log('同步结束')//宏任务
</script>
</body>
</html>
3.nextTick微任务:Node.js中的特殊成员
在Node.js环境中,事件循环稍有不同:
javascript
console.log('start');
process.nextTick(() => {
console.log('nextTick微任务');
});
Promise.resolve().then(() => {
console.log('Promise微任务');
});
setTimeout(() => {
console.log('setTimeout宏任务');
}, 0);
console.log('end');
执行顺序:
start
end
nextTick微任务
(Node.js特有)Promise微任务
setTimeout宏任务
为什么nextTick微任务是 Node.js 特有的?
- 执行时机优先级更高
在 Node.js 的事件循环中,process.nextTick()
的回调会在每个阶段结束后立即执行 ,甚至先于其他微任务(如Promise.then
)。这使得它成为 Node.js 中执行优先级最高的异步操作。 - 与 Node.js 底层机制强关联
Node.js 的事件循环基于 libuv 库实现,process.nextTick()
用于处理 Node.js 内部的异步操作(如模块加载、I/O 回调),是 Node.js 运行时的核心组成部分。
核心作用
- 异步 API 实现:确保回调在当前函数返回后执行,避免同步调用阻塞。
- 防栈溢出:将递归转换为异步执行(每次递归放入微任务队列)。
- 阶段间执行 :在
setTimeout
等宏任务回调后立即执行,先于其他微任务。 - 同步转异步:对大文件读取等耗时操作,通过异步处理防止阻塞事件循环。
与浏览器微任务的区别
特性 | process.nextTick() (Node.js) |
Promise.then (浏览器 / Node.js) |
---|---|---|
执行时机 | 每个事件循环阶段结束后立即执行 | 微任务队列的末尾,晚于 nextTick |
优先级 | 最高 | 低于 nextTick |
适用场景 | Node.js 内部机制、异步 API 实现 | 通用异步操作链 |
🚀 性能优化实战技巧
-
长任务拆分:使用宏任务分解耗时操作
javascriptfunction processChunk(data) { // 处理数据块... } function processLargeData(data) { let index = 0; function nextChunk() { const chunk = data.slice(index, index + 100); index += 100; processChunk(chunk); if (index < data.length) { setTimeout(nextChunk, 0); // 使用宏任务拆分 } } nextChunk(); }
-
优先使用微任务:当需要立即更新状态时
javascriptlet state = {}; function updateState(newState) { Promise.resolve().then(() => { // 确保在下一个渲染周期前更新 Object.assign(state, newState); render(); }); }
💡 总结与思考
特性 | 宏任务 | 微任务 |
---|---|---|
执行时机 | 每个事件循环一次 | 当前宏任务结束后立即全部执行 |
常见API | setTimeout, setInterval, I/O | Promise.then, MutationObserver |
优先级 | 低 | 高 |
是否阻塞渲染 | 是 | 否(执行在渲染前) |
理解事件循环机制能帮助我们:
- 避免常见的异步陷阱
- 优化代码执行效率
- 写出更可预测的代码
- 解决页面卡顿问题

JavaScript的事件循环就像一场精心编排的交响乐,宏任务是稳健的节拍,微任务是灵动的音符,二者共同奏响了Web应用的华美乐章。当你真正理解了它们的协作方式,你就能编写出如音乐般流畅的代码!🎵