setTimeout 4ms 最小延迟
setTimeout(fn, 0) 的延迟并不是严格意义上的 0 毫秒,特别是当它被嵌套调用(例如在回调函数里再次调用 setTimeout(fn, 0))时,浏览器会强制将最小延迟设为 4ms。这个 4ms 来源于 HTML 规范 和浏览器实现的安全策略。
为什么是 4ms?
1. 规范要求:防止“定时器风暴”
根据 HTML Living Standard 的规范:
If the nesting level is greater than 5, and the timeout is less than 4, then set the timeout to 4.
简单翻译:
- 浏览器会记录当前
setTimeout的嵌套深度(即在同一个调用栈中,连续通过setTimeout产生的回调次数)。 - 当嵌套深度 超过 5 层(某些旧规范为 4 层,现代规范为 5 层),并且你设置的延迟时间小于 4ms(比如 0),浏览器会自动将延迟强制改为 4ms。
2. 目的:保护性能和功耗
如果没有这个限制,代码可以这样写:
function loop() {
// 做点事情
setTimeout(loop, 0); // 无限递归,每次都尝试立即执行
}
loop();
这会导致浏览器在极短时间内疯狂产生大量宏任务,持续占用 CPU,造成页面卡顿、电池快速消耗,甚至让整个标签页失去响应。4ms 的限制相当于强制给这种“定时器炸弹”一个最低的时间间隙,保证事件循环有机会处理其他任务(如用户输入、渲染、网络等)。
3. 历史渊源:从 10ms 到 4ms
- 早期浏览器(IE、Firefox 等)对
setTimeout(fn, 0)的最小延迟是 10ms。 - 后来为了提升性能,Chrome 等浏览器在非嵌套场景下可以将延迟压到 1ms 左右(实际上也不完全是 0,受系统时钟粒度影响)。
- 但嵌套场景的风险一直存在,经过讨论,最终 HTML 规范采用了 4ms 作为嵌套后的强制最小值。这个数值是一个经验平衡值:既能避免过于频繁的调用,又不会明显影响正常应用的响应速度。
什么情况下会触发 4ms 限制?
- 嵌套:指在
setTimeout回调函数内部再次调用setTimeout(fn, 0),并且这种递归/嵌套的深度达到 5 层以上。 - 不是任何
setTimeout(fn, 0)都是 4ms:
第一次调用setTimeout(fn, 0),如果没有发生嵌套,实际延迟可能是 0~1ms(取决于浏览器和当前负载)。
例如:setTimeout(() => {console.log('第一次延迟 < 4ms');setTimeout(() => {console.log('第二次延迟可能还是 < 4ms'); // 深度2,未超5setTimeout(() => {// ... 深度达到6时,强制 4ms}, 0);}, 0);}, 0);
验证 4ms 的小实验(在浏览器控制台)
let start = performance.now();
let count = 0;
function testNested() {
let now = performance.now();
console.log(`第${++count}次,实际延迟:${now - start}ms`);
start = now;
if (count < 10) setTimeout(testNested, 0);
}
setTimeout(testNested, 0);
你会发现,前几次延迟很小(可能 1ms 左右),大约第 6 次开始,延迟稳定在 4ms 左右。
总结
- 4ms 不是随意定的数字,而是由 HTML 规范强制规定的嵌套 setTimeout 的最小延迟。
- 目的是防止恶意或意外的递归
setTimeout(fn, 0)霸占事件循环,保护浏览器性能和用户设备续航。 - 非嵌套的
setTimeout(fn, 0)实际延迟通常小于 4ms(但不会是绝对的 0),无法用来保证精准的定时。
如果你想实现真正的“下一个宏任务”且不被 4ms 限制,可以考虑 MessageChannel 或 postMessage(它们没有这种嵌套限制)。