事件循环到底在循环什么?5 道面试题拆透宏任务与微任务的执行顺序

0 阅读15分钟

Event Loop 机制拆解、调用栈与任务队列状态追踪、async/await 在事件循环中的真实表现

写在前面

先来看一道题,自己先猜一下输出顺序:

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.1 一句话说不清,但两句话可以

JavaScript 是单线程的——但「单线程」到底意味着什么?

意味着 JS 引擎在同一时刻只能做一件事。

那么如果用户点了个按钮触发了一个耗时 3 秒的操作,这 3 秒内页面就卡死不动了?

答案是不能。所以浏览器搞了一套机制——主线程忙着呢,我先把耗时的任务丢到一边,等有空了再来处理。

这其实就是事件循环。

1.2 调用栈与任务队列

涉及到两个核心概念:

调用栈(Call Stack):JS 引擎执行代码的地方。函数被调用时压入栈顶,执行完毕后弹出。栈是后进先出(LIFO)。

任务队列(Task Queue):存放异步回调的地方。队列是先进先出(FIFO)。

举个例子:

function foo() {
  console.log('foo');
}

function bar() {
  foo();
  console.log('bar');
}

bar();

// foo
// bar

执行过程:

1. bar() 被调用 → 压入调用栈
   调用栈: [bar]
   
2. bar 内部调用 foo() → 压入调用栈
   调用栈: [bar, foo]
   
3. foo 执行 console.log('foo') → 压入 → 执行 → 弹出
   调用栈: [bar, foo]
   
4. foo 执行完毕 → 弹出
   调用栈: [bar]
   
5. bar 执行 console.log('bar') → 压入 → 执行 → 弹出
   调用栈: [bar]
   
6. bar 执行完毕 → 弹出
   调用栈: []  ← 空了

输出顺序:foo → bar

这是同步代码的执行过程,和事件循环还没关系。

1.3 异步代码怎么跑的

看这段:

console.log('start');

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

console.log('end');

// start
// end
// timeout

执行过程:

1. console.log('start') 执行 → 输出 "start"
   调用栈: []
   
2. setTimeout(...) 被调用
   → 浏览器 Web API 接管定时器
   → 回调函数被记录,0ms 后放入宏任务队列
   调用栈: []
   宏任务队列: [() => console.log('timeout')]
   
3. console.log('end') 执行 → 输出 "end"
   调用栈: []
   
4. 调用栈空了!事件循环开始工作:
   → 检查微任务队列:空
   → 从宏任务队列取一个任务执行
   → console.log('timeout') 执行 → 输出 "timeout"

输出顺序:start → end → timeout

这就是事件循环的基本流程:同步代码先跑完,然后不断检查任务队列,有任务就取出来执行。


二、宏任务与微任务

2.1 为什么要有两种任务队列

如果只有一种队列,那 setTimeoutPromise.then 的回调就按先来后到执行。

但 JS 的设计者认为:Promise 的回调更重要,应该优先执行。

所以任务队列被分成了两类:

类型包含哪些执行时机
宏任务(MacroTask)setTimeout、setInterval、I/O、UI 渲染、script 整体代码每轮事件循环取一个执行
微任务(MicroTask)Promise.then/catch/finally、MutationObserver、queueMicrotask每个宏任务执行完后,清空所有微任务

在这里插入图片描述

注意几个容易混淆的:

// ❌ Promise 构造函数内的代码是同步的!
new Promise((resolve) => {
  console.log('promise 内部是同步的');  // ← 这行同步执行
  resolve();
}).then(() => {
  console.log('then 是微任务');  // ← 这行异步执行
});

// ✅ async/await 的等价形式
async function test() {
  console.log('async 函数体同步部分');
  await Promise.resolve();
  console.log('await 之后的代码相当于 .then()');  // ← 微任务
}

2.2 事件循环的完整流程

在这里插入图片描述

┌─────────────────────────────────┐
│         执行一个宏任务           │
│  (script 整体代码也算一个)      │
└──────────────┬──────────────────┘
               │
               ▼
