事件循环(Event Loop):宏任务、微任务与浏览器渲染的那点事
JavaScript 在浏览器里通常运行在单线程(主线程)上:同一时刻只能做一件事。
但你又每天都在写异步:网络请求、定时器、点击事件、动画……这些“同时发生”的效果,靠的就是 事件循环(Event Loop) 在背后做调度。
这篇文档的目标是:把事件循环讲到你能画图解释、写对输出顺序、并且知道怎么在项目里用它优化体验。
- 主要讲浏览器的事件循环(前端面试/开发最常用)
- 末尾会用一小节对比 Node.js 事件循环(知道差异即可)
一、先把“5 个角色”认全:调用栈、Web APIs、两种队列、渲染
你可以把浏览器主线程想象成一个“收银台”:
- 调用栈(Call Stack):收银员正在处理的事情(同步代码一层层执行)
- Web APIs(浏览器能力):旁边的“外包团队”(定时器、网络、DOM 事件、IO 等)
- 宏任务队列(Task Queue):外包做完后,把“待处理回调”放这里排队
- 微任务队列(Microtask Queue):比宏任务更“插队”的队列(Promise then/await 等)
- 渲染(Rendering):浏览器把最新 DOM/CSS 结果绘制到屏幕
用一张图把关系串起来(先记住大方向,后面再抠细节):
你只要先记住一句话:调用栈清空后,事件循环会先清空微任务,再去取下一个宏任务;合适的时候会穿插一次渲染。
二、一次“循环”到底怎么转:宏任务 → 清空微任务 →(可能)渲染 → 下一个宏任务
把它简化成你最常用的版本:
这里有两个非常重要的“面试口头禅”:
- 微任务会在本轮宏任务结束后立刻执行,并且会“清空”到队列为空为止。
- 如果你不停往微任务队列里塞新任务,浏览器可能一直没机会渲染(页面就会卡)。
三、宏任务 vs 微任务:你需要背下来的清单
不同资料会把宏任务叫:task / macrotask。本文统一叫“宏任务”。
3.1 常见来源速查表(浏览器)
| 类型 | 典型 API / 场景 | 进入的队列 |
|---|---|---|
| 整段脚本 | <script>、模块初始化、一次事件回调的整段执行 | 宏任务(你可以理解为“当前这一轮”) |
| 定时器 | setTimeout / setInterval | 宏任务 |
| UI 事件 | 点击、输入、滚动等事件回调 | 宏任务 |
| 网络/IO 回调 | fetch/XHR 的回调(最终以任务形式调度) | 宏任务(概念上) |
| Promise 回调 | then/catch/finally、async/await 的继续执行 | 微任务 |
| 显式微任务 | queueMicrotask | 微任务 |
| DOM 变更观察 | MutationObserver 回调 | 微任务 |
浏览器规范里“宏任务队列”不是一个,而是按 task source 分组的多个队列;渲染也不是每轮都发生。
但在日常开发/面试中,用“宏任务队列 + 微任务队列 + 渲染时机”这个模型足够解决 90% 的问题。
3.2 一个最容易踩的坑:await 也是微任务
await 看起来像“同步暂停”,本质上是 Promise 的语法糖:
await 之后的代码会被安排到微任务里“稍后继续执行”。
四、输出顺序题:用 3 道例子把脑子“拧正”
4.1 Promise 一定比 setTimeout 先?
console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
Promise.resolve().then(() => {
console.log(3);
});
console.log(4);
输出顺序:
- 同步先跑完:
1 4 - 本轮宏任务结束,清空微任务:
3 - 下一轮取宏任务(定时器回调):
2
所以最终是:1 4 3 2
4.2 async/await 的“断点”在哪里?
async function main() {
console.log("A");
await 0;
console.log("B");
}
console.log("start");
main();
console.log("end");
输出顺序:
- 同步:
start - 调用
main(),先执行到await前:打印A await 0之后的console.log("B")被放进微任务- 同步继续:
end - 清空微任务:
B
最终:start A end B
看到 await,就把它当成一次“把后续代码放进微任务队列”的操作。
4.3 微任务里再塞微任务:会不会拖到下一轮?
Promise.resolve()
.then(() => {
console.log("M1");
return Promise.resolve().then(() => console.log("M2"));
})
.then(() => console.log("M3"));
setTimeout(() => console.log("T1"), 0);
你可以这样理解:
- 本轮宏任务结束后开始“清空微任务”
- 执行
M1时又创建了新的微任务M2 - 微任务会一直清空到队列为空,所以
M2不会等到下一轮 M3也仍然属于这次清空过程的一部分
所以输出是:M1 M2 M3 T1
五、事件循环和“渲染”是什么关系:为什么页面会卡?
浏览器渲染(简化理解)大概是:计算样式 → 布局 → 绘制。
关键点是:渲染需要主线程空下来,否则它就只能等你 JS 执行完。
5.1 为什么“疯狂 then”会卡住页面?
因为微任务会在每个宏任务结束后被一次性清空。
如果你在微任务里不断递归塞微任务,会出现“微任务饥饿”:宏任务和渲染都得不到机会。
function starve() {
queueMicrotask(starve);
}
starve(); // ⚠️ 会让页面几乎无法响应(不要在真实页面里运行)
5.2 requestAnimationFrame 放在事件循环的哪里?
你可以把 requestAnimationFrame(rAF)理解成:
- 浏览器准备绘制下一帧之前,先给你一次机会改 DOM
- 很适合做动画(和屏幕刷新节奏对齐)
用一张“足够应付面试”的图记住顺序:
await Promise.resolve() / Promise.then() 只是把代码放到微任务,通常不会给渲染“喘气”的机会。
如果你想让 UI 先更新再继续计算,更常见的做法是“让出到下一帧/下一轮宏任务”,例如:
const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r()));
// 或:const nextTask = () => new Promise((r) => setTimeout(r, 0));
六、实战:把“长任务”切成小块,让页面不卡
当你做大量计算/大列表处理时,如果一次同步跑完,用户会感到卡顿。
一个常用策略是:分片 + 让出执行权。
6.1 分片到下一帧(更利于 UI 更新)
const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r()));
async function runInChunks(chunks, work) {
for (const chunk of chunks) {
work(chunk);
await nextFrame(); // 让浏览器有机会渲染/响应输入
}
}
6.2 分片到空闲时间(低优先级任务)
function runWhenIdle(work) {
if ("requestIdleCallback" in window) {
window.requestIdleCallback(() => work(), {timeout: 1000});
return;
}
setTimeout(work, 16);
}
想把这块讲得更细,可以搭配阅读:
docs/frontend/javascript/api/request-animation-frame.md(rAF 的细节与坑)docs/frontend/vue/next-tick.md(框架为什么要基于事件循环做批量更新)
七、浏览器 vs Node.js:同名但不是同一个“事件循环”
面试偶尔会追问一句:“Node 的事件循环也这样吗?”你可以用下面这段话回答:
- 相同点:都有“宏观上循环取任务执行”的模型;Promise 回调都属于微任务。
- 不同点:Node.js 由 libuv 管理,有更细的阶段(timers/poll/check 等),并且还有一个优先级很高的
process.nextTick队列(它甚至比 Promise 微任务更“插队”)。
如果你写的是前端业务:把浏览器这一套吃透更重要;Node 的阶段细节通常只有在写服务端/底层时才需要抠。
八、面试高频问答(建议背诵 + 能举例)
8.1 什么是事件循环?
事件循环是浏览器调度 JS 执行的一套机制:主线程每次执行一个宏任务,结束后清空微任务,然后在合适的时候进行渲染,再进入下一轮。
8.2 宏任务和微任务有什么区别?
宏任务是一轮一轮执行的“大任务”(脚本、定时器回调、事件回调等);微任务会在本轮宏任务结束后立刻执行,并且会被清空到队列为空(Promise.then/await、queueMicrotask、MutationObserver 等)。
8.3 为什么 Promise.then 一般比 setTimeout(fn, 0) 先执行?
因为 then 回调进的是微任务队列,而定时器回调进的是宏任务队列;事件循环会在每个宏任务结束后先清空微任务,再执行下一轮宏任务。
8.4 await 后面的代码什么时候执行?
await 之后的代码会在当前同步执行结束后,以微任务的形式继续执行(即使 await 的是一个普通值也一样)。
8.5 为什么大量微任务会导致页面卡顿?
微任务会被一次性清空。如果微任务源源不断地产生(甚至递归产生),浏览器就难以进入渲染阶段,出现“微任务饥饿”,页面表现为卡顿/不刷新。
8.6 requestAnimationFrame 和 setTimeout 做动画有什么区别?
requestAnimationFrame 的回调会在下一帧绘制前执行,更贴合刷新节奏,掉帧更可控;setTimeout 受最小延时、后台降频等影响,且与渲染不同步,不适合做高质量动画。
8.7 如果我想“让 UI 先更新,再继续跑计算”,该怎么做?
不要只用 Promise.then/await Promise.resolve()(它们是微任务,可能不给渲染机会)。更常见的是让出到下一帧或下一轮宏任务,比如 await new Promise(r => requestAnimationFrame(() => r())) 或 setTimeout。