前端写异步,真正难的不是「会不会用 Promise」,而是你能不能回答清楚:这段代码为什么是这个输出顺序、为什么页面不渲染了、为什么 setTimeout(0) 不是 0、为什么 MutationObserver 回调像 Promise 一样“插队”。这篇把浏览器里的事件循环拆开讲:宏任务、微任务、渲染、常见 API 的定位,以及你在工程里最容易踩的坑。
1. 事件循环到底在“循环”什么
浏览器里通常只有一条主线程负责:
- JS 执行:同步代码、回调、微任务
- UI 渲染:样式计算、布局、绘制、合成
- 事件分发:点击、输入、滚动等
事件循环可以把它理解为一个调度器:不断从「任务队列」里取回调执行,让主线程在 “执行 JS” 与 “渲染 UI” 之间切换。
把浏览器里最常见的一次 tick(一次轮转)粗略写成这样:
- 取出并执行 一个宏任务(macro task)
- 执行完该宏任务后,清空微任务队列(micro task queue)(一直执行到为空)
- 进入渲染机会:浏览器在合适时机进行 render
- 下一轮 tick
一句话:微任务会在“本轮宏任务结束后、渲染前”被清空(如果一直追加微任务,会把渲染饿死)。
2. 宏任务 vs 微任务
2.1 微任务(Micro Task)
常见来源:
Promise.then/catch/finallyqueueMicrotaskMutationObserver的回调(浏览器把它安排在微任务里批量触发)
微任务的特征:更“紧急”,会在当前宏任务结束后立刻执行完(执行到队列为空)。
2.2 宏任务(Macro Task)
常见来源:
script(整段同步脚本本身就是一个宏任务)setTimeout/setInterval- 事件回调(点击、输入等)
MessageChannel(常用作更稳定的宏任务调度)
宏任务的特征:一轮只取一个执行,执行完再进入微任务清空。
2.3 重要但容易混淆:requestAnimationFrame
requestAnimationFrame(rAF)不是用“宏/微任务”最好记,它更像是一次绘制周期(frame)里的回调:浏览器准备绘制下一帧时,会在合适时机调用 rAF,让你把视觉相关的更新(DOM 写入、动画推进)对齐到这一帧;随后浏览器再进入本帧的绘制/合成流程。
3. 一段代码讲清输出顺序(最经典)
console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
输出顺序:1 4 3 2
解释要点:
1、4:同步代码,立刻执行3:Promise 的then是微任务 → 本轮宏任务结束后马上执行2:setTimeout回调是宏任务 → 下一轮 tick 才会取出执行
4. queueMicrotask:把函数塞进微任务队列
4.1 它是什么
queueMicrotask(fn) 是 浏览器原生 API(现代 Node.js 也提供),语义非常纯粹:把 fn 放进 微任务队列。
等价记忆:
queueMicrotask(fn)≈Promise.resolve().then(fn)(都进入微任务队列)。
4.2 什么时候用它
- 你想把一段逻辑延后到当前同步代码执行完之后,但又希望它早于 setTimeout。
- 你想表达“这是一个微任务”,避免引入 Promise 链(更语义化)。
示例:
console.log("A");
queueMicrotask(() => console.log("micro"));
console.log("B");
输出:A B micro
4.3 典型坑:微任务递归会饿死渲染
function loop() {
queueMicrotask(loop);
}
loop();
这段会让微任务队列永远清不空,浏览器很难再进入渲染阶段,表现为页面卡死/无响应。
工程上要做“分片/让出主线程”,一般选择:
- 用
requestAnimationFrame贴合帧节奏 - 或用宏任务(
setTimeout/MessageChannel)分批推进 - 或用
requestIdleCallback在空闲片段执行(有兼容与超时策略)
5. MutationObserver:监听 DOM 变化(回调以微任务方式批量触发)
5.1 它解决什么问题
在没有它之前,你可能会用:
- 轮询 DOM
- 重写/包装 DOM API
- 事件委托 + 各种“猜测”
MutationObserver 允许你声明式地监听一个节点(及其子树)的变更:节点增删、属性变化、文本变化等。
5.2 基本用法:监听子节点增删
const target = document.querySelector("#app");
const observer = new MutationObserver((mutationList) => {
for (const m of mutationList) {
if (m.type === "childList") {
console.log("added:", m.addedNodes);
console.log("removed:", m.removedNodes);
}
}
});
observer.observe(target, {
childList: true, // 监听子节点增删
subtree: true, // 监听整个子树
});
// 停止监听:observer.disconnect();
5.3 监听属性变化(常用于 class/style/data-*)
const el = document.querySelector("#box");
const observer = new MutationObserver((list) => {
for (const m of list) {
console.log("attr changed:", m.attributeName);
}
});
observer.observe(el, {
attributes: true,
attributeFilter: ["class", "style"], // 可选:只关心这些属性
});
5.4 回调为什么“像 Promise 一样插队”
因为浏览器会把 MutationObserver 的回调安排到 微任务 中,并且会把同一轮宏任务里产生的多次变更 batch 成一个 mutationList,在宏任务结束后统一回调。
你会观察到这种现象:
- 同步代码里连续改 DOM 多次
- observer 的回调不会“同步触发很多次”,而是本轮末尾统一触发一次(或少量次)
这既是性能优化(合并多次变更),也是它与事件循环的关键联系点。
6. 渲染(render)到底发生在什么时候
在浏览器里,渲染并不是“每执行一行 JS 就渲染一次”,它通常发生在:
- 当前宏任务执行结束
- 微任务队列被清空
- 浏览器判断需要渲染且当前时机允许(比如下一帧)
这就解释了两个常见现象:
6.1 “我改了 DOM,但页面没立刻变化”
因为你仍在同一个宏任务里执行 JS,浏览器一般不会在你中途打断去渲染。
6.2 “我在同一轮里读 layout,可能触发强制同步布局”
当你写入影响布局的样式(如 width/height/transform 之外的布局属性),然后立刻读取 offsetWidth / getBoundingClientRect() 之类,浏览器可能被迫把待处理的样式与布局计算提前做完(俗称 reflow/forced layout),这会造成性能抖动。
7. rAF / 微任务 / setTimeout:工程里怎么选
7.1 选择指南(足够实用)
| 目标 | 推荐 | 原因 |
|---|---|---|
| 想在本轮同步结束后立刻跑一段逻辑 | queueMicrotask / Promise.then | 微任务,优先级高,紧贴当前上下文 |
| 想在下一轮再跑(让出主线程) | setTimeout / MessageChannel | 宏任务切片,能给渲染/输入机会 |
| 想和渲染对齐做动画/视觉更新 | requestAnimationFrame | 与帧节奏一致,避免错位与无效绘制 |
| 想做低优先级后台工作 | requestIdleCallback(注意降级/超时) | 利用空闲片段,减少与关键交互争抢 |
7.2 典型反例:用微任务做“分片”
如果你用微任务不断推进大循环,看起来是“异步了”,但它仍可能:
- 长时间霸占主线程(微任务清空策略)
- 推迟渲染与用户输入响应
所以“分片”的关键不是“异步”两个字,而是你有没有在合适粒度让出主线程。
7.3 MessageChannel:更稳定的“下一轮再执行”(宏任务切片)
MessageChannel 是浏览器的消息管道(port1 / port2)。常见用法是把回调挂到 port1.onmessage,再用 port2.postMessage() 触发——这会把回调排入任务队列(宏任务),适合做:
- nextTick(下一轮 tick):比
setTimeout(fn, 0)更“干净”的宏任务调度 - 切片执行:把大任务拆成多段,段与段之间让浏览器有机会渲染/响应输入
7.3.1 一个 nextTick 例子(可直接跑)
function nextTick(fn) {
const { port1, port2 } = new MessageChannel();
port1.onmessage = () => {
port1.close();
port2.close();
fn();
};
port2.postMessage(null);
}
console.log("sync-1");
nextTick(() => console.log("messagechannel"));
Promise.resolve().then(() => console.log("promise"));
console.log("sync-2");
你通常会看到顺序是:同步(sync-1、sync-2)→ 微任务(promise)→ 宏任务(messagechannel)。
7.3.2 切片执行:把大循环拆成多段
function chunkRun(tasks, batchSize = 500) {
const channel = new MessageChannel();
channel.port1.onmessage = () => {
let n = 0;
while (n < batchSize && tasks.length) {
tasks.shift()();
n++;
}
if (tasks.length) {
channel.port2.postMessage(0); // 下一段:排一个新的宏任务
} else {
channel.port1.close();
channel.port2.close();
}
};
channel.port2.postMessage(0);
}
const tasks = Array.from({ length: 50000 }, (_, i) => () => {
if (i % 10000 === 0) console.log("done", i);
});
chunkRun(tasks, 500);
8. 经典输出题:
8.1 题目 1:微任务里再塞宏任务
console.log(1);
setTimeout(() => console.log(2));
Promise.resolve()
.then(() => {
console.log(3);
setTimeout(() => console.log(4));
})
.then(() => console.log(5));
console.log(6);
推导:
- 同步:
1 6 - 微任务(清空):第一个 then 输出
3,并安排一个宏任务(打印 4);链式 then 输出5 - 下一轮宏任务:原来的
2(更早排队),再4
所以输出通常是:1 6 3 5 2 4
记住:同一轮宏任务结束后会把当前微任务队列清空;微任务里创建的
setTimeout要到后续 tick 才会跑。
8.2 题目 2:MutationObserver 与 Promise 谁先
在同一轮宏任务里同时产生 Promise 微任务与 DOM 变更触发的 observer 回调时,它们都发生在微任务阶段。在不同浏览器或不同触发方式下,具体先后可能有差异;工程上不要依赖“谁一定先于谁”,只要把它们当成“本轮末尾、渲染前的一批工作”即可。
你可以用下面的思路判断,而不是死记:
- 谁先入队,谁先执行
- 但它们都属于“本轮末尾、渲染前”的那一批
8.3 一段对比 demo:把常见调度放在一起(建议直接粘到控制台)
const app = document.createElement("div");
app.id = "app";
document.body.appendChild(app);
const ob = new MutationObserver(() => console.log("mutation"));
ob.observe(app, { childList: true });
console.log("sync-1");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
queueMicrotask(() => console.log("queueMicrotask"));
requestAnimationFrame(() => console.log("raf"));
app.appendChild(document.createElement("span")); // 触发 mutation 收集
console.log("sync-2");
你通常会看到:
sync-1、sync-2先输出(同步)- 然后是一批微任务输出(
promise/queueMicrotask/mutation的相对先后不建议当作稳定规则) raf出现在绘制周期附近(不同设备/负载下可能与上一批输出的相对位置略有差异)timeout最后(下一轮宏任务)
9. 最常见误区清单(面试 + 工程都常见)
setTimeout(fn, 0)不是 0ms:计时器受最小延迟、嵌套层级、后台标签页节流等影响。- 微任务不是“更快的 setTimeout”:微任务会在本轮结束时被清空,滥用会影响渲染与交互响应。
- DOM 变了不代表立刻渲染:渲染通常在宏任务与微任务之后,由浏览器决定时机。
- 把“事件循环”只背宏/微任务不够:真正的线上卡顿来自“长任务 + 渲染被推迟 + 强制同步布局”,三者常一起出现。
10. 一页总结
- 同步代码先跑完(这是一个宏任务:script)
- 本轮末尾会清空微任务(Promise、
queueMicrotask、MutationObserver) - 微任务清空后,浏览器才有机会渲染
setTimeout等宏任务回调在后续 tick 执行- 微任务递归会饿死渲染;做分片/动画优先考虑 rAF 或宏任务切片
11. 延伸阅读(MDN 权威链接)
如果你想看更“标准化”的定义与细节,下面这些 MDN 页面很值得顺着读(尤其是微任务指南两篇):
- 微任务指南:
- 微任务相关 API:
- 计时器(宏任务常见来源):
- 渲染对齐:
- 宏任务切片 / 消息通道: