2166 words
11 minutes
JavaScript 事件循环:宏任务、微任务和 async/await

事件循环是前端面试里最经典、也最容易“背错”的题之一。很多人知道 JavaScript 是单线程,也知道有宏任务和微任务,但一旦让他分析一段 Promise + setTimeout + async/await 的执行顺序,答案就开始混乱。

真正理解事件循环,要把几个概念拆开:

  • 调用栈
  • 宿主环境
  • 任务队列
  • 微任务
  • 宏任务
  • 渲染时机

一、先说本质:JavaScript 为什么需要事件循环?#

JavaScript 在浏览器主线程里通常是单线程执行的。

这意味着同一时刻:

  • 只能执行一段 JavaScript 代码
  • 不能并行跑多个调用栈

但现实中的 Web 应用又充满异步场景:

  • 定时器
  • 网络请求
  • 用户点击
  • DOM 事件
  • Promise 回调

问题就来了:

如果 JS 只能单线程,那这些异步回调什么时候执行?

答案就是:

通过事件循环(Event Loop)协调“当前正在执行的代码”和“未来要执行的任务”。


二、先分清:JavaScript 引擎和宿主环境不是一回事#

这是理解事件循环的第一步。

JavaScript 引擎负责:

  • 解析代码
  • 维护调用栈
  • 执行同步 JS

浏览器宿主环境负责:

  • 定时器
  • DOM 事件
  • 网络请求
  • 渲染
  • MessageChannel 等异步能力

也就是说:

  • JS 引擎只会执行代码
  • 宿主环境负责把异步任务在合适时机放回队列

MDN 对运行时环境的示意图如下:

JavaScript runtime environment diagram


三、事件循环涉及的三个核心结构#

3.1 调用栈(Call Stack)#

同步代码运行的地方。

例如:

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

执行顺序是:

  1. foo 入栈
  2. bar 入栈
  3. baz 入栈
  4. baz 出栈
  5. bar 出栈
  6. foo 出栈

所以调用栈本质是 LIFO(后进先出)

3.2 任务队列(Task Queue)#

异步任务完成后,它们的回调不会直接插进调用栈,而是先进入队列,等待调度。

3.3 事件循环(Event Loop)#

事件循环会不断做这件事:

  1. 看调用栈是不是空了
  2. 如果空了,就从队列里取任务
  3. 把任务压入调用栈执行
  4. 重复这个过程

所以最朴素的一句话是:

调用栈负责执行,事件循环负责调度。


四、宏任务和微任务到底是什么?#

这是面试里最关键的部分。

4.1 宏任务(Macrotask)#

常见宏任务包括:

  • 整体 script
  • setTimeout
  • setInterval
  • DOM 事件回调
  • postMessage
  • MessageChannel

4.2 微任务(Microtask)#

常见微任务包括:

  • Promise.then / catch / finally
  • queueMicrotask
  • MutationObserver

4.3 两者最核心的区别#

每执行完一个宏任务后,事件循环会先把当前产生的所有微任务清空,再去执行下一个宏任务。

这个顺序一定要记牢:

  1. 执行一个宏任务
  2. 清空微任务队列
  3. 浏览器可能进行一次渲染
  4. 再执行下一个宏任务

五、为什么 Promise 优先级比 setTimeout 高?#

来看这段代码:

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

输出结果是:

1
4
3
2

原因是:

  1. 整体 script 是第一个宏任务
  2. 同步代码先执行,输出 14
  3. setTimeout 回调进入宏任务队列
  4. Promise.then 回调进入微任务队列
  5. 当前宏任务执行结束后,先清空微任务,所以输出 3
  6. 再执行下一个宏任务,输出 2

这也是为什么大家常说:

Promise 比 setTimeout 更早执行。

但更精确的说法应该是:

同一轮事件循环中,微任务会在下一个宏任务前被清空。


六、async/await 到底和 Promise 是什么关系?#

async/await 本质上是 Promise 的语法糖。

来看一个例子:

async function foo() {
console.log('a')
await Promise.resolve()
console.log('b')
}
console.log('c')
foo()
console.log('d')

输出结果:

c
a
d
b

为什么?

  1. console.log('c') 同步执行
  2. 调用 foo(),进入函数体,先输出 a
  3. 遇到 await,后面的逻辑被拆成一个微任务
  4. console.log('d') 继续同步执行
  5. 当前宏任务结束,执行微任务,输出 b

所以你可以把:

await xxx

理解成:

把后半段代码挂到一个 Promise 微任务里,当前函数先“让出执行权”。


七、完整分析一段经典面试题#

