结论

Event Loop 是 JavaScript 处理异步的机制:同步代码在调用栈执行,异步回调交给 Web APIs,完成后进入任务队列。每执行完一个宏任务,引擎会先清空整个微任务队列,再取下一个宏任务——这就是 Promise 回调总比 setTimeout 先执行的原因。

最后更新:2026-01-03 · 基于 浏览器环境的 HTML 规范事件循环,Node.js 的阶段划分略有不同

Event Loop(事件循环)是前端面试里出镜率最高的概念之一,几乎每家公司都爱考。但说实话,我当初学的时候看了好几篇文章,还是一知半解——每篇都在讲”宏任务微任务”,却说不清楚它们跟 Event Loop 的关系到底是什么。

后来是通过多跑代码、观察输出顺序,才慢慢把这个东西搞清楚的。

先从一个问题开始

运行下面这段代码,输出顺序是什么?

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 → 4 → 3 → 2

如果你能马上说出来,原因也说得清楚,这篇文章可以跳过了。如果有点迷糊,接着看。

JavaScript 是单线程的

首先要明确:JavaScript 是单线程语言,一次只能做一件事。但我们写了大量异步代码——定时器、网络请求、事件监听——这些并不会阻塞主线程。原因就在 Event Loop。

几个核心概念

调用栈(Call Stack)

同步代码执行的地方。代码进栈、执行、出栈,先进后出。

1
2
3
4
5
6
7
8
9
function a() {
b()
console.log('a')
}
function b() {
console.log('b')
}
a()
// b → a

Web APIs

浏览器提供的能力,比如 setTimeoutfetch、DOM 事件。这些不在 JS 引擎里,是浏览器处理的。setTimeout(fn, 0) 并不是”立刻执行”,而是把 fn 交给浏览器,等时间到了再推进队列。

任务队列(Task Queue)

也叫宏任务队列(Macro Task Queue)。宏任务处理完了会放到这里等待。

微任务队列(Microtask Queue)

优先级比宏任务高,每次宏任务执行完后,先清空微任务队列,再取下一个宏任务。

Event Loop 的执行流程

说清楚流程就两句话:

  1. 执行当前调用栈里的同步代码,直到栈清空
  2. 栈清空后,先把微任务队列清空,再取宏任务队列的第一个执行

不断重复这个过程,就是事件循环。

宏任务和微任务都有哪些

宏任务(Macro Task):

  • setTimeout
  • setInterval
  • setImmediate(Node.js)
  • I/O 操作
  • UI 渲染

微任务(Micro Task):

  • Promise.then / catch / finally
  • MutationObserver
  • queueMicrotask()
  • async/await(await 后面的代码相当于 .then)

微任务比宏任务优先级高,这是关键。

回到开头的例子

1
2
3
4
5
6
7
8
9
10
11
console.log('1')         // 同步,进调用栈,输出 1,出栈

setTimeout(() => {
console.log('2') // 宏任务,交给浏览器,时间到后推入宏任务队列
}, 0)

Promise.resolve().then(() => {
console.log('3') // 微任务,推入微任务队列
})

console.log('4') // 同步,输出 4,出栈

调用栈清空后:

  1. 先清微任务队列 → 输出 3
  2. 再取宏任务队列第一个 → 输出 2

所以是 1 → 4 → 3 → 2

一个稍微复杂一点的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
console.log('start')

setTimeout(() => {
console.log('timeout1')
Promise.resolve().then(() => {
console.log('promise in timeout')
})
}, 0)

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

console.log('end')

一步步分析:

  1. console.log('start') → 输出 start
  2. setTimeout → 宏任务注册,等待
  3. Promise.then × 2 → 微任务队列:[promise1回调, promise2回调]
    (注意:第二个 .then 要等第一个执行完才进队列)
  4. console.log('end') → 输出 end
  5. 调用栈清空,清微任务队列:
    • 执行 promise1 回调 → 输出 promise1,此时 promise2 回调入队
    • 执行 promise2 回调 → 输出 promise2
  6. 微任务队列清空,取宏任务:
    • 执行 timeout1 回调 → 输出 timeout1
    • 遇到 Promise.resolve().then → 微任务入队
    • 宏任务执行完,清微任务队列 → 输出 promise in timeout

最终输出:start → end → promise1 → promise2 → timeout1 → promise in timeout

async/await 的情况

async/await 是 Promise 的语法糖,await 之后的代码相当于 .then 里的回调。

1
2
3
4
5
6
7
8
9
async function main() {
console.log('async start')
await Promise.resolve()
console.log('after await') // 相当于 Promise.then 里的内容
}

console.log('before')
main()
console.log('after main call')

输出:before → async start → after main call → after await

await 会暂停当前 async 函数的执行,把控制权还给外部,等微任务队列处理时再继续。

为什么要理解这个

说实话,理解 Event Loop 不只是为了面试。工作里很多 bug 都跟异步顺序有关:

  • 明明已经 setState,但读到的还是旧值
  • 事件回调里操作 DOM 但布局没更新
  • 多个异步操作执行顺序跟预期不符

搞清楚 Event Loop,这类问题排查起来会顺很多。

常见问题

为什么 setTimeout 0 不是立即执行

setTimeout 不在 JS 引擎里执行,而是把回调交给浏览器。即使延时写 0,回调也要先等浏览器计时结束、进入宏任务队列,再等当前同步代码跑完、微任务清空之后才轮到它。

宏任务和微任务分别有哪些

常见宏任务有 setTimeout、setInterval、I/O、UI 渲染、事件回调;常见微任务有 Promise 的 then/catch/finally 回调、queueMicrotask、MutationObserver。async 函数中 await 之后的代码也属于微任务。

console.log 1 setTimeout 2 Promise 3 console.log 4 输出顺序是什么

输出 1 → 4 → 3 → 2。两个 console.log 是同步代码先执行,所以先 1 后 4;然后清空微任务队列,Promise 回调输出 3;最后才取宏任务队列里的 setTimeout 回调输出 2。

微任务队列会一次清空还是只取一个

一次清空。每个宏任务执行完后,引擎会反复取微任务直到队列为空才继续。所以如果微任务里又产生新的微任务,它们会在本轮一并执行完,而不是等到下一轮。