面试里问「宏任务和微任务的区别」,多数人能背出来。但问「一段代码什么时候会让页面掉帧」,答案往往就模糊了。这篇不背概念,只用实验说话。

实验一:谁先执行

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 少一层钳制
事件回调 宏任务 事件到达时

小结

事件循环不是八股文,它直接决定了三件事:

  1. 代码的执行顺序(微任务优先)
  2. 页面会不会卡(长任务阻塞渲染)
  3. 动画顺不顺(把变更放进 rAF,只动 transform)

下次遇到「明明逻辑没问题但页面卡顿」,先问一句:我在哪一层队列里干活?