文章

宏任务与微任务执行机制深度解析

深入剖析 JavaScript 事件循环中的宏任务与微任务机制,理解 setTimeout/setInterval 与 Promise.then 的执行顺序规则,掌握 Event Loop 面试核心考题。

宏任务与微任务执行机制深度解析

一句话概括

JavaScript 事件循环(Event Loop)通过宏任务队列与微任务队列的双队列设计,实现了”执行一个宏任务 → 清空所有微任务 → 渲染 → 取下一个宏任务”的循环模型,微任务总是优先于下一个宏任务执行。


背景

JavaScript 是单线程语言,所有代码在同一条调用栈上执行。为了处理异步操作(网络请求、定时器、用户交互),浏览器引入了事件循环机制。早期只有简单的任务队列概念,随着 Promise 的引入,ES 规范正式将任务分为宏任务(macrotask/task)微任务(microtask/job)两种优先级,彻底改变了异步代码的执行时序。

理解宏任务与微任务的执行顺序,是解答所有”输出顺序”类面试题的基础,也是理解 Vue 的 nextTick、React 的批量更新等框架机制的底层前提。


概念与定义

宏任务(Macrotask / Task)

来源示例
定时器setTimeoutsetInterval
I/O 操作网络请求完成、文件读取完成
UI 交互点击、滚动等用户事件
浏览器 APIrequestAnimationFrame(部分实现归为宏任务)
DOM 变更MessageChannel

微任务(Microtask / Job)

来源示例
Promise.then().catch().finally()
MutationObserverDOM 变更监听回调
queueMicrotask显式微任务调度
async/awaitawait 之后的代码等价于 .then()
Object.observe已废弃,但属于微任务

核心规则

  1. 每轮循环只取一个宏任务执行
  2. 宏任务执行完后,清空整个微任务队列(逐个执行至队列为空)
  3. 微任务执行过程中新产生的微任务,在本轮清空阶段一并执行
  4. 微任务全部清空后,才可能进行 UI 渲染
  5. 渲染完成后,取下一个宏任务,开始下一轮循环

最小示例

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'))          // 微任务1
  .then(() => console.log('4'));         // 微任务2(由微任务1产生)

console.log('5');                        // 同步代码(当前宏任务)

// 输出顺序:1 → 5 → 3 → 4 → 2

解析

  • 15:同步代码,属于当前宏任务,立即执行
  • 34:Promise.then 产生微任务,当前宏任务结束后清空微任务队列
  • 2:setTimeout 产生宏任务,下一轮事件循环才执行

核心知识点拆解

1. 事件循环完整流程

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
┌───────────────────────────────────┐
│           事件循环模型              │
│                                   │
│  ┌─────────┐    ┌─────────────┐  │
│  │ 宏任务   │    │  微任务队列  │  │
│  │  队列    │    │ (FIFO, 全清) │  │
│  └────┬────┘    └──────┬──────┘  │
│       │                │         │
│       ▼                │         │
│  取一个宏任务 ──────► 执行同步代码 │
│                          │       │
│                          ▼       │
│                    清空微任务队列  │
│                          │       │
│                          ▼       │
│                    是否需要渲染?   │
│                     │        │   │
│                    是        否   │
│                     ▼        │   │
│                  UI 渲染     │   │
│                     │        │   │
│                     └───┬────┘   │
│                         ▼        │
│                   下一轮循环      │
└───────────────────────────────────┘

2. 微任务的”插队”特性

微任务在当前宏任务结束后、下一个宏任务开始前全部执行完毕,相当于插队到下一个宏任务前面

1
2
3
4
5
6
7
8
9
10
setTimeout(() => console.log('timeout'), 0);  // 宏任务A

Promise.resolve().then(() => {
  console.log('microtask1');
  Promise.resolve().then(() => {
    console.log('microtask2');  // 微任务中产生的微任务,本轮清空
  });
});

// 输出:microtask1 → microtask2 → timeout

关键点:微任务中产生的微任务不会延迟到下一轮,而是在当前微任务清空阶段一并执行。

3. async/await 的微任务本质

