模块一 · 语言与 DOM
事件循环:为什么 Promise 先于 setTimeout
事件循环不是"队列转圈",而是调用栈、宏任务、微任务与渲染时机的协作。这一节用可复现的例子讲清执行顺序。
读完这一节,你能做到
- 画出一次事件循环的完整流程。
- 判断一段代码里同步、微任务、宏任务的执行顺序。
- 解释 Promise 为什么先于 setTimeout。
- 说清渲染时机与 requestAnimationFrame 的位置。
一、三个基本结构
调用栈:正在执行的函数
宏任务队列:setTimeout、setInterval、I/O、消息事件
微任务队列:Promise.then、queueMicrotask、MutationObserver
一次循环的流程:
- 执行调用栈里的同步代码,直到栈空。
- 清空微任务队列(期间新产生的微任务也会在本轮执行)。
- 浏览器判断是否需要渲染(样式、布局、绘制)。
- 取出一个宏任务执行,回到第 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/O | timers、poll、check、close |
| 微任务 | Promise、MutationObserver | Promise、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 会让出执行权”。微任务优先,渲染最后。
五、常见坑
- 以为 setTimeout(fn, 0) 会立刻执行(它至少等下一轮)。
- 在微任务里无限递归,导致页面卡死(微任务会一直清空)。
- 用 setTimeout 做动画,帧率不稳定。
- 认为 Promise 是宏任务。
- 在 Node 里把 nextTick 与 Promise 的顺序记反。
六、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 手写一段包含同步、微任务、宏任务的代码,先预测输出再运行验证。
- 用
performance.now()测量一个长任务,尝试拆成多个微任务。 - 用 rAF 写一个进度条动画,对比 setTimeout 的平滑度。
② 让 AI 写同一题
把第 1 题(输出顺序)交给 AI,要求它逐行标注每个回调属于宏任务还是微任务。
③ 对比要盯的点
- 它有没有说清“每个宏任务结束后清空整个微任务队列”?只讲先后不讲清空时机等于没讲。
requestAnimationFrame它放在哪个队列?这点最容易含糊。- 它有没有混入 Node 的
process.nextTick而不说明环境差异?
④ 一句话取舍
它给的顺序你能自己推出来吗?推不出来就是背的。
七、自测
- 一次事件循环的四个阶段是什么?
- 为什么微任务会在宏任务之前执行?
- 浏览器与 Node 的微任务优先级差异是什么?
- rAF 的回调在什么时机执行?
- 微任务无限递归会发生什么?
参考答案要点:栈→微任务→渲染→一个宏任务;栈空即清空;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 通过消息通信。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★★★ 原型和原型链是什么?instanceof 是怎么判断的?
- ★★★★★ this 的指向规则有哪些?箭头函数有什么不同?
- ★★★★☆ 重排和重绘有什么区别?哪些操作会触发它们?
- ★★★★☆ 从输入 URL 到页面渲染完成,中间发生了什么?
提示:读完可以顺手做几道对应题目,检验是否真的理解