JavaScript 的事件循环(Event Loop)是其异步编程的核心机制。由于 JavaScript 是单线程的,它通过事件循环来协调各种任务的执行,使得非阻塞 I/O、定时器、用户交互等能够有序进行。理解事件循环的关键在于区分宏任务(MacroTask) 和微任务(MicroTask) ,并掌握它们的执行顺序。
1. 基本概念
-
调用栈(Call Stack) :执行同步代码的地方,函数调用会以栈帧形式压入栈,执行完毕后弹出。
-
任务队列(Task Queue) :存放待执行的异步任务回调。分为两类:
- 宏任务队列(MacroTask Queue) :由宿主环境(浏览器、Node.js)提供的任务源产生的任务。
- 微任务队列(MicroTask Queue) :由 JavaScript 自身提供的任务源产生的任务,优先级高于宏任务。
注意:规范中将宏任务简称为“task”,微任务称为“microtask”。HTML 标准规定每个事件循环有一个或多个任务队列(每个任务源对应一个队列),而微任务队列是唯一的。
2. 常见任务类型
| 任务类型 | 常见来源 |
|---|---|
| 宏任务(Task) | <script> 整体代码、setTimeout、setInterval、setImmediate(Node.js 独有)、I/O 操作、UI 渲染、MessageChannel、postMessage 等 |
| 微任务(Microtask) | Promise.then/catch/finally、MutationObserver、queueMicrotask、process.nextTick(Node.js 环境,其优先级高于普通微任务) |
3. 事件循环执行顺序
每一轮事件循环(一次 tick)的执行流程如下:
- 执行一个宏任务:从宏任务队列中取出最早的一个任务执行。首次执行时,
<script>整体代码就是第一个宏任务。 - 清空微任务队列:当前宏任务执行完毕后,立即检查微任务队列,并依次执行其中所有的微任务,直到队列为空。如果在执行微任务过程中产生了新的微任务,也会在本轮清空。
- 必要时更新渲染:浏览器可能会在此时进行 UI 渲染(渲染时机由浏览器决定,但通常发生在清空微任务之后、下一个宏任务之前)。
- 重复循环:从宏任务队列中取下一个任务,继续步骤 1。
关键原则:
- 微任务优先级高于宏任务:在当前宏任务结束后、下一个宏任务开始前,一定会清空微任务队列。
- 微任务队列是逐轮清空:每一轮事件循环只会执行一个宏任务,但会清空所有微任务。
- 渲染时机:渲染操作通常发生在微任务队列清空之后、下一个宏任务之前,但具体时机取决于浏览器的调度策略和帧率限制。
通俗理解
JavaScript 就像只有一个服务员,按以下规则干活:
- 先做完手头所有事(同步代码)。
- 立刻处理所有临时插进来的小事(微任务),直到小事清空。
- 再处理一件预约的大事(宏任务)。
- 大事办完后,回到步骤2(处理大事带来的新小事),然后继续步骤3(下一件大事),如此循环。
一句话总结:同步代码最先,然后所有微任务,接着一个宏任务,重复。
4. 示例解析
示例1:基础顺序
javascript
console.log('1'); // 同步代码
setTimeout(() => {
console.log('2'); // 宏任务
}, 0);
Promise.resolve().then(() => {
console.log('3'); // 微任务
});
console.log('4'); // 同步代码
执行步骤:
-
当前宏任务为整体
<script>:- 输出
'1' - 将
setTimeout回调加入宏任务队列 - 将
Promise.then回调加入微任务队列 - 输出
'4' - 当前宏任务执行完毕
- 输出
-
检查微任务队列:执行
Promise.then回调,输出'3' -
微任务队列清空,可能渲染
-
取出下一个宏任务(
setTimeout回调),输出'2'
输出结果:1 4 3 2
示例2:嵌套微任务
javascript
console.log('start');
Promise.resolve().then(() => {
console.log('promise1');
Promise.resolve().then(() => {
console.log('promise2');
});
});
setTimeout(() => {
console.log('timeout');
}, 0);
console.log('end');
执行过程:
-
宏任务(整体代码):
- 输出
'start' - 将第一个
Promise.then加入微任务队列 - 将
setTimeout加入宏任务队列 - 输出
'end'
- 输出
-
清空微任务:
- 执行第一个
then:输出'promise1',并将第二个then加入微任务队列 - 继续清空微任务:执行第二个
then,输出'promise2'
- 执行第一个
-
微任务清空
-
执行下一个宏任务:
setTimeout回调输出'timeout'
输出:start end promise1 promise2 timeout
示例3:混合宏任务与微任务
javascript
setTimeout(() => {
console.log('timeout1');
Promise.resolve().then(() => console.log('promise1'));
}, 0);
setTimeout(() => {
console.log('timeout2');
}, 0);
Promise.resolve().then(() => {
console.log('promise2');
setTimeout(() => console.log('timeout3'), 0);
});
执行过程:
-
宏任务(整体代码):
- 将第一个
setTimeout加入宏任务队列 - 将第二个
setTimeout加入宏任务队列 - 将
Promise.then加入微任务队列
- 将第一个
-
清空微任务:
- 执行
promise2回调:输出'promise2',并将setTimeout3加入宏任务队列
- 执行
-
微任务清空
-
宏任务1(第一个
setTimeout):- 输出
'timeout1' - 将
promise1加入微任务队列 - 宏任务结束,清空微任务:执行
promise1,输出'promise1'
- 输出
-
宏任务2(第二个
setTimeout):- 输出
'timeout2'
- 输出
-
宏任务3(第三个
setTimeout):- 输出
'timeout3'
- 输出
输出:promise2 timeout1 promise1 timeout2 timeout3
5. 深入细节
-
多个宏任务队列:根据规范,不同任务源(如
setTimeout、I/O、UI 渲染)可以拥有自己的队列,浏览器会按一定顺序(如优先处理高优先级队列)从中选取任务执行。但在实践中,可以近似看作一个先进先出的队列,但需注意setTimeout的延迟可能不精确。 -
微任务队列唯一性:所有微任务共享一个队列,必须在本轮全部清空。
-
await的处理:async/await是 Promise 的语法糖。await后面的代码相当于Promise.then中的回调,属于微任务。 -
Node.js 环境的差异:
- Node.js 中的事件循环分为多个阶段(timers、pending callbacks、idle/prepare、poll、check、close callbacks),每个阶段对应一个宏任务队列。
process.nextTick不属于微任务,但优先级高于微任务,会在当前阶段结束后、下一阶段前立即执行。- 浏览器环境没有
process.nextTick,但queueMicrotask可以添加微任务。
6. 为什么需要区分宏任务和微任务?
- 宏任务 用于处理耗时较长的异步操作(如定时器、I/O),避免阻塞主线程。
- 微任务 用于在当前宏任务结束后、渲染前执行高优先级的操作(如 Promise 回调、DOM 状态更新),保证在浏览器渲染之前完成必要的 JavaScript 计算,避免不必要的布局抖动和性能损失。
这种设计确保了异步任务的及时性和渲染的流畅性,是 JavaScript 高性能的基础。
7. 总结
- 事件循环是一个永不停歇的循环,每次迭代处理一个宏任务,然后清空所有微任务。
- 微任务始终在宏任务之后、下一个宏任务之前执行。
- 理解事件循环能帮助开发者写出更可预测的异步代码,避免常见陷阱(如
setTimeout嵌套的延迟、Promise 的执行时机等)。