async/await 是 Promise 的语法糖,await 之后的代码等价于 .then()

1
2
3
4
5
6
7
8
9
10
11
async function foo() {
  console.log('1');          // 同步
  await Promise.resolve();
  console.log('2');          // 等价于 .then(() => console.log('2')),微任务
}

console.log('3');            // 同步
foo();
console.log('4');            // 同步

// 输出:3 → 1 → 4 → 2

等价转换:

1
2
3
4
5
6
function foo() {
  console.log('1');
  Promise.resolve().then(() => {
    console.log('2');  // 微任务
  });
}

4. setTimeout(fn, 0) 不是真零延迟

1
2
3
4
5
6
7
// HTML5 规范:setTimeout 最小延迟为 4ms(嵌套超过5层后)
setTimeout(() => console.log('timeout'), 0);

// 在同一调用栈中,多次 setTimeout(0) 的执行顺序
setTimeout(() => console.log('A'), 0);
setTimeout(() => console.log('B'), 0);
// A 一定在 B 前面(同优先级 FIFO)

5. 微任务队列的无限扩展陷阱

1
2
3
4
5
6
7
8
9
// ⚠️ 危险:微任务队列无限扩展,导致宏任务永远无法执行
function infiniteMicrotasks() {
  Promise.resolve().then(() => {
    console.log('microtask');
    infiniteMicrotasks();  // 递归产生微任务,页面卡死!
  });
}
infiniteMicrotasks();
// setTimeout 永远不会执行,UI 无法渲染

这是一个常见的性能陷阱:微任务队列必须清空后才能执行下一个宏任务和渲染,如果在微任务中不断产生新微任务,页面会完全卡死。


实战案例

案例一:经典面试输出顺序题

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

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

new Promise((resolve) => {
  console.log('promise1');     // Promise 构造函数是同步执行的!
  resolve();
}).then(() => {
  console.log('promise2');
}).then(() => {
  console.log('promise3');
});

console.log('script end');

// 输出顺序:
// script start     ← 同步
// promise1         ← 同步(Promise 构造函数同步执行)
// script end       ← 同步
// promise2         ← 微任务(第1个then)
// promise3         ← 微任务(第2个then,由第1个then产生)
// setTimeout       ← 宏任务

核心陷阱:Promise 的 executor(构造函数参数)是同步执行的,只有 .then() 中的回调才是微任务。

