JavaScript 中的事件循环:宏任务与微任务

JavaScript 中的事件循环:宏任务与微任务

一、为什么需要事件循环

JavaScript 通常运行在单线程上,同一时刻只能执行一段 JavaScript 代码。如果网络请求、定时器、文件读取和用户点击都由 JavaScript 同步等待,页面就会被阻塞。因此,浏览器或 Node.js 负责处理这些耗时操作;操作满足条件后,再把对应回调安排给 JavaScript 执行。

事件循环(Event Loop)就是运行环境协调“当前代码”和“以后要执行的回调”的调度机制。

需要先认识三个概念:

  • 调用栈(Call Stack):保存当前正在执行的函数和同步代码。
  • 任务队列(Task Queue):保存等待事件循环后续轮次处理的任务,日常教程常称为宏任务队列。
  • 微任务队列(Microtask Queue):保存当前任务结束后、下一个任务开始前要处理的回调。

需要特别注意:同步代码、宏任务、微任务并不是三个完全平级的队列。整段初始脚本本身就是一个任务,而同步代码是这个任务内部立即执行的代码。

1
2
3
4
5
6
当前任务
└── 按顺序执行其中的同步代码

以后执行的回调
├── 微任务:当前任务结束后立即清空
└── 宏任务:等待事件循环后续轮次处理

二、调用栈与同步代码

当前正在执行的函数会进入调用栈。只有调用栈清空后,运行环境才会处理已经排队的异步回调。

1
2
3
4
5
6
7
8
9
10
function a() {
console.log("a");
}

function b() {
a();
console.log("b");
}

b();

执行过程可以简化为:

1
2
3
4
5
6
b 入栈
a 入栈
输出 a
a 出栈
输出 b
b 出栈

同步代码具有两个重要特点:

  1. 按照代码顺序立即执行。
  2. 当前同步代码不会被异步回调从中间打断。

三、什么是宏任务

“宏任务”是为了和微任务相对而产生的常用说法。在浏览器规范语境中,它通常直接称为 task。

常见宏任务包括:

  • 整段 <script> 脚本;
  • setTimeout;
  • setInterval;
  • 用户交互事件,例如 click;
  • 网络和 I/O 回调;
  • postMessage;
  • MessageChannel;
  • Node.js 中的 setImmediate。

宏任务通常代表一个新的、相对独立的事件,例如定时器到期、用户点击按钮或收到网络数据。

1
2
3
4
5
6
7
console.log("开始");

setTimeout(() => {
console.log("定时器");
}, 0);

console.log("结束");

输出:

1
2
3
开始
结束
定时器

原因如下:

  1. 整段脚本作为当前宏任务执行。
  2. 同步输出“开始”。
  3. setTimeout 注册定时器。
  4. 当前脚本继续执行,同步输出“结束”。
  5. 定时器满足时间条件后,其回调进入相应的任务队列。
  6. 当前任务以及需要优先处理的微任务完成后,事件循环才会执行定时器回调。

所以,setTimeout(fn, 0) 并不是立即调用 fn,而是“至少等待相应时间后,允许回调进入队列等待调度”。


四、什么是微任务

微任务用于安排需要在当前任务结束后、下一个宏任务开始前尽快完成的后续工作。

常见微任务包括:

  • Promise.then;
  • Promise.catch;
  • Promise.finally;
  • MutationObserver;
  • await 之后恢复执行的代码;
  • Node.js 中的 process.nextTick 使用一个更高优先级的特殊队列。
1
2
3
4
5
6
7
console.log("开始");

Promise.resolve().then(() => {
console.log("Promise");
});

console.log("结束");

输出:

1
2
3
开始
结束
Promise

.then() 回调进入微任务队列,但它不会打断当前脚本。当前同步代码执行完、调用栈清空后,运行环境才会执行微任务。


五、为什么要区分宏任务和微任务

宏任务和微任务服务于两种不同需求:

  • 宏任务用于处理一个新的独立事件,例如定时器、点击和网络回调。
  • 微任务用于完成当前操作紧接着产生的后续工作,例如处理已经确定状态的 Promise。

如果所有异步回调都进入同一个普通队列,Promise 的后续处理就要与各种定时器、点击事件一起等待。微任务队列提供了更明确的执行时机:当前任务不能被打断,但当前任务结束后,先完成它的后续处理,再开始新的事件。

这同时满足了两个目标:

  1. 保证当前同步代码的完整性,不被异步回调随意插入。
  2. 让 Promise 等后续操作能在下一个独立事件之前及时完成。

六、核心执行顺序

浏览器中的事件循环可以简化为:

1
2
3
4
5
6
7
8
9
10
11
取出一个宏任务
↓
执行其中的所有同步代码
↓
调用栈清空
↓
清空全部微任务
↓
浏览器可能进行页面渲染
↓
执行下一个宏任务