console.log('start')
setTimeout(() => {
console.log('timeout')
}, 0)
Promise.resolve()
.then(() => {
console.log('promise1')
})
.then(() => {
console.log('promise2')
})
async function main() {
console.log('async start')
await Promise.resolve()
console.log('async end')
}
main()
console.log('end')

第一步:先执行同步代码#

输出:

start
async start
end

同时队列状态:

  • 宏任务队列:setTimeout
  • 微任务队列:
    • promise1
    • async end

第二步:清空微任务#

先执行 promise1

promise1

它的后续 .then() 又会产生新的微任务 promise2

再执行 async end

async end

再执行 promise2

promise2

第三步:执行下一个宏任务#

timeout

最终输出顺序:

start
async start
end
promise1
async end
promise2
timeout

八、setTimeout(fn, 0) 为什么不是“立刻执行”?#

很多初学者以为:

setTimeout(fn, 0)

表示“马上执行”。

其实不是。

它的真实含义更接近:

最早在当前调用栈清空,并且轮到新的宏任务时执行。

所以即使写了 0,它也一定要等:

  1. 当前同步代码执行完
  2. 当前微任务清空
  3. 事件循环调度到它

因此它从来不是“立即执行”。


九、浏览器渲染和事件循环是什么关系?#

在浏览器环境里,事件循环不只是调度 JS,还和页面渲染相关。

通常可以粗略理解为:

  1. 执行一个宏任务
  2. 清空微任务
  3. 浏览器有机会进行渲染
  4. 进入下一轮循环

这意味着如果你不断往微任务队列里塞任务,就可能长期阻塞渲染。

例如:

function loop() {
Promise.resolve().then(loop)
}
loop()

这种写法会不断产生微任务,可能导致:

  • 页面卡住
  • 渲染得不到机会

所以微任务虽然优先级高,但也不能滥用。


十、事件循环和 Node.js 完全一样吗?#

不完全一样。

浏览器和 Node.js 都有事件循环,但宿主环境不同,所以任务阶段划分也不同。

浏览器里你重点记住:

  • 宏任务
  • 微任务
  • 渲染时机

Node.js 则还有:

  • timers
  • pending callbacks
  • poll
  • check
  • close callbacks

以及:

  • process.nextTick
  • Promise 微任务

面试如果明确问“前端/浏览器中的事件循环”,你主要讲浏览器模型就够了。


十一、常见误区#

11.1 “事件循环就是宏任务和微任务”#

不准确。

更完整地说,事件循环是一个调度机制,宏任务和微任务只是其中的任务分类。

11.2 “微任务总是在宏任务前执行”#

也不准确。

更准确地说是:

当前宏任务执行结束后,会先清空微任务,再进入下一个宏任务。

11.3 “await 会让整个函数异步执行”#

不完全对。

await 之前的代码还是同步执行,await 之后的部分才会挂到微任务队列。

11.4 “setTimeout(0) 就是立刻执行”#

完全错误。

它只是尽快排到后续宏任务里。


十二、面试里怎么回答“JavaScript 事件循环”?#

建议按这个结构回答:

第一步:先讲单线程和异步需求#

JavaScript 在浏览器主线程里是单线程执行的,但定时器、网络请求、用户事件等都需要异步处理,所以需要事件循环协调同步执行和异步回调。

第二步:讲三个核心结构#

  • 调用栈
  • 任务队列
  • 事件循环

调用栈负责执行,任务队列负责等待,事件循环负责调度。

第三步:讲宏任务和微任务#

  • 宏任务:script、setTimeout、DOM 事件等
  • 微任务:Promise.then、queueMicrotask、MutationObserver

每个宏任务执行结束后,会先清空当前所有微任务,再进入下一轮宏任务。

第四步:讲 async/await#

await 本质上会把后续逻辑放进微任务,所以 await 之后的代码通常比下一个宏任务先执行。

第五步:补一个进阶点#

在浏览器中,微任务清空之后,浏览器才可能获得渲染机会,因此过多微任务也可能阻塞页面更新。


十三、总结#

JavaScript 事件循环的核心,不是“背顺序”,而是理解调度规则:

  1. 同步代码先进调用栈执行
  2. 异步能力由宿主环境处理
  3. 回调进入对应任务队列
  4. 当前宏任务结束后,先清空微任务
  5. 再进入下一轮宏任务

所以最准确的一句话是:

JavaScript 事件循环,就是浏览器在单线程执行模型下,对同步代码、异步回调、微任务、宏任务和渲染时机进行协调的机制。

JavaScript 事件循环:宏任务、微任务和 async/await
https://fuwari.vercel.app/posts/javascript-event-loop/
Author
Owen
Published at
2026-05-29
License
CC BY-NC-SA 4.0