┌─────────────────────────────────┐
│     检查微任务队列,全部执行      │
│   (直到微任务队列为空)          │
└──────────────┬──────────────────┘
               │
               ▼
┌─────────────────────────────────┐
│        浏览器决定是否渲染         │
│    (通常 16.67ms 一次)         │
└──────────────┬──────────────────┘
               │
               ▼
┌─────────────────────────────────┐
│    回到顶部,取下一个宏任务       │
└─────────────────────────────────┘

第一步:取出并执行一个宏任务 从宏任务队列(Task Queue)中取出队首的一个任务执行。这里的宏任务包括:整体 <script> 代码、setTimeoutsetIntervalI/O 操作、UI 交互事件(如 click)等。

第二步:清空微任务队列(关键防坑点) 宏任务执行完毕后,立即检查微任务队列(Microtask Queue)。必须将当前微任务队列中的所有任务全部执行完毕,直到队列为空,才能进入下一步。微任务包括 Promise.then/catch/finallyMutationObserverqueueMicrotask
(特别警惕:如果在微任务中不断往队列里添加新的微任务,主线程会一直卡在这里,无法进入渲染,导致页面“假死”。)

第三步:判断是否需要更新渲染(浏览器渲染时机) 微任务队列清空后,浏览器会检查当前是否处于渲染时机(即是否有垂直同步信号 VSync 到来)。
(重要纠正:并不是每 16.67ms 强制渲染,而是显示器发出了刷新信号,且主线程刚好处于空闲状态时,才有机会渲染。)

第四步(若需要渲染):执行渲染前回调 如果确定本次需要渲染,浏览器会先执行 requestAnimationFrame(rAF)的所有回调函数。这个 API 的回调一定在渲染之前执行,且与显示器刷新频率同步。

第五步(若需要渲染):执行渲染流水线 执行 rAF 后,浏览器进行样式计算(Recalculate Style)布局(Layout)绘制(Paint)合成(Composite) 等一系列渲染操作,最终将画面呈现到屏幕上。

第六步:进入下一轮循环 渲染完成(或不需要渲染)后,本轮事件循环结束,回到第一步,从宏任务队列中取出下一个宏任务继续执行。


简单点说就是 “一宏 → 全微 → 渲染(rAF + 画)”执行 1 个宏任务清空所有微任务到了渲染时机就执行 rAF 并渲染,然后循环往复。

  1. 关于宏任务优先级
    “宏任务队列其实不止一个,浏览器根据任务源(Task Source)分成了多个队列,比如 setTimeout 队列和 UI 交互队列。浏览器通常会优先处理用户交互任务(如点击),保证页面高响应性,而不是严格的先进先出。”

  2. 关于 16.67ms 的本质
    “如果某个宏任务执行了 50ms,超过了 16.67ms 的帧预算,那么这一帧的渲染就会被直接跳过(掉帧)。浏览器不会中途打断 JS 去渲染,必须等到当前宏任务和所有微任务都执行完,才会去响应 VSync 信号。”

关键点:微任务队列每次都会被清空,不是只取一个。

这意味着如果一个微任务里又产生了新的微任务,新的微任务也会在当前轮次被执行完。

测试一下:

Promise.resolve().then(() => {
  console.log('微任务1');
  Promise.resolve().then(() => {
    console.log('微任务1产生的微任务');
  });
});

Promise.resolve().then(() => {
  console.log('微任务2');
});

// 输出:微任务1 → 微任务2 → 微任务1产生的微任务

为什么 微任务1产生的微任务 排在最后?因为在执行 微任务1 时,新产生的微任务被追加到队列尾部。此时队列里已经有 微任务2 了,所以 微任务2 先执行,然后才轮到新产生的那个。


三、5 道面试题拆解

五道面试题是逐步深入的,可以按照顺序看

先尝试自己做一遍,看看是否与答案一致,然后再看拆解过程以及注意点

面试题 1:基础题

console.log('script start');

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

Promise.resolve().then(() => {
  console.log('promise1');
}).then(() => {
  console.log('promise2');
});