最重要的规则是:

执行一个宏任务 → 清空全部微任务 → 浏览器可能渲染 → 执行下一个宏任务。

“可能渲染”表示浏览器会根据刷新时机和页面状态判断是否需要更新画面,并不保证每个宏任务完成后都一定立即渲染。


七、经典代码论证

1
2
3
4
5
6
7
8
9
10
11
console.log("1");

setTimeout(() => {
console.log("2");
}, 0);

Promise.resolve().then(() => {
console.log("3");
});

console.log("4");

输出:

1
2
3
4
1
4
3
2

逐步分析:

  1. 整段脚本是当前宏任务。
  2. 同步输出 1。
  3. 注册定时器,其回调之后作为宏任务等待执行。
  4. Promise.then 回调进入微任务队列。
  5. 同步输出 4。
  6. 当前脚本执行结束,调用栈清空。
  7. 清空微任务队列,输出 3。
  8. 执行下一个宏任务,即定时器回调,输出 2。

因此顺序为:

1
当前同步代码 1、4 → 微任务 3 → 下一个宏任务 2

八、“清空全部微任务”是什么意思

事件循环不是每次只执行一个微任务,而是会持续执行,直到微任务队列为空。如果一个微任务执行时又产生了新的微任务,新微任务也会在当前检查点继续执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
console.log("1");

setTimeout(() => {
console.log("2");
}, 0);

Promise.resolve().then(() => {
console.log("3");

Promise.resolve().then(() => {
console.log("4");
});
});

console.log("5");

输出:

1
2
3
4
5
1
5
3
4
2

原因是:

  1. 当前脚本同步输出 1、5。
  2. 脚本结束后执行第一个 Promise 微任务,输出 3。
  3. 该微任务又创建一个输出 4 的微任务。
  4. 微任务队列还没有清空,因此继续输出 4。
  5. 所有微任务完成后,才执行定时器并输出 2。

因此不能说“一个宏任务后执行一个微任务”,准确说法是:

一个宏任务结束后,会清空整个微任务队列。


九、每个宏任务结束后都要检查微任务

1
2
3
4
5
6
7
8
9
10
11
setTimeout(() => {
console.log("宏任务 1");

Promise.resolve().then(() => {
console.log("微任务 1");
});
}, 0);

setTimeout(() => {
console.log("宏任务 2");
}, 0);

输出:

1
2
3
宏任务 1
微任务 1
宏任务 2

第一个定时器宏任务执行完以后,运行环境会先清空它产生的微任务,然后才执行第二个定时器宏任务。


十、Promise 构造函数是同步执行的

Promise 构造函数接收的 executor 函数会立即同步执行。异步进入微任务队列的是 .then()、.catch() 和 .finally() 注册的回调。

1
2
3
4
5
6
7
8
9
10
console.log("1");

new Promise((resolve) => {
console.log("2");
resolve();
}).then(() => {
console.log("3");
});

console.log("4");

输出:

1
2
3
4
1
2
4
3

执行过程:

  1. 输出 1。
  2. Promise executor 立即执行,输出 2。
  3. resolve() 使 Promise 变为 fulfilled。
  4. .then() 回调被安排为微任务。
  5. 同步输出 4。
  6. 当前脚本结束后执行微任务,输出 3。

所以不能笼统地说“Promise 是异步的”。更准确的说法是:Promise executor 同步执行,而 Promise 的反应回调通过微任务调度。


十一、async/await 与微任务

async/await 建立在 Promise 机制之上。await 之前的代码同步执行;函数遇到 await 后暂停,await 之后的代码会在后续微任务中恢复执行。

1
2
3
4
5
6
7
8
9
10
console.log("1");

async function run() {
console.log("2");
await Promise.resolve();
console.log("3");
}

run();
console.log("4");

输出:

1
2
3
4
1
2
4
3

分析:

  1. 输出 1。
  2. 调用 run(),await 之前同步输出 2。
  3. 遇到 await,run() 暂停。
  4. 外层同步代码继续执行,输出 4。
  5. 当前脚本结束后,通过微任务恢复 run(),输出 3。

为了理解调度顺序,可以把它近似看成:

1
2
3
4
5
6
7
function run() {
console.log("2");

return Promise.resolve().then(() => {
console.log("3");
});
}

这种写法只是便于理解,并不是 ECMAScript 规范内部实现的完整等价展开。


十二、综合代码论证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
console.log("1");

setTimeout(() => {
console.log("2");

Promise.resolve().then(() => {
console.log("3");
});

console.log("4");
}, 0);

Promise.resolve().then(() => {
console.log("5");

setTimeout(() => {
console.log("6");
}, 0);

Promise.resolve().then(() => {
console.log("7");
});
});

console.log("8");

输出:

1
2
3
4
5
6
7
8
1
8
5
7
2
4
3
6