案例二:async/await 与 Promise 混合

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
26
27
28
29
30
31
32
33
34
35
36
async function async1() {
  console.log('async1 start');
  await async2();             // await 右侧同步执行,之后进入微任务
  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            ← await async2() 同步执行
// promise1          ← Promise executor 同步执行
// script end
// async1 end        ← 微任务(await 之后的代码)
// promise2          ← 微任务
// setTimeout        ← 宏任务

案例三:微任务中创建宏任务

1
2
3
4
5
6
7
8
9
10
11
12
Promise.resolve().then(() => {
  console.log('microtask1');
  setTimeout(() => console.log('macro from micro'), 0);
}).then(() => {
  console.log('microtask2');
});

setTimeout(() => console.log('macro1'), 0);
setTimeout(() => console.log('macro2'), 0);

// 输出顺序:
// microtask1 → microtask2 → macro1 → macro2 → macro from micro

在微任务中注册的宏任务会排到宏任务队列末尾,不会提前执行。


底层原理

V8 引擎中的微任务实现

V8 使用微任务检查点(Microtask Checkpoint)机制:

  1. 每个宏任务执行后,V8 会调用 PerformMicrotaskCheckpoint()
  2. 该函数循环取出微任务队列中的任务执行,直到队列为空
  3. 微任务执行过程中新增的微任务会在同一轮 Checkpoint 中执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
调用栈          微任务队列          宏任务队列
  │                │                  │
  │ 同步代码 ──►   │                  │
  │                │                  │
  │ await/then ──► [cb1, cb2]         │
  │                │                  │
  │ 同步代码结束    │                  │
  │                │                  │
  ▼ Checkpoint     │                  │
  │ ◄── cb1 ──────┤                  │
  │     (执行)     │                  │
  │ ◄── cb2 ──────┤                  │
  │     (执行)     │                  │
  │                │                  │
  │ 渲染?         │                  │
  │                │                  │
  ▼ 取宏任务       │           ◄── task1
  │ ◄─────────────────────────────────┤

浏览器中的任务调度

Chrome 的任务调度器实现:

  • 主线程:执行 JS、DOM、样式计算、布局、绘制
  • 定时器线程:管理 setTimeout/setInterval 计时(非主线程)
  • 事件触发线程:管理用户交互事件(非主线程)

定时器到期后,回调被推入主线程的宏任务队列,等待主线程取出执行。这就是为什么 setTimeout(fn, 0) 不是零延迟——主线程可能正在执行其他任务。

Node.js 事件循环的差异

Node.js 的事件循环基于 libuv,分为 6 个阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
   ┌──────────────────────────┐
   │        timers             │ ← setTimeout/setInterval
   ├──────────────────────────┤
   │     pending callbacks     │ ← 系统级回调
   ├──────────────────────────┤
   │       idle, prepare       │ ← 内部使用
   ├──────────────────────────┤
   │         poll              │ ← I/O 回调
   ├──────────────────────────┤
   │         check             │ ← setImmediate
   ├──────────────────────────┤
   │     close callbacks       │ ← close 事件
   └──────────────────────────┘

Node.js 中 process.nextTick 优先级高于 Promise.then(微任务内部再分级)。


高频面试题解析

Q1:宏任务和微任务的区别是什么?

  • 执行时机:微任务在当前宏任务结束后立即执行,宏任务在下一轮事件循环执行
  • 队列策略:每轮循环只取一个宏任务,但清空全部微任务
  • 典型代表:宏任务——setTimeout/setInterval/I/O;微任务——Promise.then/MutationObserver
  • 优先级:微任务 > 宏任务(微任务总是先于下一个宏任务执行)

Q2:为什么 Promise.then 是微任务而不是宏任务?

:设计为微任务是为了最小化延迟。Promise 表示”即将发生的操作”,如果放在宏任务队列,需要等下一个宏任务才能执行;作为微任务,可以在当前宏任务结束后立即执行,保证 then 回调的时效性。这也是 Vue 的 nextTick 使用微任务的原因——确保 DOM 更新后立即执行回调。

Q3:Promise 构造函数是同步还是异步?

同步执行。只有 .then().catch().finally() 中的回调才是微任务。构造函数中的 executor 立即同步执行,这是最常见的面试陷阱。

Q4:微任务中产生的微任务什么时候执行?

:在当前微任务清空阶段执行。微任务队列是动态的——执行一个微任务可能产生新的微任务,这些新微任务会被追加到队列末尾,在当前 Checkpoint 中继续执行,直到队列完全清空。

Q5:requestAnimationFrame 是宏任务还是微任务?

:既不是标准宏任务也不是标准微任务。它在微任务清空后、浏览器渲染前执行,可以视为”渲染前任务”。执行顺序:微任务 → requestAnimationFrame → 渲染 → 宏任务。

Q6:以下代码的输出顺序?

1
2
3
4
5
6
setTimeout(() => console.log(1), 0);
new Promise(resolve => {
  console.log(2);
  resolve();
}).then(() => console.log(3));
console.log(4);

:2 → 4 → 3 → 1。

  • 2:Promise executor 同步执行
  • 4:同步代码
  • 3:Promise.then 微任务
  • 1:setTimeout 宏任务

总结与扩展

要点说明
双队列宏任务队列(FIFO,逐个取)+ 微任务队列(FIFO,一次性清空)
执行顺序同步代码 → 清空微任务 → 可能渲染 → 取下一个宏任务
Promise executor同步执行,不是微任务
async/awaitawait 后的代码等价于 .then(),属于微任务
微任务插队微任务总是在下一个宏任务前执行完毕
微任务陷阱递归微任务会导致页面卡死(宏任务和渲染被阻塞)

扩展阅读

本文由作者按照 CC BY 4.0 进行授权