从事件循环看setTimeout嵌套的延时偏差问题
很多人在控制台都试过:`setTimeout(fn, 0)` 并不会立刻执行,如果把它嵌套起来递归调用,间隔还会越来越“离谱”。有人怀疑浏览器计时器不准,有人觉得是机器性能问题。其实,这背后既有事件循环的调度机制,也有 HTML 规范对嵌套定时器的最小延迟限制。理解这些,才能知道什么时候该用 `setTimeout`,什么时候不该对它抱有不切实际的精度期待。
本文从事件循环出发,看看 `setTimeout` 嵌套时延时偏差到底从哪里来,以及实际开发中能怎么绕开这些坑。
事件循环不是“定时器执行器”
`setTimeout(fn, delay)` 的语义不是“delay 毫秒后一定执行 fn”,而是“至少 delay 毫秒后,把 fn 放进任务队列”。真正执行还要等事件循环轮到它。
浏览器里一次典型的事件循环大致会做这些事:执行一个宏任务,清空微任务队列,必要时进行渲染,然后再取下一个宏任务。`setTimeout` 的回调属于宏任务。如果当前调用栈里有长任务,或者前面已经排了别的宏任务,即使定时器早就到期,回调也只能排队等待。
所以实际触发时间通常是:
实际时间 >= 指定延时 + 排队等待 + 主线程阻塞时间
这也是为什么在主线程繁忙的页面里,`setTimeout(fn, 100)` 可能 300ms 后才执行。它不是定时器坏了,而是事件循环只能一个一个处理任务。
嵌套 setTimeout 为什么会放大偏差
嵌套 `setTimeout` 的常见写法是这样:
let last = performance.now();
function tick(n) {
const now = performance.now();
console.log(n, (now - last).toFixed(2));
last = now;
if (n < 10) {
setTimeout(() => tick(n + 1), 0);
}
}
tick(0);
如果你在浏览器控制台运行,会看到前几次间隔可能小于 1ms,但嵌套到一定层级后,间隔往往稳定在 4ms 左右。这不是偶然。
HTML 规范里有一个嵌套计时器限制:当 `setTimeout` 的嵌套层级超过 5 层,并且延时小于 4ms 时,浏览器会把延时提升到 4ms。这个限制最初是为了避免页面用 `setTimeout(fn, 0)` 无限递归造成 CPU 占用过高。不同浏览器的实现细节可能略有差异,但“嵌套越深,最小间隔越大”是普遍现象。
更关键的是,嵌套写法意味着下一个定时器必须等上一个回调执行完才注册。因此每次间隔都会包含:
- 上一个回调自身的执行时间;
- 定时器到期后进入任务队列的排队时间;
- 浏览器施加的最小延迟;
- 其他宏任务、微任务、渲染带来的等待。
这些因素叠加后,偏差会一层层累积。你用 `setTimeout` 递归模拟一个 16ms 的节拍,跑一段时间后可能已经偏离几十毫秒。
setInterval 也不等于精确
有人会想,那用 `setInterval` 不就好了?它看起来会按固定间隔触发。但 `setInterval` 的问题是:它只负责按间隔把任务放进队列,并不关心上一个回调有没有执行完。
如果回调执行时间超过间隔,任务就会堆积,或者浏览器直接跳过部分触发。结果是:你以为在按固定频率采样,实际却在“补作业”和“丢帧”之间摇摆。
相对而言,递归 `setTimeout` 至少能保证上一次执行结束后再安排下一次,不会造成任务堆积。但它同样会漂移。如果业务需要稳定节拍,应该用时间戳校正:
const interval = 100;
let expected = performance.now() + interval;
function loop() {
const now = performance.now();
// 做事情
console.log('drift:', now - expected);
expected += interval;
const delay = Math.max(0, expected - performance.now());
setTimeout(loop, delay);
}
这种方式不会让每次间隔都精确等于 100ms,但能让长期平均频率更接近目标,避免误差无限累积。
Node.js 里也有类似问题
服务端的 Node.js 同样有事件循环,只是阶段划分不同。`setTimeout` 回调在 timers 阶段执行,但如果 poll 阶段有大量 I/O 回调,或者前面有 `process.nextTick`、Promise 微任务,定时器回调也会被推迟。
Node.js 里 `setTimeout(fn, 0)` 通常会被当成 1ms,嵌套定时器同样可能受到限制。所以“定时器不精确”不是浏览器独有的问题,而是事件循环模型的天然结果。
实践建议:别拿 setTimeout 当精密时钟
如果你的场景是 UI 动画,优先用 `requestAnimationFrame`,它天然跟随浏览器渲染节奏。如果是音频或复杂动画,考虑 Web Audio API 的时钟。如果是普通业务调度,接受 `setTimeout` 的“至少延迟”语义,不要在它上面建立强实时假设。
还要注意:
- 避免无限递归 `setTimeout(fn, 0)`,它会持续占用主线程;
- 长任务拆成小块,减少对定时器回调的阻塞;
- 需要稳定频率时,用 `performance.now()` 做补偿,而不是盲目相信 delay;
- 后台标签页会被节流,移动端省电模式也会影响定时器,不要依赖它做关键计时。
从事件循环的角度看,`setTimeout` 嵌套的延时偏差不是 bug,而是调度模型、规范限制和主线程竞争共同作用的结果。它适合做“稍后执行”的调度,不适合做“精确到毫秒”的时钟。理解这一点,比调大或调小 delay 更有用。
转载请注明出处,版权归原作者所有。
管理员
黑卡会员



