跳到主要内容

事件循环(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 结果绘制到屏幕

用一张图把关系串起来(先记住大方向,后面再抠细节):

你只要先记住一句话:调用栈清空后,事件循环会先清空微任务,再去取下一个宏任务;合适的时候会穿插一次渲染。


二、一次“循环”到底怎么转:宏任务 → 清空微任务 →(可能)渲染 → 下一个宏任务

把它简化成你最常用的版本:

这里有两个非常重要的“面试口头禅”:

  1. 微任务会在本轮宏任务结束后立刻执行,并且会“清空”到队列为空为止。
  2. 如果你不停往微任务队列里塞新任务,浏览器可能一直没机会渲染(页面就会卡)。

三、宏任务 vs 微任务:你需要背下来的清单

不同资料会把宏任务叫:task / macrotask。本文统一叫“宏任务”。

3.1 常见来源速查表(浏览器)

类型典型 API / 场景进入的队列
整段脚本<script>、模块初始化、一次事件回调的整段执行宏任务(你可以理解为“当前这一轮”)
定时器setTimeout / setInterval宏任务
UI 事件点击、输入、滚动等事件回调宏任务
网络/IO 回调fetch/XHR 的回调(最终以任务形式调度)宏任务(概念上)
Promise 回调then/catch/finallyasync/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. 同步先跑完:1 4
  2. 本轮宏任务结束,清空微任务:3
  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");

输出顺序:

  1. 同步:start
  2. 调用 main(),先执行到 await 前:打印 A
  3. await 0 之后的 console.log("B") 被放进微任务
  4. 同步继续:end
  5. 清空微任务: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 requestAnimationFramesetTimeout 做动画有什么区别?

requestAnimationFrame 的回调会在下一帧绘制前执行,更贴合刷新节奏,掉帧更可控;setTimeout 受最小延时、后台降频等影响,且与渲染不同步,不适合做高质量动画。

8.7 如果我想“让 UI 先更新,再继续跑计算”,该怎么做?

不要只用 Promise.then/await Promise.resolve()(它们是微任务,可能不给渲染机会)。更常见的是让出到下一帧或下一轮宏任务,比如 await new Promise(r => requestAnimationFrame(() => r()))setTimeout