跳到主要内容

事件循环(Event Loop):宏任务、微任务、渲染时机、Node 差异怎么讲?

面试速答(30 秒版 TL;DR)

  • JS 同一时刻只能在一个主线程上执行一段同步代码;异步能力由宿主环境(浏览器 / Node)提供,最终再把回调塞回事件循环。
  • 浏览器里可以先记一句话:执行一个 task -> 调用栈清空 -> 清空 microtask -> 浏览器决定是否渲染 -> 下一轮 task
  • 常见口径里,setTimeout、DOM 事件回调、I/O 回调属于宏任务(task);Promise.thenqueueMicrotaskMutationObserver 属于微任务(microtask)。
  • 微任务优先级高于下一个宏任务,所以“Promise.thensetTimeout(fn, 0) 先执行”。
  • 面试别只背输出题,要能解释三件事:回调为什么没立刻执行、为什么 Promise 更早、渲染为什么不是每行 JS 后都发生。

心智模型:JS 线程只负责“跑栈”,调度靠宿主

把整个系统拆成三层最好讲:

  1. 调用栈(Call Stack):同步代码真正执行的地方。
  2. 宿主能力(Web APIs / Node APIs):定时器、网络、文件、事件监听等异步来源。
  3. 事件循环(Event Loop):当调用栈空了,决定下一步把哪个回调推进来执行。

一句话版本:

  • 同步代码永远先入栈执行。
  • 异步任务先交给宿主环境等待时机成熟。
  • 时机成熟后,回调进入对应队列,等调用栈空了再被调度执行。

浏览器里的标准口径

常见来源可以这么背:

类型常见来源面试口径
宏任务 / task整体脚本、setTimeout、用户事件回调、网络事件回调一轮只取一个 task 执行
微任务 / microtaskPromise.then/catch/finallyqueueMicrotaskMutationObserver当前 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

标准解释:

  1. 整个脚本本身就是一个 task,所以同步代码先执行,打印 AB
  2. setTimeout 只是把回调注册给宿主环境,不会立刻入栈。
  3. Promise.then 注册的是微任务,等当前 task 结束后立刻清空。
  4. 所以先执行 P1P2,再进入下一轮 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,常见口径包括 timerspollcheck 等。
  • setTimeoutsetImmediate 在 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.thenqueueMicrotask 是微任务,setTimeout 是 task。
  • await 本质是把后续逻辑放进微任务续执行。
  • 微任务优先级高,但连续塞微任务会拖慢渲染与交互。