console.log('script end');

执行追踪:

步骤1: 执行 console.log('script start')
  → 输出: script start
  调用栈: []  宏任务队列: []  微任务队列: []

步骤2: 执行 setTimeout(...)
  → Web API 接管,0ms 后回调进入宏任务队列
  调用栈: []  宏任务队列: [setTimeout回调]  微任务队列: []

步骤3: 执行 Promise.resolve().then(...)
  → then 回调进入微任务队列
  调用栈: []  宏任务队列: [setTimeout回调]  微任务队列: [promise1回调]

步骤4: 执行 console.log('script end')
  → 输出: script end
  调用栈: []  宏任务队列: [setTimeout回调]  微任务队列: [promise1回调]

─── script 作为宏任务执行完毕,开始清空微任务 ───

步骤5: 取出 promise1 回调执行
  → 输出: promise1
  → 第一个 .then() 返回新的 Promise,第二个 .then 回调进入微任务队列
  调用栈: []  宏任务队列: [setTimeout回调]  微任务队列: [promise2回调]

步骤6: 取出 promise2 回调执行
  → 输出: promise2
  调用栈: []  宏任务队列: [setTimeout回调]  微任务队列: []

─── 微任务清空,取下一个宏任务 ───

步骤7: 取出 setTimeout 回调执行
  → 输出: setTimeout

答案: script start → script end → promise1 → promise2 → setTimeout

需要注意一个细节:第一个 .then() 执行完后,第二个 .then() 才被放入微任务队列。因为 Promise 链是前一个完成后才把后一个排进去的。

面试题 2:Promise 嵌套

console.log('1');

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

new Promise((resolve) => {
  console.log('3');
  resolve();
}).then(() => {
  console.log('4');
  return new Promise((resolve) => {
    resolve();
  });
}).then(() => {
  console.log('5');
});

console.log('6');

先说一个最容易踩的坑: new Promise((resolve) => { console.log('3'); resolve(); }) 里面的 console.log('3')同步执行的。Promise 构造函数接收的函数是立即执行的。

执行追踪:

步骤1: console.log('1') → 输出: 1

步骤2: setTimeout → 回调进入宏任务队列

步骤3: new Promise 执行
  → 构造函数内 console.log('3') 同步执行 → 输出: 3resolve() 被调用,Promise 状态变为 fulfilled
  → .then() 回调进入微任务队列

步骤4: console.log('6') → 输出: 6

─── 同步代码执行完,清空微任务 ───

步骤5: 执行第一个 .then 回调
  → 输出: 4return new Promise(...) → 返回一个已 resolve 的 Promise
  → ⚠️ 关键:返回的是一个新的 Promise,第二个 .then 要等这个 Promise resolve 后才进入队列
  → 第二个 .then 回调进入微任务队列

步骤6: 执行第二个 .then 回调
  → 输出: 5

─── 微任务清空,取宏任务 ───

步骤7: 执行 setTimeout 回调
  → 输出: 2

答案: 1 → 3 → 6 → 4 → 5 → 2

注意步骤5的那个 return new Promise(...) ——如果你在 .then() 里返回一个 Promise,后续的 .then() 会等这个 Promise resolve 后才执行。这和直接返回一个普通值不一样。

面试题 3:async/await 的陷阱

async function async1() {
  console.log('async1 start');
  await async2();
  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('promise');
  resolve();
}).then(() => {
  console.log('then');
});

console.log('script end');

这道题比较经典。经常会有人翻车,翻车点在 await async2() 之后的代码到底什么时候执行。

核心知识点: await 之后的代码,等价于放在 .then() 里——是微任务。

也就是说:

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

// ↑ 等价于 ↓

async function async1() {
  console.log('async1 start');
  Promise.resolve(async2()).then(() => {
    console.log('async1 end');
  });
}

注意 async2() 本身是同步调用的——它内部的 console.log('async2') 会立即执行。await 暂停的是 async1 后续的代码,不是 async2 的执行。

执行追踪:

步骤1: console.log('script start') → 输出: script start

