🐒一文搞懂前端事件循环(Event Loop):从宏任务/微任务到渲染时机

0 阅读9分钟

前端写异步,真正难的不是「会不会用 Promise」,而是你能不能回答清楚:这段代码为什么是这个输出顺序为什么页面不渲染了为什么 setTimeout(0) 不是 0为什么 MutationObserver 回调像 Promise 一样“插队”。这篇把浏览器里的事件循环拆开讲:宏任务、微任务、渲染、常见 API 的定位,以及你在工程里最容易踩的坑。


1. 事件循环到底在“循环”什么

浏览器里通常只有一条主线程负责:

  • JS 执行:同步代码、回调、微任务
  • UI 渲染:样式计算、布局、绘制、合成
  • 事件分发:点击、输入、滚动等

事件循环可以把它理解为一个调度器:不断从「任务队列」里取回调执行,让主线程在 “执行 JS” 与 “渲染 UI” 之间切换。

把浏览器里最常见的一次 tick(一次轮转)粗略写成这样:

  1. 取出并执行 一个宏任务(macro task)
  2. 执行完该宏任务后,清空微任务队列(micro task queue)(一直执行到为空)
  3. 进入渲染机会:浏览器在合适时机进行 render
  4. 下一轮 tick

一句话微任务会在“本轮宏任务结束后、渲染前”被清空(如果一直追加微任务,会把渲染饿死)。


2. 宏任务 vs 微任务

2.1 微任务(Micro Task)

常见来源:

  • Promise.then/catch/finally
  • queueMicrotask
  • MutationObserver 的回调(浏览器把它安排在微任务里批量触发)

微任务的特征:更“紧急”,会在当前宏任务结束后立刻执行完(执行到队列为空)。

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 是微任务 → 本轮宏任务结束后马上执行
  • 2setTimeout 回调是宏任务 → 下一轮 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-1sync-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-1sync-2 先输出(同步)
  • 然后是一批微任务输出(promise / queueMicrotask / mutation 的相对先后不建议当作稳定规则)
  • raf 出现在绘制周期附近(不同设备/负载下可能与上一批输出的相对位置略有差异)
  • timeout 最后(下一轮宏任务)

9. 最常见误区清单(面试 + 工程都常见)

  • setTimeout(fn, 0) 不是 0ms:计时器受最小延迟、嵌套层级、后台标签页节流等影响。
  • 微任务不是“更快的 setTimeout”:微任务会在本轮结束时被清空,滥用会影响渲染与交互响应。
  • DOM 变了不代表立刻渲染:渲染通常在宏任务与微任务之后,由浏览器决定时机。
  • 把“事件循环”只背宏/微任务不够:真正的线上卡顿来自“长任务 + 渲染被推迟 + 强制同步布局”,三者常一起出现。

10. 一页总结

  1. 同步代码先跑完(这是一个宏任务:script)
  2. 本轮末尾会清空微任务(Promise、queueMicrotaskMutationObserver
  3. 微任务清空后,浏览器才有机会渲染
  4. setTimeout 等宏任务回调在后续 tick 执行
  5. 微任务递归会饿死渲染;做分片/动画优先考虑 rAF 或宏任务切片

11. 延伸阅读(MDN 权威链接)

如果你想看更“标准化”的定义与细节,下面这些 MDN 页面很值得顺着读(尤其是微任务指南两篇):