web学习笔记编程技术学习与分享
首页 / 前端开发 / 正文

事件循环与微任务:setTimeout 为什么不准时

2026 年 09 月 03 日约 3 分钟阅读 前端开发

setTimeout(fn, 0) 实际执行时间经常是 4ms 甚至更久,不是浏览器实现得差,而是事件循环本身的规则决定的。

一次循环做四件事

JavaScript 是单线程的,所有任务排队执行。每一轮事件循环大致是:

  1. 从宏任务队列取出一个任务执行;
  2. 执行期间产生的微任务进入微任务队列;
  3. 当前宏任务结束后,清空整个微任务队列;
  4. 必要时进行渲染(样式、布局、绘制)。

宏任务包括:setTimeout、setInterval、I/O 回调、用户交互事件。微任务包括:Promise.then、queueMicrotask、MutationObserver。

关键差异:每轮只取一个宏任务,但会把微任务全部执行完。所以微任务里再产生微任务,会继续在同一轮里执行,形成「饿死渲染」的风险。

setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('sync');
// 输出:sync → promise → timeout

为什么会有 4ms 的延迟

HTML 规范规定,嵌套层级超过 5 层的定时器,最小延迟为 4ms。另外,浏览器需要在任务之间留出渲染时间,否则页面无法响应。

更常见的原因是主线程被长任务占住。同步执行 200ms 的循环,期间所有定时器、点击事件都要排队,等循环结束才轮得到。

用微任务改写的坑

function loop() {
  Promise.resolve().then(loop);   // 错误:微任务无限递归,页面直接卡死
}

正确做法是用宏任务让出主线程:

function loop() {
  setTimeout(loop, 0);            // 每轮之间留出渲染机会
}

实践中的几条经验

  • 大量 DOM 操作合并到一次,减少布局次数;
  • 长列表分批渲染,每批之间 await new Promise(r => setTimeout(r, 0));
  • 需要「尽快但在当前同步代码之后」执行,用 queueMicrotask,不要用 setTimeout(fn, 0);
  • 不要在微任务里做重活,它比宏任务更容易造成渲染饥饿。

理解这套模型后,「为什么点击没反应」「为什么动画掉帧」这类问题,多半能在 Performance 面板的长任务列表里找到答案。

JavaScript异步事件循环