一道经典的 JS 面试题弄懂事件循环

一道经典的 JS 面试题弄懂事件循环

三葉Leaves Author

题目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 目标:回答代码执行顺序  
console.log(1)
setTimeout(() => {
console.log(2)
const p = new Promise(resolve => resolve(3))
p.then(result => console.log(result))
}, 0)
const p = new Promise(resolve => {
setTimeout(() => {
console.log(4)
}, 0)
resolve(5)
})
p.then(result => console.log(result))
const p2 = new Promise(resolve => resolve(6))
p2.then(result => console.log(result))
console.log(7)

最后两位尤其容易搞错,正确的答案是 1 7 5 6 2 3 4

为了搞懂上面这道题,先来看看相关知识。之后会有题目解析:

事件循环模型

基础

调用栈里存着程序源码,引擎会从上到下把其中的代码取到宿主环境里执行。

如果遇到异步任务,则会先塞进任务队列里,等所有同步代码都执行完,再来根据队列里的顺序或者情况一条条取出执行。

这里有一道题可以测试你的理解情况:

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
/**  
* 目标:阅读并回答执行的顺序结果
*/
console.log(1)
setTimeout(() => {
console.log(2)
}, 0)
function myFn() {
console.log(3)
}
function ajaxFn() {
const xhr = new XMLHttpRequest()
xhr.open('GET', 'http://hmajax.itheima.net/api/province')
xhr.addEventListener('loadend', () => {
console.log(4)
})
xhr.send()
}
for (let i = 0; i < 1; i++) {
console.log(5)
}
ajaxFn()
document.addEventListener('click', () => {
console.log(6)
})
myFn()

正确答案是:1 5 3 2 4 6

宏任务和微任务

然而,ES6 之后引入了 Promise 对象, 让 JS 引擎也可以发起异步任务

异步任务划分为了两个:

  • 宏任务:由浏览器环境执行的异步代码
  • 微任务:由 JS 引擎环境执行的异步代码

微任务的优先级大于宏任务。

所以我们需要将队列拆分成两个,一个宏任务一个微任务:

宏任务 (Macrotasks)

可以理解为独立的、较大的工作单元。每次事件循环只执行一个。 常见的宏任务包括:

  • script (整个脚本文件,可以看作是第一个宏任务)
  • setTimeout()setInterval() 的回调函数
  • I/O 操作(如文件读写)
  • UI rendering (浏览器渲染)
  • 用户交互事件(如 click, scroll

微任务 (Microtasks)

可以理解为需要尽快执行的、与当前任务紧密相关的小任务。在一个宏任务执行完毕后,会立即执行所有微任务。 常见的微任务包括:

  • Promise.then(), Promise.catch(), Promise.finally() 的回调函数
  • await 后面的代码(因为 async/await 是基于 Promise 的语法糖)
  • queueMicrotask()
  • MutationObserver

题目解析

微任务队列有两个特点:

  1. 优先级大于宏任务队列。
  2. 每一条宏任务执行完,都会清空微任务队列,才会继续执行下一条宏任务。

这时候我们可以继续来看面试题了:

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
// 目标:回答代码执行顺序  
// 1 7 5 6 2 3 4
console.log(1)

// 1️⃣
setTimeout(() => {
console.log(2)
const p = new Promise(resolve => resolve(3))
p.then(result => console.log(result))
}, 0)

const p = new Promise(resolve => {
// 2️⃣
setTimeout(() => {
console.log(4)
}, 0)
resolve(5)
})

p.then(result => console.log(result))

const p2 = new Promise(resolve => resolve(6))
p2.then(result => console.log(result))

console.log(7)

过程

我们可以分析一下程序执行过程:

  1. 首先,程序里的同步代码肯定是先执行完,所以先输出了 1、7,其他的代码塞进了队列里。

  2. 微任务队列里的优先级更高,所以先处理微任务队列。微任务队列里现在应该有两条语句:

1
p.then(result => console.log(result))

而且里面的 result 值都已经有了,这是因为一旦 promise 里的 resolve 被执行,那就会调用后面的 then。

这两条语句按顺序执行,那就是 5、6

  1. 这时候开始看宏任务队列。第一个需要解决的任务是:
1
2
3
4
5
6
// 1️⃣
setTimeout(() => {
console.log(2)
const p = new Promise(resolve => resolve(3))
p.then(result => console.log(result))
}, 0)

所以先输出了一个 2,之后又看到有 then() 语句,塞入微任务队列。

  1. 重点来了。第一个宏任务队列里的任务执行完了,并不会继续解决第二个宏任务队列里的任务,而是会看一眼微任务队列有没有东西。

结果发现有一个(第三步里塞的),那就执行。于是输出 3

  1. 既然微任务队列再次被清空了,那就从宏任务队列里取出第二个任务:
1
2
3
setTimeout(() => {  
console.log(4)
}, 0)

执行,所以输出 4。到这里,所有任务都执行完毕了。

注意点

  • 上面的第四点很关键,它是事件循环的核心机制,也是新人容易弄错的地方。

  • 还有一个比较容易出问题的点:promise 内部的代码总在 promise 实例创建的时候就执行,所以可以理解为是同步代码。

总结

事件循环可以用下面的流程概括:

  1. 先执行完所有同步代码。
  2. 然后清空所有微任务 (Microtask)
  3. 接着取一个 宏任务 (Macrotask) 来执行。
  4. 执行完这个宏任务后,再回到第 2 步,检查并清空所有微任务。
  5. 不断重复这个循环。

这个机制确保了高优先级的微任务(如 Promise.then)总是在下一个宏任务(如 setTimeout 或用户点击)之前被执行。

  • 标题: 一道经典的 JS 面试题弄懂事件循环
  • 作者: 三葉Leaves
  • 创建于 : 2025-06-17 00:00:00
  • 更新于 : 2025-07-10 13:40:50
  • 链接: https://blog.oksanye.com/e542963c65f3/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论