步骤2: setTimeout → 回调进入宏任务队列

步骤3: 调用 async1()
  → console.log('async1 start') → 输出: async1 start
  → 调用 async2()
    → console.log('async2') → 输出: async2
  → async2() 返回 Promise(已 resolve)
  → await 暂停 async1,console.log('async1 end') 进入微任务队列

步骤4: new Promise 执行
  → console.log('promise') → 输出: promise
  → resolve() 调用
  → .then() 回调进入微任务队列

步骤5: console.log('script end') → 输出: script end

─── 同步代码执行完,清空微任务 ───

步骤6: 执行 async1 end → 输出: async1 end

步骤7: 执行 .then 回调 → 输出: then

─── 微任务清空,取宏任务 ───

步骤8: 执行 setTimeout → 输出: setTimeout

答案: script start → async1 start → async2 → promise → script end → async1 end → then → setTimeout

这题最容易翻车的地方:很多人以为 async1 end 会在 then 之后执行。

其实不会,因为 await 产生的微任务比 Promise.then 的微任务先入队,所以先执行。

面试题 4:微任务中产生宏任务

console.log('1');

setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => {
    console.log('3');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('4');
  setTimeout(() => {
    console.log('5');
  }, 0);
});

console.log('6');

这题的关键是:微任务执行过程中产生的新宏任务,不会在当前轮次执行。必须等当前所有微任务清空后,才去宏任务队列取。

执行追踪:

步骤1: console.log('1') → 输出: 1

步骤2: setTimeout → 回调A进入宏任务队列

步骤3: Promise.resolve().then → 回调B进入微任务队列

步骤4: console.log('6') → 输出: 6

─── 清空微任务 ───

步骤5: 执行回调B
  → console.log('4') → 输出: 4setTimeout → 回调C进入宏任务队列
  宏任务队列: [回调A, 回调C]

─── 微任务清空,取宏任务 ───

步骤6: 执行回调A(先入队的先执行)
  → console.log('2') → 输出: 2Promise.resolve().then → 回调D进入微任务队列
  
─── 宏任务执行完,清空微任务 ───

步骤7: 执行回调D
  → console.log('3') → 输出: 3

─── 微任务清空,取下一个宏任务 ───

步骤8: 执行回调C
  → console.log('5') → 输出: 5

答案: 1 → 6 → 4 → 2 → 3 → 5

注意 5 排在最后。因为回调C是微任务执行过程中产生的宏任务,它排在回调A后面。而回调A执行时又产生了微任务D,D会在C之前执行(微任务优先于宏任务)。

这就是事件循环的精髓:宏任务 → 清空微任务 → 宏任务 → 清空微任务 → ... 不断循环。

这道题其实有个扩展点:如果回调A执行耗时很长,回调C会先执行吗?

可以回答:

不会。因为 JS 是单线程的,宏任务队列遵循 FIFO(先进先出) 原则。回调A在 setTimeout 调用时(步骤2)就已经入队了,而回调C是在步骤5的微任务执行期间才入队。主线程必须先把队列头部的回调A取出来执行完,才会去取回调C。即使回调A里有 while(1) 死循环,回调C也只能在队列里干等,无法插队。

扩展:下面的代码会输出什么?

setTimeout(() => console.log('A'), 0);
queueMicrotask(() => console.log('B'));

可以将你的答案放到评论区,讨论一下

面试题 5:综合大题

async function async1() {
  console.log('A');
  await async2();
  console.log('B');
}

async function async2() {
  console.log('C');
}

console.log('D');

setTimeout(() => {
  console.log('E');
  
  Promise.resolve().then(() => {
    console.log('F');
  });
  
  async1();
}, 0);

async1();

new Promise((resolve) => {
  console.log('G');
  resolve();
}).then(() => {
  console.log('H');
});

console.log('I');

这道题把前面所有知识点都揉在一起了。先自己猜一下,我直接拆解。

执行追踪:

─── 第一轮宏任务:script 整体代码 ───

步骤1: console.log('D') → 输出: D

步骤2: setTimeout → 回调X进入宏任务队列