第一阶段:初始脚本

同步输出 1、8。第一个 Promise 回调进入微任务队列,第一个定时器回调等待作为后续宏任务执行。

第二阶段:清空微任务

Promise 回调输出 5,并创建一个输出 6 的定时器宏任务和一个输出 7 的微任务。微任务队列必须继续清空,因此接着输出 7。

第三阶段:第一个定时器

定时器回调同步输出 2,创建输出 3 的微任务,然后同步输出 4。该宏任务结束后立即清空微任务,所以输出 3。

第四阶段:第二个定时器

最后执行之前创建的定时器,输出 6。

完整顺序是:

1
1 → 8 → 5 → 7 → 2 → 4 → 3 → 6

十三、浏览器渲染时机

浏览器通常会在一个任务完成并清空微任务以后,获得一次可能的渲染机会:

1
宏任务 → 清空微任务 → 可能渲染 → 下一个宏任务

渲染不是 JavaScript 微任务或宏任务本身,而是浏览器事件循环中的另一个重要步骤。浏览器会综合刷新率、页面变化以及执行耗时决定是否更新画面。

如果微任务在执行过程中不断创建新的 Promise 微任务,微任务队列就可能长时间无法清空,导致后续宏任务、用户交互和页面渲染不能及时进行。这种现象称为微任务饥饿。


十四、Node.js 中的特殊情况

浏览器和 Node.js 都有事件循环,但具体调度阶段不完全相同。Node.js 的事件循环包含 timers、poll、check 等阶段:

  • setTimeout 主要在 timers 阶段处理;
  • setImmediate 主要在 check 阶段处理;
  • Promise 回调属于微任务;
  • process.nextTick 使用 Node.js 特有的、更高优先级的队列。
1
2
3
4
5
6
7
8
9
10
11
console.log("1");

Promise.resolve().then(() => {
console.log("2");
});

process.nextTick(() => {
console.log("3");
});

console.log("4");

在 Node.js 中通常输出:

1
2
3
4
1
4
3
2

可以近似理解为:

1
2
3
4
5
6
7
当前同步代码
↓
process.nextTick 队列
↓
Promise 等微任务队列
↓
后续事件循环阶段

process.nextTick 是 Node.js 特有机制,不能直接套用到浏览器环境。


十五、常见误区

误区 1:同步代码、宏任务、微任务是三个平级队列

不是。整段初始脚本本身就是一个宏任务,同步代码是当前任务内部立即执行的代码;微任务和后续宏任务才涉及未来的调度时机。

误区 2:微任务永远先于宏任务

不够准确。当前宏任务必须先执行;它结束以后,微任务才先于下一个宏任务执行。

误区 3:setTimeout(fn, 0) 会立即执行

不会。它仍然要等待当前同步代码、当前微任务和排在前面的相关任务完成。

误区 4:Promise 中的所有代码都是异步的

不是。Promise executor 同步执行,Promise 的反应回调才作为微任务执行。

误区 5:每个宏任务结束后只执行一个微任务

不是。每个宏任务结束后会清空全部微任务,包括执行微任务时新产生的微任务。

误区 6:await 后面的代码会立即继续执行

不是。await 之前同步执行,await 之后的代码会通过微任务恢复执行。


十六、判断输出顺序的方法

遇到事件循环代码题时,可以按以下步骤分析:

  1. 把整段初始脚本视为当前宏任务。
  2. 从上到下执行同步代码,直接记录同步输出。
  3. 遇到定时器、事件回调等,将其记入后续宏任务。
  4. 遇到 Promise 反应回调或 await 后续代码,将其记入微任务队列。
  5. 当前同步代码执行完后,按照入队顺序清空全部微任务。
  6. 微任务中产生的新微任务也要继续清空。
  7. 微任务队列为空后,浏览器可能进行渲染。
  8. 取出下一个宏任务,重复以上过程。

建议做题时分别画出两个队列:

1
2
微任务队列:[……]
宏任务队列:[……]

每执行一步就更新队列,比只凭“微任务优先”猜结果更可靠。


十七、最终结论

JavaScript 事件循环的核心执行顺序是:

1
2
3
4
5
6
1. 取出一个宏任务
2. 执行该宏任务中的同步代码,直到调用栈清空
3. 清空当前全部微任务
4. 浏览器可能进行页面渲染
5. 执行下一个宏任务
6. 重复以上过程

最简记忆公式:

1
一个宏任务 → 所有微任务 → 可能渲染 → 下一个宏任务

最关键的一句话:

微任务不会打断当前同步代码,但会在当前宏任务结束后、下一个宏任务开始前被全部执行。


JavaScript 中的事件循环:宏任务与微任务
http://example.com/2026/08/07/js中的事件循环/
Author
John Doe
Posted on
August 7, 2026
Licensed under