网站首页 > 技术文章 正文
大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。
即使使用 JavaScript 很长一段时间,很多开发者也可能会惊叹为何 setTimeout(0) 并不是真正的 setTimeout(0)。相反,其可能会延迟 4 毫秒运行:
// W3C 规范:如果嵌套级别大于 5,并且超时小于 4ms,则将超时设置为 4ms
function createNestedTimeout(nestingLevel) {
if (nestingLevel > 10) return;
// 防止无限
const start = performance.now();
setTimeout(() => {
const delay = performance.now() - start;
console.log(` 嵌套层级 ${nestingLevel}: 实际延迟 ≈ ${delay.toFixed(2)} ms`);
// 在 setTimeout 回调中直接递归调用下一个 setTimeout
// 此时才会被浏览器视为 “嵌套定时器”
createNestedTimeout(nestingLevel + 1);
}, 0);
}
createNestedTimeout(1);
Microsoft Edge 团队开发者给出的解释是:浏览器这样做是为了避免 setTimeout 被滥用。 为了尽量避免耗尽用户电量或阻碍用户交互,浏览器会设置一个特殊的限制 ,即最小值 4ms。
这也从侧面解释了为何有些浏览器会对使用电池供电的设备加大节流(旧版 Edge 为 16ms)或对后台标签页进行更严格的节流(Chrome 为 1000ms)。
然而这又引申出另一个问题:如果 setTimeout 被滥用,那为何浏览器还要不断引入新的计时器,例如:setImmediate、Promises,甚至 scheduler.postTask() 呢?同时如果 setTimeout 必须被削弱,那么这些计时器最终会不会也遭遇同样的命运?
Nolan Lawson 在 2018 年写过一篇 JavaScript 计时器的长文,最近他又开发了 fake-indexeddb 库,该库是 IndexedDB 的纯 JavaScript 实现,于是开始重新审视这个问题。IndexedDB 希望在事件循环中没有未完成的工作时自动提交事务,即在所有微任务完成后但在任何新宏任务开始之前 。
为了实现目标,fake-indexeddb 在 Node.js 中使用了 setImmediate,而在浏览器中使用了 setTimeout。在 Node 中 setImmediate 非常完美,因为其会在所有微任务之后、但在任何其他任务之前立即运行。然而在浏览器中,setTimeout 的表现欠佳: 在一个基准测试中,发现 Chrome 浏览器需要 4.8 秒才能完成 Node 中仅需 300 毫秒的任务,即速度降低了 16 倍!。
同时,展望 2025 年的计时器格局,仍然很难做出明确的选择。
- setImmediate:仅支持旧版 Edge 和 IE,因此无法使用
- MessageChannel.postMessage:MessageChannel 接口允许创建一个新的消息通道并通过两个 MessagePort 属性发送数据
const channel = new MessageChannel();
const output = document.querySelector(".output");
const iframe = document.querySelector("iframe");
// 等待 iframe 加载完成
iframe.addEventListener("load", onLoad);
function onLoad() {
// 监听 port1 的消息
channel.port1.onmessage = onMessage;
// 将 port2 发送给 iframe
iframe.contentWindow.postMessage("Hello from the main page!", "*", [
channel.port2,
]);
}
// 监听 port1 收到的消息
function onMessage(e) {
output.innerHTML = e.data;
}
- window.postMessage :想法不错但有点卡顿,因为其可能会干扰页面上使用相同 API 的其他脚本 (setImmediate 的 polyfill 使用了该方法)
const myWorker = new Worker("worker.js");
if (crossOriginIsolated) {
const buffer = new SharedArrayBuffer(16);
myWorker.postMessage(buffer);
} else {
const buffer = new ArrayBuffer(16);
myWorker.postMessage(buffer);
}
- scheduler.postTask :最佳选择
为了比较以上诸多选项,下面编写了一个基准测试,且基准测试有一些限制:
- 必须多次迭代 setTimeout 才能真正了解限制机制。从技术上讲,根据 HTML 规范,只有在 setTimeout 嵌套 5 次后,4 毫秒的限制才会生效
- 没有测试所有可能的组合:1)电池供电还是插电供电;2)显示器刷新率;3)背景标签页还是前景标签页等等。
const pre = document.querySelector("pre");
const log = (str) => {
pre.textContent += str + "\n";
};
const methods = {
setTimeout: () => {
return new Promise((resolve) =>
setTimeout(() => resolve(performance.measure("setTimeout", "start")))
);
},
messageChannel: () => {
return new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = () =>
resolve(performance.measure("messageChannel", "start"));
channel.port2.postMessage(undefined);
});
},
window: () => {
return new Promise((resolve) => {
window.addEventListener(
"message",
() => resolve(performance.measure("window", "start")),
{ once: true }
);
window.postMessage(undefined);
});
},
...(typeof scheduler !== "undefined" && {
postTask: () => {
return new Promise((resolve) =>
scheduler.postTask(() =>
resolve(performance.measure("postTask", "start"))
)
);
},
}),
};
async function main() {
const medians = [];
for (const [method, func] of Object.entries(methods)) {
const results = [];
for (let i = 0; i < 101; i++) {
performance.mark("start");
// 开始标记
results.push(await func());
// 结束标记
}
const median =
results.map((_) => _.duration).reduce((a, b) => a + b, 0) /
results.length;
log(`${method}: ${median} (median of ${results.length} iterations)`);
medians.push(median);
}
log("\nAs table:");
log(medians.map((median) => median.toFixed(2)).join("|"));
}
main();
以下是一些数值(以毫秒为单位,101 次迭代的中位数,基于 2021 年 16 英寸 MacBook Pro):
开发者不必过于担心确切的数字,重点是 Chrome 和 Firefox 将 setTimeout 限制在 4 毫秒,其他三个选项大致相同。有趣的是,在 Safari 中,setTimeout 受到的限制更为严重,
MessageChannel.postMessage 比 window.postMessage 稍慢一些。
感悟:现代浏览器(尤其是 Chrome 和 Firefox)实际上已经将 setTimeout(0) 的最小延迟默认 clamped 到 4ms,即使在非嵌套场景下。这看似违反 HTML 规范,但实际上是浏览器厂商出于性能、功耗和兼容性考虑所做的主动干预(intervntion)。
因此几乎可以得出结论:fake-indexeddb 应该使用 scheduler.postTask,然后 fallback 到
MessageChannel.postMessage 或 window.postMessage。
然而到此还是没回答先前的疑问:既然 Web 开发者可以直接使用 scheduler.postTask 或 MessageChannel,为什么浏览器还要费心去限制 setTimeout 呢?下面是来自 Web 性能工作组 (Web Performance Working Group)Todd Reifsteck 的回答:
总体观点是开发者实际上存在两个阵营:
- 限制派:需要限制计时器以保护 Web 开发者免受自身缺陷的影响
- 非限制派:开发者应该 “衡量自己的愚蠢程度”,任何微妙的限制启发式方法只会造成混乱
因此大致可以得出结论:浏览器干预通常是因为 Web 开发者要么用得太多(例如 setTimeout),要么就是完全没有意识到更好的选择。归根结底,浏览器是代表用户行事的 “用户代理”,而 W3C 的优先级明确指出,最终用户的需求始终高于 Web 开发者的需求。因此,通过赋予开发者对任务和调度更多的控制权,可以避免反复使用 setTimeout 并造成需要干预的混乱。
同时,postTask/postMessage 可能暂时不会受到限制。在 Todd Reifsteck 提出的两个阵营中,Scheduler API 的存在本身就表明,其提供了一系列用于任务调度的细粒度工具。尽管 Todd Reifsteck 认为该 API 更像是两派之间的妥协:其提供了大量的控制权,但也与浏览器的实际渲染管道(而非随机超时)保持一致。
然而还是需要从悲观的角度思考下该 API 是否仍然可能被滥用,例如:在任何地方不经意地使用 user-blocking 优先级,又或许在未来一些有进取心的浏览器厂商会更加坚定地踩下油门,并发现其能让网站运行得更快、响应更快、更省电。如果这种情况发生,可能会看到新一轮的干预。
参考资料
文章主体内容来自Nolan Lawson发表的文章《Why do browsers throttle JavaScript timers?》,但是对部分内容添加了自己的理解。
https://nolanlawson.com/2025/08/31/why-do-browsers-throttle-javascript-timers/
https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timer-initialisation-steps
https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#reasons_for_delays_longer_than_specified
https://dev.to/jeetvora331/throttling-in-javascript-easiest-explanation-1081
猜你喜欢
- 2024-08-10 前端进阶都应该了解的知识点 - INP
- 2024-08-10 前端常见问题和技术解决方案(前端开发常见问题)
- 2024-08-10 第72节 Blob、File对象和FileReader及URL对象-前端开发JavaScript
- 2024-08-10 面试完50个人后我写下这篇总结(面试50分钟是好事吗)
- 2024-08-10 WebView 详细解说「图文」(webiview)
- 2024-08-10 看看浏览器如何解析我们写的页面?
- 2024-08-10 layui 弹层组件layer(layui弹框类型)
- 2024-08-10 JAVA浏览器控件JxBrowser v7.5上线!更轻松处理Dynamic Favicons
- 2024-08-10 那些不常见,但却非常实用的JS知识点(上)
- 2024-08-10 WebView性能、体验分析与优化(webiview)
- 最近发表
- 标签列表
-
- cmd/c (90)
- c++中::是什么意思 (84)
- 标签用于 (71)
- 主键只能有一个吗 (77)
- c#console.writeline不显示 (95)
- pythoncase语句 (88)
- es6includes (74)
- sqlset (76)
- apt-getinstall-y (100)
- node_modules怎么生成 (87)
- chromepost (71)
- flexdirection (73)
- c++int转char (80)
- mysqlany_value (79)
- static函数和普通函数 (84)
- el-date-picker开始日期早于结束日期 (76)
- js判断是否是json字符串 (75)
- c语言min函数头文件 (77)
- asynccallback (87)
- localstorage.removeitem (77)
- vector线程安全吗 (70)
- java (73)
- js数组插入 (83)
- mac安装java (72)
- 无效的列索引 (74)