浏览器篇 | 事件循环
前言
JavaScript 是单线程语言——任何时候只有一个任务在执行。但我们日常写的代码里到处都是”异步”:setTimeout、fetch、Promise、async/await……如果只有一个线程,这些异步操作是怎么做到”不阻塞”的?
答案就是事件循环(Event Loop)。
事件循环是 JavaScript 运行时的核心调度机制。理解它,你就能准确预测代码的执行顺序,理解为什么 setTimeout(fn, 0) 不是”立即执行”,解释清楚 Promise 和 setTimeout 的执行先后。不理解它,你写的异步代码就像在碰运气——有时候对,有时候不对,出了 bug 也不知道为什么。
面试中,事件循环是几乎必考的知识点,尤其是经典的”说出下面代码的执行顺序”。本章我们就彻底把它讲透。
诊断自测
Q1:下面代码的输出顺序是什么?
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
点击查看答案
输出顺序:1 → 4 → 3 → 2。
同步代码先执行,所以先输出 1 和 4。然后微任务(Promise.then)优先于宏任务(setTimeout)执行,所以先输出 3,再输出 2。
Q2:微任务和宏任务分别有哪些?至少各说三个。
点击查看答案
微任务(Microtask): Promise.then/catch/finally 的回调、MutationObserver 的回调、queueMicrotask() 注册的回调、async/await 中 await 之后的代码。
宏任务(Macrotask / Task): setTimeout、setInterval、setImmediate(Node.js)、I/O 操作、UI 渲染事件、requestAnimationFrame(有争议,有些文章归为宏任务,但它实际上在渲染之前独立执行)、MessageChannel。
Q3:为什么说 setTimeout(fn, 0) 不是”立即执行”?
点击查看答案
setTimeout(fn, 0) 只是把 fn 作为一个宏任务放入任务队列,它要等到当前同步代码执行完毕并且所有微任务都处理完之后,才会被事件循环取出执行。另外,HTML 规范规定嵌套超过 5 层的 setTimeout 最小延迟为 4ms。所以 setTimeout(fn, 0) 不是”0 延迟”,而是”尽快,但要排队”。
一、单线程模型与异步
1.1 为什么 JavaScript 是单线程的?
JavaScript 最初是为浏览器中的 DOM 操作而设计的。想象一下,如果两个线程同时操作同一个 DOM 节点——一个要删除它,另一个要修改它的内容——浏览器该听谁的?为了避免这种复杂的同步问题,JavaScript 从一开始就被设计为单线程。
单线程意味着:同一时间只能做一件事。代码是一行一行顺序执行的。
但问题来了:如果一个网络请求需要 3 秒才能返回,难道页面就要卡 3 秒吗?
当然不是。这就是异步存在的意义。
1.2 异步的本质:不是多线程,而是”回头再说”
JavaScript 引擎(如 V8)本身确实是单线程的,但它运行在一个多线程的宿主环境中(浏览器或 Node.js)。当你调用 setTimeout 或 fetch 时:
- JavaScript 引擎把异步操作交给宿主环境的其他线程处理(比如定时器线程、网络线程)
- 引擎自己继续执行后面的同步代码
- 等异步操作完成后,宿主环境把回调函数放入任务队列
- JavaScript 引擎空闲时,从任务队列中取出回调来执行
这个”交出去 → 继续做别的 → 回头处理结果”的机制,就是事件循环。
二、事件循环机制:完整流程
2.1 核心组件
事件循环涉及几个关键概念:
调用栈(Call Stack)
JavaScript 引擎执行代码的地方。每调用一个函数,就往栈里压入一个帧;函数执行完毕,帧弹出。栈空了,引擎就”闲”了。
任务队列(Task Queue / Macrotask Queue)
宏任务完成后,回调会被放在这里排队。事件循环每次从这里取一个任务来执行。
微任务队列(Microtask Queue)
微任务完成后,回调放在这里。优先级高于任务队列——每个宏任务执行完后,引擎会清空整个微任务队列,然后才会取下一个宏任务。
Web API / 宿主环境
定时器、DOM 事件、网络请求等由宿主环境(浏览器或 Node.js)管理的异步操作。
2.2 事件循环的执行顺序
用文字描述完整的事件循环流程:
1. 执行全局同步代码(这本身就是第一个宏任务)
2. 同步代码执行完毕后,清空微任务队列
- 依次执行所有微任务
- 如果微任务中又产生了新的微任务,继续执行,直到队列清空
3. 浏览器判断是否需要渲染
- 如果需要渲染,执行 requestAnimationFrame 回调
- 执行渲染(Layout → Paint → Composite)
4. 从宏任务队列中取出一个任务执行
5. 回到第 2 步,重复
用一张流程图来理解:
┌──────────────────────────────────────────┐
│ 调用栈 (Call Stack) │
│ 执行当前宏任务的代码 │
└─────────────────┬────────────────────────┘
│ 栈空了
▼
┌──────────────────────────────────────────┐
│ 微任务队列全部清空 │
│ Promise.then / queueMicrotask / ... │
│ (如果新产生的微任务,继续处理) │
└─────────────────┬────────────────────────┘
│ 微任务队列空了
▼
┌──────────────────────────────────────────┐
│ 浏览器渲染(如果需要的话) │
│ requestAnimationFrame → Layout → Paint │
└─────────────────┬────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ 从宏任务队列取出一个任务执行 │
│ setTimeout / setInterval / I/O / ... │
└─────────────────┬────────────────────────┘
│
└──── 回到"清空微任务"步骤
2.3 关键规则总结
- 同步代码优先:所有同步代码在当前宏任务中一口气执行完
- 微任务先于下一个宏任务:每个宏任务结束后,先清空所有微任务
- 微任务中产生的微任务也会在当前轮次执行:不会被推迟到下一轮
- 每轮事件循环只取一个宏任务(但会清空所有微任务)
三、宏任务 vs 微任务
3.1 具体分类
宏任务(Macrotask / Task):
| 宏任务 | 说明 |
|---|---|
setTimeout / setInterval | 定时器回调 |
setImmediate | Node.js 特有 |
| I/O 操作 | 文件读写、网络请求完成后的回调 |
| UI 渲染 | 浏览器的 DOM 渲染 |
MessageChannel | 消息通道回调 |
| script 整体代码 | 初始的全局代码也是一个宏任务 |
微任务(Microtask):
| 微任务 | 说明 |
|---|---|
Promise.then / .catch / .finally | Promise 回调 |
async/await | await 后面的代码相当于 .then |
MutationObserver | DOM 变更观察器的回调 |
queueMicrotask() | 手动添加微任务的 API |
process.nextTick | Node.js 特有(优先级比 Promise 还高) |
3.2 为什么要区分宏任务和微任务?
微任务的设计目的是让某些操作能在当前宏任务结束后、下一个宏任务开始前尽快执行。这对于 Promise 等异步原语至关重要——你希望 .then 的回调能尽快得到执行,而不是排到一堆 setTimeout 后面。
举个例子感受一下区别:
setTimeout(() => console.log('timeout 1'), 0);
setTimeout(() => console.log('timeout 2'), 0);
Promise.resolve().then(() => console.log('promise 1'));
Promise.resolve().then(() => console.log('promise 2'));
// 输出:promise 1 → promise 2 → timeout 1 → timeout 2
两个 Promise 的微任务在两个 setTimeout 的宏任务之前执行,因为微任务队列会在当前宏任务(全局代码)结束后被完全清空。
四、经典面试题:执行顺序分析
这是面试中最常见的题型。我们从简单到复杂逐步分析。
4.1 基础题:setTimeout + Promise
console.log('start');
setTimeout(() => {
console.log('timeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise');
});
console.log('end');
分析过程:
- 执行同步代码:输出
start setTimeout:回调注册为宏任务,放入宏任务队列Promise.resolve().then():回调注册为微任务,放入微任务队列- 执行同步代码:输出
end - 当前宏任务(全局代码)执行完毕,清空微任务队列:输出
promise - 取下一个宏任务执行:输出
timeout
输出: start → end → promise → timeout
4.2 进阶题:嵌套的 Promise 和 setTimeout
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 setTimeout→ 回调记为 T1,入宏任务队列Promise.then→ 回调记为 P1,入微任务队列- 同步:输出
6 - 清空微任务队列:执行 P1 → 输出
4,内部setTimeout→ 回调记为 T2,入宏任务队列 - 微任务队列空了。取下一个宏任务 T1 → 输出
2,内部Promise.then→ 回调记为 P2,入微任务队列 - 清空微任务队列:执行 P2 → 输出
3 - 取下一个宏任务 T2 → 输出
5
输出: 1 → 6 → 4 → 2 → 3 → 5
4.3 终极题:async/await + Promise + setTimeout
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('promise1');
resolve();
}).then(() => {
console.log('promise2');
});
console.log('script end');
分析过程(关键点:await 后面的代码等价于 .then 的回调):
- 同步:输出
script start setTimeout→ 回调入宏任务队列- 调用
async1():- 输出
async1 start - 执行
await async2():先执行async2(),输出async2 await之后的代码(console.log('async1 end'))作为微任务入队
- 输出
- 执行
new Promise的构造函数(同步):输出promise1 .then回调作为微任务入队- 同步:输出
script end - 清空微任务队列:
- 输出
async1 end - 输出
promise2
- 输出
- 取宏任务:输出
setTimeout
输出: script start → async1 start → async2 → promise1 → script end → async1 end → promise2 → setTimeout
这道题的核心考点:
new Promise(executor)中的executor是同步执行的await后面的代码相当于放在.then()里,是微任务- 微任务按入队顺序执行
五、requestAnimationFrame 在事件循环中的位置
requestAnimationFrame(简称 rAF)是一个比较特殊的存在。它既不属于宏任务,也不属于微任务,而是在事件循环中有自己独立的时机:
宏任务 → 微任务全部清空 → requestAnimationFrame → 浏览器渲染 → 下一个宏任务
也就是说,rAF 的执行时机是在微任务之后、浏览器渲染之前。
setTimeout(() => console.log('timeout'), 0);
requestAnimationFrame(() => console.log('rAF'));
Promise.resolve().then(() => console.log('promise'));
// 通常输出:promise → rAF → timeout
// 但 rAF 和 timeout 的先后不是 100% 确定的,取决于浏览器是否在这轮决定渲染
为什么 rAF 很重要?
- rAF 的回调在浏览器下一次重绘之前执行,适合做动画、DOM 修改等视觉更新
- 如果页面不可见(比如切到了后台标签),rAF 会暂停执行,节省资源
- rAF 的频率通常和屏幕刷新率一致(60Hz 屏幕上约 16.7ms 一次)
// 使用 rAF 做动画
function animate() {
element.style.left = `${parseFloat(element.style.left) + 1}px`;
requestAnimationFrame(animate); // 下一帧继续
}
requestAnimationFrame(animate);
面试追问:为什么动画应该用 rAF 而不是 setTimeout?
setTimeout的执行时机不可控,可能在两次渲染之间执行多次(浪费计算),也可能错过渲染时机(导致卡顿)- rAF 和浏览器的渲染节奏同步,保证每一帧恰好执行一次,既不浪费也不遗漏
- 页面后台时 rAF 自动暂停,
setTimeout不会
六、Node.js 事件循环与浏览器的差异
虽然浏览器和 Node.js 都有事件循环,但它们的实现有一些重要差异。面试中偶尔会问到这个对比。
6.1 Node.js 事件循环的六个阶段
Node.js 的事件循环基于 libuv 库,分为六个阶段:
┌───────────────────────────┐
┌─>│ timers │ setTimeout / setInterval
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ pending callbacks │ 系统级别的回调
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ idle, prepare │ 内部使用
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ poll │ I/O 回调
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ check │ setImmediate
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ close callbacks │ socket.on('close', ...)
│ └───────────┬───────────────┘
│ │
└──────────────┘
6.2 关键差异
1. process.nextTick vs Promise
Node.js 中有一个特殊的微任务:process.nextTick。它的优先级比 Promise 的微任务更高——在每个阶段切换之前,Node.js 会先清空 nextTick 队列,然后清空 Promise 微任务队列。
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// Node.js 输出:nextTick → promise
2. setImmediate vs setTimeout(fn, 0)
setImmediate 是 Node.js 特有的 API,它的回调在 check 阶段执行。与 setTimeout(fn, 0) 的执行顺序在不同上下文中可能不同:
// 在主模块中,顺序不确定
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 可能是 timeout → immediate,也可能反过来
// 在 I/O 回调中,setImmediate 总是先于 setTimeout
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// 总是 immediate → timeout
3. 微任务的执行时机
在 Node.js 11+ 中,微任务的行为和浏览器基本一致——每执行完一个宏任务,就清空微任务队列。但在 Node.js 10 及更早版本中,微任务是在阶段切换时才清空的,这导致了一些和浏览器不一致的行为。现代 Node.js 已经对齐了浏览器的行为。
七、容易混淆的细节补充
7.1 Promise 构造函数是同步的
new Promise((resolve) => {
console.log('同步执行');
resolve();
}).then(() => {
console.log('微任务');
});
console.log('后面的同步代码');
// 输出:同步执行 → 后面的同步代码 → 微任务
new Promise(executor) 中的 executor 函数是立即同步执行的,只有 .then 的回调才是微任务。这个点在面试中经常被用来出”障眼法”。
7.2 async 函数的返回值
async function foo() {
return 42;
}
// 等价于
function foo() {
return Promise.resolve(42);
}
async 函数的返回值会被自动包装成 Promise。如果返回的已经是一个 Promise,则不会再包一层。
7.3 微任务”塞车”会阻塞渲染
由于微任务会在当前轮次被全部清空,如果微任务产生了大量新的微任务,就会一直占用主线程,导致渲染被推迟:
// ⚠️ 危险:无限微任务,页面会卡死
function loop() {
Promise.resolve().then(loop);
}
loop();
相比之下,无限的宏任务(如递归 setTimeout)不会卡死页面,因为每个宏任务之间浏览器有机会渲染:
// 不会卡死,每次 setTimeout 之间浏览器可以渲染
function loop() {
setTimeout(loop, 0);
}
loop();
这也是理解微任务和宏任务差异的一个重要视角。
常见误区
误区一:“setTimeout(fn, 0) 就是立即执行”
不是。setTimeout(fn, 0) 的意思是”把 fn 放入宏任务队列,延迟 0ms”。但这个 0ms 不是真的 0ms——首先,所有微任务和可能的渲染都要先完成;其次,HTML 规范规定嵌套层级超过 5 时,最小延迟会被钳制为 4ms。更准确的理解是:setTimeout(fn, 0) 表示”在当前宏任务和所有微任务之后,尽快执行 fn”。
误区二:“async/await 让代码变成同步执行了”
不是。async/await 只是 Promise 的语法糖,让异步代码”看起来”像同步的。await 后面的代码实际上被放到了微任务队列中,和 .then() 的行为完全一致。await 会让出当前函数的执行权,等微任务轮到它时才继续。
误区三:“微任务一定比宏任务先执行”
这句话不够精确。准确说法是:在同一轮事件循环中,当前宏任务产生的微任务会在下一个宏任务之前执行。 如果一个微任务是在某个宏任务执行之后才入队的,它可能在更后面的宏任务之后才执行。执行顺序取决于入队时机和所处的事件循环轮次。
误区四:“浏览器每轮事件循环都会渲染”
不一定。浏览器会根据需要决定是否渲染。如果没有视觉变化(比如没有 DOM 修改、没有动画),浏览器可能跳过渲染步骤以节省资源。通常浏览器的目标渲染频率是 60fps(约 16.7ms 一帧),但这不意味着每 16.7ms 一定会渲染。
小结
本章我们从 JavaScript 的单线程本质出发,完整梳理了事件循环的工作机制。
核心要点
- JavaScript 单线程,但通过事件循环 + 宿主环境的多线程实现异步
- 事件循环的核心节奏:执行宏任务 → 清空所有微任务 → 可能渲染 → 下一个宏任务
- 微任务优先级高于宏任务:每个宏任务结束后,所有微任务会被一次性清空
- 常见宏任务:setTimeout、setInterval、I/O、UI 渲染
- 常见微任务:Promise.then、async/await、MutationObserver、queueMicrotask
- requestAnimationFrame 在微任务之后、渲染之前执行
- Node.js 事件循环有六个阶段,
process.nextTick优先级高于 Promise - Promise 构造函数是同步的,只有
.then的回调才是微任务
记忆口诀
一个宏任务 → 清空所有微任务 → 渲染 → 下一个宏任务。
微任务”插队”,宏任务”排队”。
本章思维导图
- 为什么需要
- JS 单线程 → 不能阻塞 → 需要异步
- 宿主环境多线程处理异步操作
- 核心组件
- 调用栈(Call Stack)
- 宏任务队列(Task Queue)
- 微任务队列(Microtask Queue)
- Web API / 宿主环境
- 执行顺序
- 宏任务 → 清空微任务 → 渲染 → 下一个宏任务
- 微任务中产生的微任务在当前轮次执行
- 宏任务 vs 微任务
- 宏任务:setTimeout / setInterval / I/O / MessageChannel
- 微任务:Promise.then / async-await / MutationObserver / queueMicrotask
- requestAnimationFrame
- 在微任务之后、渲染之前
- 适合动画、和渲染节奏同步
- Node.js 差异
- 六个阶段(timers / poll / check / ...)
- process.nextTick 优先于 Promise
- setImmediate 在 check 阶段
- 经典面试题
- setTimeout + Promise 执行顺序
- async/await 的拆解(await 后 = .then)
- Promise 构造函数是同步的
练习挑战
第一题 ⭐(基础):说出输出顺序
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve()
.then(() => console.log('C'))
.then(() => console.log('D'));
console.log('E');
点击查看答案
输出: A → E → C → D → B
分析:
- 同步:输出
A setTimeout回调入宏任务队列Promise.then回调入微任务队列- 同步:输出
E - 清空微任务:输出
C,.then链的下一个回调入微任务队列,输出D - 取宏任务:输出
B
注意 .then 的链式调用:第一个 .then 执行完后,第二个 .then 的回调才入队(因为需要前一个 Promise resolve 后才会注册下一个 .then)。
第二题 ⭐⭐(进阶):混合场景
setTimeout(() => {
console.log('1');
Promise.resolve().then(() => console.log('2'));
}, 0);
setTimeout(() => {
console.log('3');
}, 0);
Promise.resolve().then(() => {
console.log('4');
});
console.log('5');
点击查看答案
输出: 5 → 4 → 1 → 2 → 3
分析:
- 两个
setTimeout的回调分别记为 T1、T2,入宏任务队列 Promise.then回调入微任务队列- 同步:输出
5 - 清空微任务队列:输出
4 - 取宏任务 T1:输出
1,内部Promise.then回调入微任务队列 - 清空微任务:输出
2 - 取宏任务 T2:输出
3
关键点:T1 执行完后,它内部的微任务(输出 2)会在 T2 之前执行——因为每个宏任务结束后都要先清空微任务队列。
第三题 ⭐⭐⭐(综合):async/await 完整分析
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2 start');
return new Promise((resolve) => {
console.log('async2 promise');
resolve();
}).then(() => {
console.log('async2 end');
});
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => {
console.log('promise1');
resolve();
}).then(() => {
console.log('promise2');
}).then(() => {
console.log('promise3');
});
console.log('script end');
点击查看答案
输出: script start → async1 start → async2 start → async2 promise → promise1 → script end → async2 end → promise2 → async1 end → promise3 → setTimeout
详细分析:
- 同步:输出
script start setTimeout回调入宏任务队列- 调用
async1():- 输出
async1 start - 调用
async2():- 输出
async2 start - 执行
new Promise构造函数(同步):输出async2 promise .then(() => console.log('async2 end'))入微任务队列(记为 M1)async2返回的是一个 Promise(即.then返回的新 Promise)
- 输出
await等待async2()返回的 Promise resolve,console.log('async1 end')需要等 M1 执行后,async2 返回的 Promise 才 resolve,所以async1 end还不能入微任务队列
- 输出
- 执行
new Promise构造函数(同步):输出promise1 .then(() => console.log('promise2'))入微任务队列(记为 M2)- 同步:输出
script end - 清空微任务:
- 执行 M1:输出
async2 end,此时 async2 返回的 Promise resolve 了,async1 end入微任务队列(记为 M3) - 执行 M2:输出
promise2,.then(() => console.log('promise3'))入微任务队列(记为 M4) - 执行 M3:输出
async1 end - 执行 M4:输出
promise3
- 执行 M1:输出
- 取宏任务:输出
setTimeout
这道题的难点在于 async2 返回了一个 Promise 链(不是直接 resolve),导致 await 需要等待额外的微任务轮次才能继续。
自我检测
- 能画出或描述事件循环的完整流程:宏任务 → 微任务 → 渲染 → 下一个宏任务
- 能列举至少 3 种宏任务和 3 种微任务
- 能准确分析 setTimeout + Promise 混合代码的执行顺序
- 能解释
await后面的代码为什么是微任务(等价于.then) - 能说清楚
new Promise(executor)中 executor 是同步执行的 - 能解释 requestAnimationFrame 在事件循环中的位置及其适用场景
- 能说出 Node.js 事件循环与浏览器的至少两个关键差异
- 能解释为什么无限微任务会卡死页面,而无限宏任务不会
- 能在面试中完整、有条理地分析一道 async/await + Promise + setTimeout 的执行顺序题
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90