JavaScript 中的事件循环:宏任务与微任务
JavaScript 中的事件循环:宏任务与微任务
一、为什么需要事件循环
JavaScript 通常运行在单线程上,同一时刻只能执行一段 JavaScript 代码。如果网络请求、定时器、文件读取和用户点击都由 JavaScript 同步等待,页面就会被阻塞。因此,浏览器或 Node.js 负责处理这些耗时操作;操作满足条件后,再把对应回调安排给 JavaScript 执行。
事件循环(Event Loop)就是运行环境协调“当前代码”和“以后要执行的回调”的调度机制。
需要先认识三个概念:
- 调用栈(Call Stack):保存当前正在执行的函数和同步代码。
- 任务队列(Task Queue):保存等待事件循环后续轮次处理的任务,日常教程常称为宏任务队列。
- 微任务队列(Microtask Queue):保存当前任务结束后、下一个任务开始前要处理的回调。
需要特别注意:同步代码、宏任务、微任务并不是三个完全平级的队列。整段初始脚本本身就是一个任务,而同步代码是这个任务内部立即执行的代码。
1 | |
二、调用栈与同步代码
当前正在执行的函数会进入调用栈。只有调用栈清空后,运行环境才会处理已经排队的异步回调。
1 | |
执行过程可以简化为:
1 | |
同步代码具有两个重要特点:
- 按照代码顺序立即执行。
- 当前同步代码不会被异步回调从中间打断。
三、什么是宏任务
“宏任务”是为了和微任务相对而产生的常用说法。在浏览器规范语境中,它通常直接称为 task。
常见宏任务包括:
- 整段
<script>脚本; setTimeout;setInterval;- 用户交互事件,例如
click; - 网络和 I/O 回调;
postMessage;MessageChannel;- Node.js 中的
setImmediate。
宏任务通常代表一个新的、相对独立的事件,例如定时器到期、用户点击按钮或收到网络数据。
1 | |
输出:
1 | |
原因如下:
- 整段脚本作为当前宏任务执行。
- 同步输出“开始”。
setTimeout注册定时器。- 当前脚本继续执行,同步输出“结束”。
- 定时器满足时间条件后,其回调进入相应的任务队列。
- 当前任务以及需要优先处理的微任务完成后,事件循环才会执行定时器回调。
所以,setTimeout(fn, 0) 并不是立即调用 fn,而是“至少等待相应时间后,允许回调进入队列等待调度”。
四、什么是微任务
微任务用于安排需要在当前任务结束后、下一个宏任务开始前尽快完成的后续工作。
常见微任务包括:
Promise.then;Promise.catch;Promise.finally;MutationObserver;await之后恢复执行的代码;- Node.js 中的
process.nextTick使用一个更高优先级的特殊队列。
1 | |
输出:
1 | |
.then() 回调进入微任务队列,但它不会打断当前脚本。当前同步代码执行完、调用栈清空后,运行环境才会执行微任务。
五、为什么要区分宏任务和微任务
宏任务和微任务服务于两种不同需求:
- 宏任务用于处理一个新的独立事件,例如定时器、点击和网络回调。
- 微任务用于完成当前操作紧接着产生的后续工作,例如处理已经确定状态的 Promise。
如果所有异步回调都进入同一个普通队列,Promise 的后续处理就要与各种定时器、点击事件一起等待。微任务队列提供了更明确的执行时机:当前任务不能被打断,但当前任务结束后,先完成它的后续处理,再开始新的事件。
这同时满足了两个目标:
- 保证当前同步代码的完整性,不被异步回调随意插入。
- 让 Promise 等后续操作能在下一个独立事件之前及时完成。
六、核心执行顺序
浏览器中的事件循环可以简化为:
1 | |
最重要的规则是:
执行一个宏任务 → 清空全部微任务 → 浏览器可能渲染 → 执行下一个宏任务。
“可能渲染”表示浏览器会根据刷新时机和页面状态判断是否需要更新画面,并不保证每个宏任务完成后都一定立即渲染。
七、经典代码论证
1 | |
输出:
1 | |
逐步分析:
- 整段脚本是当前宏任务。
- 同步输出
1。 - 注册定时器,其回调之后作为宏任务等待执行。
Promise.then回调进入微任务队列。- 同步输出
4。 - 当前脚本执行结束,调用栈清空。
- 清空微任务队列,输出
3。 - 执行下一个宏任务,即定时器回调,输出
2。
因此顺序为:
1 | |
八、“清空全部微任务”是什么意思
事件循环不是每次只执行一个微任务,而是会持续执行,直到微任务队列为空。如果一个微任务执行时又产生了新的微任务,新微任务也会在当前检查点继续执行。
1 | |
输出:
1 | |
原因是:
- 当前脚本同步输出
1、5。 - 脚本结束后执行第一个 Promise 微任务,输出
3。 - 该微任务又创建一个输出
4的微任务。 - 微任务队列还没有清空,因此继续输出
4。 - 所有微任务完成后,才执行定时器并输出
2。
因此不能说“一个宏任务后执行一个微任务”,准确说法是:
一个宏任务结束后,会清空整个微任务队列。
九、每个宏任务结束后都要检查微任务
1 | |
输出:
1 | |
第一个定时器宏任务执行完以后,运行环境会先清空它产生的微任务,然后才执行第二个定时器宏任务。
十、Promise 构造函数是同步执行的
Promise 构造函数接收的 executor 函数会立即同步执行。异步进入微任务队列的是 .then()、.catch() 和 .finally() 注册的回调。
1 | |
输出:
1 | |
执行过程:
- 输出
1。 - Promise executor 立即执行,输出
2。 resolve()使 Promise 变为 fulfilled。.then()回调被安排为微任务。- 同步输出
4。 - 当前脚本结束后执行微任务,输出
3。
所以不能笼统地说“Promise 是异步的”。更准确的说法是:Promise executor 同步执行,而 Promise 的反应回调通过微任务调度。
十一、async/await 与微任务
async/await 建立在 Promise 机制之上。await 之前的代码同步执行;函数遇到 await 后暂停,await 之后的代码会在后续微任务中恢复执行。
1 | |
输出:
1 | |
分析:
- 输出
1。 - 调用
run(),await之前同步输出2。 - 遇到
await,run()暂停。 - 外层同步代码继续执行,输出
4。 - 当前脚本结束后,通过微任务恢复
run(),输出3。
为了理解调度顺序,可以把它近似看成:
1 | |
这种写法只是便于理解,并不是 ECMAScript 规范内部实现的完整等价展开。
十二、综合代码论证
1 | |
输出:
1 | |
第一阶段:初始脚本
同步输出 1、8。第一个 Promise 回调进入微任务队列,第一个定时器回调等待作为后续宏任务执行。
第二阶段:清空微任务
Promise 回调输出 5,并创建一个输出 6 的定时器宏任务和一个输出 7 的微任务。微任务队列必须继续清空,因此接着输出 7。
第三阶段:第一个定时器
定时器回调同步输出 2,创建输出 3 的微任务,然后同步输出 4。该宏任务结束后立即清空微任务,所以输出 3。
第四阶段:第二个定时器
最后执行之前创建的定时器,输出 6。
完整顺序是:
1 | |
十三、浏览器渲染时机
浏览器通常会在一个任务完成并清空微任务以后,获得一次可能的渲染机会:
1 | |
渲染不是 JavaScript 微任务或宏任务本身,而是浏览器事件循环中的另一个重要步骤。浏览器会综合刷新率、页面变化以及执行耗时决定是否更新画面。
如果微任务在执行过程中不断创建新的 Promise 微任务,微任务队列就可能长时间无法清空,导致后续宏任务、用户交互和页面渲染不能及时进行。这种现象称为微任务饥饿。
十四、Node.js 中的特殊情况
浏览器和 Node.js 都有事件循环,但具体调度阶段不完全相同。Node.js 的事件循环包含 timers、poll、check 等阶段:
setTimeout主要在 timers 阶段处理;setImmediate主要在 check 阶段处理;- Promise 回调属于微任务;
process.nextTick使用 Node.js 特有的、更高优先级的队列。
1 | |
在 Node.js 中通常输出:
1 | |
可以近似理解为:
1 | |
process.nextTick 是 Node.js 特有机制,不能直接套用到浏览器环境。
十五、常见误区
误区 1:同步代码、宏任务、微任务是三个平级队列
不是。整段初始脚本本身就是一个宏任务,同步代码是当前任务内部立即执行的代码;微任务和后续宏任务才涉及未来的调度时机。
误区 2:微任务永远先于宏任务
不够准确。当前宏任务必须先执行;它结束以后,微任务才先于下一个宏任务执行。
误区 3:setTimeout(fn, 0) 会立即执行
不会。它仍然要等待当前同步代码、当前微任务和排在前面的相关任务完成。
误区 4:Promise 中的所有代码都是异步的
不是。Promise executor 同步执行,Promise 的反应回调才作为微任务执行。
误区 5:每个宏任务结束后只执行一个微任务
不是。每个宏任务结束后会清空全部微任务,包括执行微任务时新产生的微任务。
误区 6:await 后面的代码会立即继续执行
不是。await 之前同步执行,await 之后的代码会通过微任务恢复执行。
十六、判断输出顺序的方法
遇到事件循环代码题时,可以按以下步骤分析:
- 把整段初始脚本视为当前宏任务。
- 从上到下执行同步代码,直接记录同步输出。
- 遇到定时器、事件回调等,将其记入后续宏任务。
- 遇到 Promise 反应回调或
await后续代码,将其记入微任务队列。 - 当前同步代码执行完后,按照入队顺序清空全部微任务。
- 微任务中产生的新微任务也要继续清空。
- 微任务队列为空后,浏览器可能进行渲染。
- 取出下一个宏任务,重复以上过程。
建议做题时分别画出两个队列:
1 | |
每执行一步就更新队列,比只凭“微任务优先”猜结果更可靠。
十七、最终结论
JavaScript 事件循环的核心执行顺序是:
1 | |
最简记忆公式:
1 | |
最关键的一句话:
微任务不会打断当前同步代码,但会在当前宏任务结束后、下一个宏任务开始前被全部执行。