步骤3: 调用 async1()(外层)
  → console.log('A') → 输出: A
  → 调用 async2() → console.log('C') → 输出: C
  → await 暂停,console.log('B') 进入微任务队列 → 记为微任务B

步骤4: new Promise 执行
  → console.log('G') → 输出: G
  → resolve() 调用
  → .then() 回调进入微任务队列 → 记为微任务H

步骤5: console.log('I') → 输出: I

─── 清空微任务 ───

步骤6: 执行微任务B → console.log('B') → 输出: B

步骤7: 执行微任务H → console.log('H') → 输出: H

─── 微任务清空,取下一个宏任务 ───

─── 第二轮宏任务:回调X ───

步骤8: console.log('E') → 输出: E

步骤9: Promise.resolve().then → 回调F进入微任务队列

步骤10: 调用 async1()(setTimeout 内部)
  → console.log('A') → 输出: A
  → 调用 async2() → console.log('C') → 输出: C
  → await 暂停,console.log('B') 进入微任务队列 → 记为微任务B2

─── 回调X执行完,清空微任务 ───

步骤11: 执行回调F → console.log('F') → 输出: F

步骤12: 执行微任务B2 → console.log('B') → 输出: B

答案: D → A → C → G → I → B → H → E → A → C → F → B

检查一下:D A C G I B H E A C F B,12 个输出,和代码里的 12 个 console.log 一一对应。

这题最容易出错的地方:

  1. 外层 async1()setTimeout 内部的 async1() 是两次独立调用AC 会输出两次
  2. 微任务B 在微任务H 之前入队,所以先执行
  3. 回调F 在微任务B2 之前入队(步骤9 比 步骤10 先发生),所以 FB 前面

如果上面的都能正确的做出来,那么可以尝试做一道反直觉的变体代码题, 看看是不是还能一眼看出区别。

// 把 setTimeout 内部的顺序调换一下
setTimeout(() => {
  console.log('E');
  
  async1(); // 先调用 async1
  
  Promise.resolve().then(() => {
    console.log('F');
  });
}, 0);

如果改成这样,输出还会是 **... E → A → C → F → B**吗?为什么?

可以在评论区输出你的答案,一起讨论一下。


四、async/await 在事件循环中的真实位置

这篇是结合了前几篇的文章内容总结出的几道面试题,可以尝试做一下。

那么async/await 和 Promise.then 到底是不是一回事?

在事件循环的层面,是的。await 之后的代码等价于 .then() 回调,都是微任务。

但其实可以看一下上一篇 Generator 文章的内容:

  • Generator + 自动执行器 是一种「手动暂停 + 恢复」的模式,暂停时执行权交回主线程
  • async/await 借鉴了这个思路,但底层实现不同——它基于 Promise 的微任务机制,不依赖 Generator

简单来说:

Generator 暂停:  函数执行权交出,主线程继续跑别的
async/await 暂停: await 之后的代码被扔进微任务队列,主线程继续跑别的

两者都实现了「暂停」的效果,但机制不一样。Generator 的暂停是语言层面的(函数状态被保存),async/await 的暂停是任务队列层面的(代码被重新调度)。

补充:Generator 本身只提供语法级的中断能力,它不关心异步。只有当开发者手动将 yield 与 Promise 绑定,并用递归 next() 驱动时,它才模拟出异步等待的效果。而 async/await 是原生固化了这种模式,并且由引擎直接调度微任务,省去了手动迭代器的开销。

// 代码演示
// Generator:执行权交还后,外部可以捕获异常或传值回来
function* gen() { 
  try { yield 1; } catch(e) { console.log(e); }
}
const it = gen();
it.next(); // 交出执行权
it.throw('error'); // 外部强行给内部抛异常,这是语言层面的交互

// async/await:代码一旦被切成微任务,外部无法干预,只能等微任务队列调度它
async function test() {
  await 1;
  console.log('只能靠事件循环唤醒我');
}

下一篇会持续深度讲解Generator函数

如果觉得有用的话,可以点点关注,感谢~