事件循环(Event Loop):宏任务、微任务、渲染时机、Node 差异怎么讲?
面试速答(30 秒版 TL;DR)
- JS 同一时刻只能在一个主线程上执行一段同步代码;异步能力由宿主环境(浏览器 / Node)提供,最终再把回调塞回事件循环。
- 浏览器里可以先记一句话:执行一个 task -> 调用栈清空 -> 清空 microtask -> 浏览器决定是否渲染 -> 下一轮 task。
- 常见口径里,
setTimeout、DOM 事件回调、I/O 回调属于宏任务(task);Promise.then、queueMicrotask、MutationObserver属于微任务(microtask)。 - 微任务优先级高于下一个宏任务,所以“
Promise.then比setTimeout(fn, 0)先执行”。 - 面试别只背输出题,要能解释三件事:回调为什么没立刻执行、为什么 Promise 更早、渲染为什么不是每行 JS 后都发生。
心智模型:JS 线程只负责“跑栈”,调度靠宿主
把整个系统拆成三层最好讲:
- 调用栈(Call Stack):同步代码真正执行的地方。
- 宿主能力(Web APIs / Node APIs):定时器、网络、文件、事件监听等异步来源。
- 事件循环(Event Loop):当调用栈空了,决定下一步把哪个回调推进来执行。
一句话版本:
- 同步代码永远先入栈执行。
- 异步任务先交给宿主环境等待时机成熟。
- 时机成熟后,回调进入对应队列,等调用栈空了再被调度执行。
浏览器里的标准口径
常见来源可以这么背:
| 类型 | 常见来源 | 面试口径 |
|---|---|---|
| 宏任务 / task | 整体脚本、setTimeout、用户事件回调、网络事件回调 | 一轮只取一个 task 执行 |
| 微任务 / microtask | Promise.then/catch/finally、queueMicrotask、MutationObserver | 当前 task 结束后会被一次性清空 |
| 渲染相关 | requestAnimationFrame、样式计算、布局、绘制 | 一般发生在 task 与下一轮 task 之间 |
注意:
- “宏任务”这个词在工程口语里很常用,但规范里更常见的是
task。 - 渲染不是“每执行一行 JS 就马上做一次”,而是浏览器在事件循环的合适时机统一处理。
经典输出题:为什么是这个顺序
console.log("A");
setTimeout(() => console.log("T"), 0);
Promise.resolve()
.then(() => console.log("P1"))
.then(() => console.log("P2"));
console.log("B");
输出:
A
B
P1
P2
T
标准解释:
- 整个脚本本身就是一个 task,所以同步代码先执行,打印
A、B。 setTimeout只是把回调注册给宿主环境,不会立刻入栈。Promise.then注册的是微任务,等当前 task 结束后立刻清空。- 所以先执行
P1、P2,再进入下一轮 task 执行T。
await 到底怎么理解
很多人会背“await 会阻塞”,这个说法不准确。更准确的口径是:
await会把后续逻辑切成一个“续体”;- 这个续体会在 Promise resolve 后,以微任务的形式恢复执行;
- 它不会阻塞整个线程,只是暂停当前
async函数后半段。
async function main() {
console.log(1);
await Promise.resolve();
console.log(2);
}
console.log(0);
main();
console.log(3);
输出:
0
1
3
2
原因:
main()先同步打印1;await后面的代码变成微任务;- 当前脚本这个 task 结束后才继续打印
2。
requestAnimationFrame 在哪一层
面试口径:
requestAnimationFrame不是普通微任务。- 它更像“浏览器下一次渲染前给你的一个回调机会”。
- 所以动画更新更适合放在
requestAnimationFrame,而不是setTimeout。
对比:
setTimeout(fn, 16):只是“至少 16ms 后把回调放入 task 队列”,未必刚好贴着一帧。requestAnimationFrame(fn):尽量和浏览器刷新节奏对齐,更适合动画、滚动联动。
为什么微任务可能把页面“饿死”
如果你不断往微任务队列里塞新任务,浏览器会一直先清微任务,导致:
- 下一个宏任务进不来;
- 渲染时机也会被推迟;
- 页面看起来像“卡住了”。
function loop() {
queueMicrotask(loop);
}
loop();
这段代码没有同步死循环,但效果一样危险,因为事件循环永远出不去。
面试结论:
- 微任务优先级高,但不能滥用。
- 大计算、分片调度、可让步任务,应该考虑拆到宏任务、
requestAnimationFrame或更细粒度的调度机制。
Node 里的差异怎么答
如果面试官追问 Node,不要把浏览器模型原样照搬。可以这样答:
- Node 也有事件循环,但它没有浏览器渲染阶段。
- Node 的事件循环分多个 phase,常见口径包括
timers、poll、check等。 setTimeout和setImmediate在 Node 中都不是微任务,它们所处 phase 不同。process.nextTick优先级非常高,通常比 Promise 微任务还早处理;这也是 Node 面试常考点。
一个安全答法:
- 浏览器重点讲“task -> microtask -> render”。
- Node 重点讲“多个 phase +
process.nextTick/ Promise 微任务的优先级差异”。 - 不要把两个运行时混成一个模型。
高频题标准答法
1. 为什么 setTimeout(fn, 0) 不是立即执行?
因为 0 表示“最早可以进入调度”,不是“立刻入栈执行”。只有当前调用栈清空,并且轮到对应 task 时,它才会运行。
2. 为什么 Promise 比 setTimeout 早?
因为 Promise 回调进的是微任务队列;微任务会在当前 task 结束后、下一个 task 开始前清空。
3. DOM 渲染发生在什么时候?
不是每句 JS 后立即渲染,而是通常在一个 task 执行完、微任务清空之后,浏览器再决定是否进行渲染。
4. 长任务为什么会导致掉帧?
因为主线程被同步代码长期占用,浏览器拿不到渲染机会,也处理不了用户输入。
易错点 / 坑
- 把 Promise 回调说成宏任务。
- 认为
setTimeout(fn, 0)会“马上执行”。 - 把浏览器和 Node 的事件循环细节混在一起讲。
- 认为
await会阻塞线程;它暂停的是当前async函数的后半段,不是整个 JS 引擎。 - 只会背输出顺序,不会解释“队列是怎么被清的”。
速记要点(可背诵)
- 浏览器一轮事件循环:
task -> 清空 microtask -> 可能渲染 -> 下一个 task。 Promise.then、queueMicrotask是微任务,setTimeout是 task。await本质是把后续逻辑放进微任务续执行。- 微任务优先级高,但连续塞微任务会拖慢渲染与交互。