事件循环与微任务:setTimeout 为什么不准时
setTimeout(fn, 0) 实际执行时间经常是 4ms 甚至更久,不是浏览器实现得差,而是事件循环本身的规则决定的。
一次循环做四件事
JavaScript 是单线程的,所有任务排队执行。每一轮事件循环大致是:
- 从宏任务队列取出一个任务执行;
- 执行期间产生的微任务进入微任务队列;
- 当前宏任务结束后,清空整个微任务队列;
- 必要时进行渲染(样式、布局、绘制)。
宏任务包括: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 面板的长任务列表里找到答案。