知

模块一 · 语言与 DOM

第 3 / 14 节18 分钟阅读型

事件循环:为什么 Promise 先于 setTimeout

事件循环不是"队列转圈",而是调用栈、宏任务、微任务与渲染时机的协作。这一节用可复现的例子讲清执行顺序。

读完这一节,你能做到

  1. 画出一次事件循环的完整流程。
  2. 判断一段代码里同步、微任务、宏任务的执行顺序。
  3. 解释 Promise 为什么先于 setTimeout。
  4. 说清渲染时机与 requestAnimationFrame 的位置。

一、三个基本结构

调用栈:正在执行的函数
宏任务队列:setTimeout、setInterval、I/O、消息事件
微任务队列:Promise.then、queueMicrotask、MutationObserver

一次循环的流程:

  1. 执行调用栈里的同步代码,直到栈空。
  2. 清空微任务队列(期间新产生的微任务也会在本轮执行)。
  3. 浏览器判断是否需要渲染(样式、布局、绘制)。
  4. 取出一个宏任务执行,回到第 1 步。

关键点:每执行完一个宏任务,都会清空一次微任务队列;微任务里再产生微任务,也会在当前轮清空,直到队列为空。

二、经典顺序题

console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");

输出是 A D C B:

  • A、D 是同步代码,先执行。
  • C 是微任务,栈空后立即执行。
  • B 是宏任务,等下一轮。

如果 Promise 里再套 setTimeout,顺序会变成"微任务先清空,再取下一个宏任务",不要凭感觉排。

一道完整的顺序题

async function async1() {
  console.log("1");
  await async2();
  console.log("2");
}
async function async2() {
  console.log("3");
}
console.log("4");
setTimeout(() => console.log("5"));
async1();
new Promise((resolve) => {
  console.log("6");
  resolve();
}).then(() => console.log("7"));
console.log("8");

执行顺序是 4 1 3 6 8 2 7 5:

  • 4、8 是同步代码;async1 调用后立刻打印 1,async2 同步打印 3。
  • await 把 2 放进微任务,Promise 构造器里的 6 同步执行。
  • 栈空后清空微任务:2 先入队,7 后入队,依次输出。
  • 最后执行宏任务 5。

答题时先说“同步 → 微任务 → 宏任务”,再逐步展开,比直接报答案更有说服力。

三、浏览器与 Node 的差异

维度浏览器Node
宏任务消息、定时器、I/Otimers、poll、check、close
微任务Promise、MutationObserverPromise、process.nextTick、queueMicrotask
优先级微任务统一nextTick 先于 Promise
渲染每轮可能渲染无渲染概念

Node 的 process.nextTick 优先级高于 Promise,这是面试常考点;浏览器没有 nextTick,对应的是微任务。

四、渲染时机与 rAF

浏览器在"清空微任务之后、取下一个宏任务之前"判断是否渲染。requestAnimationFrame 的回调在渲染前执行,适合做动画;setTimeout 不保证与帧对齐,容易出现抖动。

function loop() {
  update();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

如果一帧内的任务超过 16ms,就会掉帧。长任务要拆分,或者放进 Worker。

长任务为什么会影响交互

浏览器的主线程同时负责 JS 执行、样式计算、布局与绘制。一个 200ms 的同步任务会阻塞输入响应,用户看到的是“点了没反应”。

三个缓解手段:

  • 把计算拆成多个微任务或使用 setTimeout 分片。
  • 把纯计算移进 Web Worker。
  • 用 requestIdleCallback 安排在空闲时执行低优先级任务。

注意:微任务不能无限拆分,否则会延后渲染;需要让出主线程时,必须回到宏任务。

微任务与渲染的竞态

如果在微任务里持续修改 DOM,浏览器会等到微任务队列清空才渲染,用户看到的是“最后一帧”。这解释了两种现象:

  • 同一轮里改十次样式,只会绘制一次。
  • 在微任务里读 getBoundingClientRect,可能触发强制同步布局,反而更慢。

需要“读一次、写一次”的场景,应该把读和写分开,避免布局抖动。

为什么微任务优先

微任务的设计目的,是让“当前操作完成后的收尾工作”尽快执行,而不必等到下一轮事件循环。Promise 的回调、MutationObserver 都属于这类收尾。

代价是:如果微任务里不断产生新的微任务,渲染会被无限推迟。所以把大计算拆进微任务不是好主意,拆到宏任务或 Worker 才能让出渲染机会。

理解这一点,就能解释“为什么 Promise 链很长时页面会卡”以及“为什么 await 会让出执行权”。微任务优先,渲染最后。

五、常见坑

  1. 以为 setTimeout(fn, 0) 会立刻执行(它至少等下一轮)。
  2. 在微任务里无限递归,导致页面卡死(微任务会一直清空)。
  3. 用 setTimeout 做动画,帧率不稳定。
  4. 认为 Promise 是宏任务。
  5. 在 Node 里把 nextTick 与 Promise 的顺序记反。

六、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 手写一段包含同步、微任务、宏任务的代码,先预测输出再运行验证。
  2. 用 performance.now() 测量一个长任务,尝试拆成多个微任务。
  3. 用 rAF 写一个进度条动画,对比 setTimeout 的平滑度。

② 让 AI 写同一题

把第 1 题(输出顺序)交给 AI,要求它逐行标注每个回调属于宏任务还是微任务。

③ 对比要盯的点

  • 它有没有说清“每个宏任务结束后清空整个微任务队列”?只讲先后不讲清空时机等于没讲。
  • requestAnimationFrame 它放在哪个队列?这点最容易含糊。
  • 它有没有混入 Node 的 process.nextTick 而不说明环境差异?

④ 一句话取舍

它给的顺序你能自己推出来吗?推不出来就是背的。

七、自测

  1. 一次事件循环的四个阶段是什么?
  2. 为什么微任务会在宏任务之前执行?
  3. 浏览器与 Node 的微任务优先级差异是什么?
  4. rAF 的回调在什么时机执行?
  5. 微任务无限递归会发生什么?

参考答案要点:栈→微任务→渲染→一个宏任务;栈空即清空;nextTick 优先;渲染前;页面卡死。

附录:高频追问

问:setTimeout 的 0 和 4ms 有什么区别? 浏览器对嵌套定时器有最小延迟限制,多层嵌套后 0 会被提升到约 4ms;这不是规范保证,而是实现行为。

问:Promise.then 一定比 setTimeout 先吗? 在同一轮里是的;如果 setTimeout 先入队且微任务队列为空,宏任务先执行,但 then 仍会在该宏任务结束后立即执行。

问:async 函数里的 await 相当于什么? 相当于把后续代码放进 then 回调,也就是微任务;await 之前的代码仍是同步执行。

问:requestIdleCallback 和 rAF 的区别? rAF 在渲染前执行,适合动画;requestIdleCallback 在空闲时执行,适合低优先级任务,但兼容性与触发时机不稳定。

问:宏任务里再产生微任务,什么时候执行? 当前宏任务结束后立即清空,不会等到下一个宏任务。

问:为什么大量微任务会阻塞渲染? 因为渲染要等微任务队列清空,微任务越多,渲染越晚。

问:Node 的 setImmediate 和 setTimeout(0) 谁先? 取决于执行上下文;在 I/O 回调里 setImmediate 通常先,在主模块里顺序不确定。

问:事件循环和 Web Worker 的关系? 每个 Worker 有独立的事件循环与调用栈,主线程与 Worker 通过消息通信。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解