文章

浏览器事件循环深度解析

宏任务 vs 微任务,为什么 Promise 比 setTimeout 快,一帧里发生了什么。

浏览器事件循环深度解析

一句话概括

浏览器事件循环用两个队列调度异步任务:宏任务(setTimeout/setInterval/I/O/事件)每次只取一个执行,微任务(Promise.then/MutationObserver/queueMicrotask)一口气清空整个队列。每轮循环 = 1 个宏任务 → 所有微任务 → 渲染(如需要)。

核心知识点

1. 宏任务 vs 微任务:谁先谁后?

1
2
3
4
5
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出: 1 → 4 → 3 → 2

原因是:同步代码先跑完 → 清空微任务队列(Promise.then 输出 3) → 渲染(可选) → 取下一个宏任务(setTimeout 输出 2)。哪怕 setTimeout 延迟为 0,它也要等当前宏任务 + 所有微任务跑完才轮到自己。

宏任务微任务
setTimeout、setIntervalPromise.then/catch/finally
I/O 回调、事件监听MutationObserver
MessageChannel、postMessagequeueMicrotask()
setImmediate(Node.js)process.nextTick(Node.js, 比微任务还快)

2. 一整轮事件循环长什么样

1
2
3
4
5
6
7
8
9
10
// 用代码模拟事件循环的顺序
queueMicrotask(() => console.log('micro 1'));
setTimeout(() => {
  console.log('macro 1');
  Promise.resolve().then(() => console.log('micro 2'));
}, 0);
setTimeout(() => console.log('macro 2'), 0);

// 输出: micro 1 → macro 1 → micro 2 → macro 2
// 解读: 同步后先清微任务 → 宏任务1 执行 → 宏任务1 产生的微任务 micro 2 在下一轮前清空 → 宏任务2

3. requestAnimationFrame:渲染钩子

rAF 既不是宏任务也不是微任务——它在渲染阶段之前执行,浏览器保证在下一帧绘制前调用。适合做动画和 DOM 批量更新:

1
2
3
4
5
6
7
// 每帧执行一次,帧率跟显示器刷新同步
function animate() {
  box.style.transform = `translateX(${x++}px)`;
  if (x < 300) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
// 比 setTimeout 动画丝滑得多——不会在帧中间执行,不会丢帧

4. 页面卡死的原因:长任务阻塞

一帧预算约 16.6ms(60fps)。如果宏任务执行超过 50ms,浏览器标记为”长任务(Long Task)”,期间无法响应用户交互。解决方案:把大任务拆小。

1
2
3
4
5
6
7
8
9
10
// ❌ 一次处理 100 万条,主线程卡死数秒
items.forEach(item => heavyProcess(item));

// ✅ 用 setTimeout 拆成小块
function processChunk(start = 0) {
  const end = Math.min(start + 1000, items.length);
  for (let i = start; i < end; i++) heavyProcess(items[i]);
  if (end < items.length) setTimeout(() => processChunk(end), 0);
}
// 或者用 requestIdleCallback 在浏览器空闲时处理

5. MutationObserver:监听 DOM 的微任务

1
2
3
4
5
6
7
const observer = new MutationObserver((mutations) => {
  console.log('DOM 变化了!(微任务异步执行,不阻塞当前代码)');
});
observer.observe(document.body, { childList: true, subtree: true });
document.body.appendChild(document.createElement('div'));
console.log('先输出我');
// 输出: 先输出我 → DOM 变化了!

其实你每天都在用

  • nextTick / $nextTick(Vue):利用微任务确保在 DOM 更新后、浏览器渲染前拿到最新 DOM 状态
  • 骨架屏/loading 动画:setTimeout(() => showSkeleton(), 0) 把渲染推迟到下一个宏任务,让浏览器先画一帧
  • Chrome DevTools Performance 面板:黄条 = 脚本执行,紫条 = 渲染——看黄条是否超过 50ms 就可以找到长任务
  • Lighthouse 的 TBT(Total Blocking Time):衡量主线程被长任务阻塞的总时长
  • scheduler.postTask()(新标准):比 setTimeout 更精细的优先级调度

常见误解(FAQ)

❌ 误区一:”setTimeout(fn, 0) 就是立即执行”

0ms 不是真正的 0——浏览器最小延迟是 4ms(嵌套超过 5 层后),并且它排在当前宏任务的微任务队列清空之后。所以 Promise.resolve().then(fn) 一定比 setTimeout(fn, 0) 先执行。

❌ 误区二:”async/await 后面的是微任务”

await 之后的代码等价于 Promise.resolve().then(...),确实是微任务。但 await 之前的表达式是同步执行的:

1
2
3
4
5
6
7
8
async function test() {
  console.log('A');              // 同步
  await Promise.resolve();
  console.log('B');              // 微任务
}
test();
console.log('C');
// 输出: A → C → B

❌ 误区三:”微任务队列无限递归不会卡死页面”

会。微任务执行完当前队列中所有任务后才交控制权给浏览器——如果微任务里无限产生新微任务,宏任务永远轮不到,页面彻底卡死。process.nextTick 递归在 Node.js 中就是这样。

❌ 误区四:”requestAnimationFrame 每 16ms 一定执行”

rAF 跟着显示器刷新率走。120Hz 屏幕每 8.3ms 一次,后台标签页完全停止。而且如果 JS 执行时间超过帧预算,rAF 会被推迟——它不在独立线程上。

一句话总结

事件循环的核心:每个宏任务之后,微任务必须清空,然后浏览器才有机会渲染。写出”不卡”的代码,本质就是把长任务切成小块,让浏览器在每个宏任务之间能喘口气画一帧。

本文由作者按照 CC BY 4.0 进行授权