面试里问「宏任务和微任务的区别」,多数人能背出来。但问「一段代码什么时候会让页面掉帧」,答案往往就模糊了。这篇不背概念,只用实验说话。
实验一:谁先执行
console.log('1 script start');
setTimeout(() => console.log('5 setTimeout'), 0);
Promise.resolve().then(() => console.log('3 promise'));
queueMicrotask(() => console.log('4 microtask'));
console.log('2 script end');
输出:
1 script start
2 script end
3 promise
4 microtask
5 setTimeout
规律:一个宏任务执行完,会清空整个微任务队列,然后才轮到下一个宏任务。
setTimeout(fn, 0) 并不是「立刻执行」,而是「插入到下一个宏任务队列的末尾」。所以它永远排在本次同步代码产生的所有微任务之后。
实验二:微任务会饿死渲染
function loop() {
Promise.resolve().then(loop); // 微任务里再排微任务
}
loop();
页面会直接卡死。因为微任务队列不清空,浏览器就不会渲染。
对比一下用宏任务:
function loop() {
setTimeout(loop, 0); // 页面还能响应,只是慢
}
loop();
虽然也占满了主线程,但每轮之间浏览器有机会插入渲染和响应事件。
生产代码里要避免「在微任务里无限递归」。如果必须做大批量计算,用
setTimeout或scheduler.postTask分片。
实验三:渲染发生在哪一步
button.addEventListener('click', () => {
box.style.width = '200px'; // 修改样式
console.log(getComputedStyle(box).width); // 立刻读取 → 强制同步布局
Promise.resolve().then(() => {
console.log('微任务里读布局:', getComputedStyle(box).width);
});
requestAnimationFrame(() => {
console.log('rAF 里读布局:', getComputedStyle(box).width);
});
});
一帧的完整顺序大致是:
输入事件 → 微任务清空 → requestAnimationFrame 回调
→ 样式计算 → 布局 → 绘制 → 合成
所以:
- 微任务里读样式:还处于同一轮,可能触发强制重排
- rAF 里读样式:渲染前的最后一站,适合做动画
写动画的正确姿势:
function animate(from, to, duration) {
const start = performance.now();
function step(now) {
const t = Math.min((now - start) / duration, 1);
box.style.transform = `translateX(${from + (to - from) * t}px)`; // 只改 transform
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
只动 transform / opacity,走合成层,不触发重排重绘。
实验四:await 到底做了什么
async function f() {
console.log('a');
await null; // 等价于 await Promise.resolve()
console.log('c'); // 被放进微任务
}
f();
console.log('b');
输出 a → b → c。原因是 await 后面的代码会被包装成微任务,这是它比 .then 链更容易读、但同样会「让出」执行权的原因。
在循环里 await 尤其要注意:
// 每个请求串行,总耗时 = 各次之和
for (const id of ids) {
await fetch(`/api/item/${id}`);
}
// 并发,总耗时 = 最慢的那次
await Promise.all(ids.map((id) => fetch(`/api/item/${id}`)));
一张速查表
| 机制 | 队列 | 时机 |
|---|---|---|
| 同步代码 | — | 立即 |
queueMicrotask / Promise.then |
微任务 | 当前宏任务结束后立刻 |
requestAnimationFrame |
渲染队列 | 下一帧渲染前 |
setTimeout / setInterval |
宏任务 | 下一个宏任务轮次 |
MessageChannel |
宏任务 | 同上,但比 setTimeout 少一层钳制 |
| 事件回调 | 宏任务 | 事件到达时 |
小结
事件循环不是八股文,它直接决定了三件事:
- 代码的执行顺序(微任务优先)
- 页面会不会卡(长任务阻塞渲染)
- 动画顺不顺(把变更放进 rAF,只动 transform)
下次遇到「明明逻辑没问题但页面卡顿」,先问一句:我在哪一层队列里干活?