浅析JavaScript定时器setTimeout的时延问题
作者:前端技术栈
全局的 setTimeout() 方法设置一个定时器,一旦定时器到期,就会执行一个函数或指定的代码片段,但是需要注意的是,setTimeout 并不是 ECMAScript 标准的一部分,不过几乎每一个 JS 运行时都支持了这个函数。定时器的使用比较简单,这里不再阐述,我们这篇文章主要聊下关于 setTimeout 有最小时延的相关知识。
一、为什么定时器有最小时延
其实 setTimeout 是有最小时延的,这是为什么呢?有很多因素会导致 setTimeout 的回调函数执行比设定的预期值更久,这里列举一些导致定时器出现时延的原因:
1. 函数过度嵌套
MDN 文档:在浏览器中,每调用一次定时器的最小间隔是 4ms,这通常是由于函数嵌套导致(嵌套层级达到一定深度),5 层以上的定时器嵌套会导致至少 4ms 的延迟。
let a = performance.now(); setTimeout(() => { let b = performance.now(); console.log(b - a); setTimeout(() => { let c = performance.now(); console.log(c - b); setTimeout(() => { let d = performance.now(); console.log(d - c); setTimeout(() => { let e = performance.now(); console.log(e - d); setTimeout(() => { let f = performance.now(); console.log(f - e); setTimeout(() => { let g = performance.now(); console.log(g - f); }, 0); }, 0); }, 0); }, 0); }, 0); }, 0);
在浏览器中的打印结果大概是这样的,和规范一致,第五次执行的时候延迟来到了 4ms 以上:
2. 非活动标签的超时
为了优化后台标签的加载损耗(以及降低耗电量),浏览器会在非活动标签中强制执行一个最小的超时延迟,如果一个页面正在使用网络音频 API 播放声音,也可以不执行该延迟。
这方面的具体情况与浏览器有关:
- Firefox 桌面版和 Chrome 针对不活动标签都有一个 1 秒的最小超时值
- 安卓版 Firefox 浏览器对不活动的标签有一个至少 15 分钟的超时,并可能完全卸载它们
3. 追踪型脚本的节流
Firefox 对它识别为追踪型脚本的脚本实施额外的节流,当在前台运行时,节流的最小延迟仍然是 4ms,然而,在后台标签中,节流的最小延迟是 10000 毫秒,即 10 秒,在文档首次加载后 30 秒开始生效。
4. 超时延迟
如果页面(或操作系统/浏览器)正忙于其他任务,超时也可能比预期的晚,需要注意的一个重要情况是,在调用 setTimeout() 的线程结束之前,函数或代码片段不能被执行。例如:
function foo() { console.log("foo 被调用"); } setTimeout(foo, 0); console.log("setTimeout 之后"); // 控制台输出: // setTimeout 之后 // foo 被调用
出现这个结果的原因是,尽管 setTimeout 以 0ms 的延迟来调用函数,但这个任务已经被放入了队列中并且等待下一次执行,并不是立即执行;队列中的等待函数被调用之前,当前代码必须全部运行完毕,因此这里运行结果并非预想的那样。
5. 在加载页面时推迟超时
当前标签页正在加载时,Firefox 将推迟触发 setTimeout() 计时器,直到主线程被认为是空闲的,类似于 window.requestIdleCallback(),或者直到加载事件触发完毕,才开始触发。
二、为什么定时器最小时延是4ms
我们首先要知道是不是存在具体的规范来指定了 4ms, 还是只是业界实践的既定事实?
1. HTML standard
在 HTML standard 8.6 Timers-2020/6/23 中对于 setTimeout() 有详细的描述,我们只看其中的 10-13 行:
If timeout is less than 0, then set timeout to 0.
If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.
Increment nesting level by one.
Let task's timer nesting level be nesting level.
从上面的规范可以看出来:
- 如果设置的 timeout 小于 0,则设置为 0
- 如果嵌套的层级超过了 5 层,并且 timeout 小于 4ms,则设置 timeout 为 4ms
到这里,我们似乎已经找到了 4ms 的出处,并且对于 setTimeout 的最小延迟有了更加精确的定义 - 需要同时满足嵌套层级超过 5 层,timeout 小于 4ms,才会设置 4ms
2. 浏览器源码
除了寻找规范的出处之外,还可以去浏览器的源码中寻找答案,我们进入到谷歌浏览器源码中来查找用来设置计时器延时的地方:
static const int maxIntervalForUserGestureForwarding = 1000; // One second matches Gecko. static const int maxTimerNestingLevel = 5; static const double oneMillisecond = 0.001; // Chromium uses a minimum timer interval of 4ms. We'd like to go // lower; however, there are poorly coded websites out there which do // create CPU-spinning loops. Using 4ms prevents the CPU from // spinning too busily and provides a balance between CPU spinning and // the smallest possible interval timer. static const double minimumInterval = 0.004;
double intervalMilliseconds = std::max(oneMillisecond, interval * oneMillisecond); if (intervalMilliseconds < minimumInterval && m_nestingLevel >= maxTimerNestingLevel) intervalMilliseconds = minimumInterval;
代码逻辑很清晰,设置了几个常量:
- maxTimerNestingLevel = 5,也就是 HTML standard 当中提到的嵌套层级
- minimumInterval = 0.004,也就是 HTML standard 当中说的最小延迟
在第二段代码中我们会看到,首先会在 延迟时间 和 1ms 之间取一个最大值,换句话说,在不满足嵌套层级的情况下,最小延迟时间设置为 1ms,这也解释了为什么在 chrome 中测试 setTimeout 是上面的结果。
在 chromium 的注释中,解释了为什么要设置 minimumInterval = 4ms。简单来讲,本身 chromium 团队想要设置更低的延迟时间(其实他们期望达到亚毫秒级别),但是由于某些网站对 setTimeout 这种计时器不良的使用,设置延迟过低会导致 CPU-spinning,因此 chromium 做了些测试,选定了 4ms 作为其 minimumInterval。
到这里为止,从浏览器厂商角度和 HTML standard 规范角度都解释了 4ms 的来源和其更加精确的定义。
三、如何实现立刻执行的定时器
假设我们就需要一个「立刻执行」的定时器呢?有什么办法绕过这个 4ms 的延迟吗,其实可以采用 postMessage 来实现真正 0 延迟的定时器:
(function () { var timeouts = []; var messageName = 'message-zeroTimeout'; // 保持 setTimeout 的形态,只接受单个函数的参数,延迟始终为 0。 function setZeroTimeout(fn) { timeouts.push(fn); window.postMessage(messageName, '*'); } function handleMessage(event) { if (event.source == window && event.data == messageName) { event.stopPropagation(); if (timeouts.length > 0) { var fn = timeouts.shift(); fn(); } } } window.addEventListener('message', handleMessage, true); // 把 API 添加到 window 对象上 window.setZeroTimeout = setZeroTimeout; })();
由于 postMessage 的回调函数的执行时机和 setTimeout 类似,都属于宏任务,所以可以简单利用 postMessage 和 addEventListener('message') 的消息通知组合,来实现模拟定时器的功能,再利用上面的嵌套定时器的例子来跑一下测试:
全部在 0.1 ~ 0.3 毫秒级别,而且不会随着嵌套层数的增多而增加延迟
1. 测试验证
从理论上来说,由于 postMessage 的实现没有被浏览器引擎限制速度,一定是比 setTimeout 要快的,这里用数据进行验证:分别用 postMessage 版定时器和传统定时器做一个递归执行计数函数的操作,看看同样计数到 100 分别需要花多少时间
function runtest() { let output = document.getElementById('output'); let outputText = document.createTextNode(''); output.appendChild(outputText); function printOutput(line) { outputText.data += line + '\n'; } let i = 0; let startTime = Date.now(); // 通过递归 setZeroTimeout 达到 100 计数 // 达到 100 后切换成 setTimeout 来实验 function test1() { if (++i == 100) { let endTime = Date.now(); printOutput( '100 iterations of setZeroTimeout took ' + (endTime - startTime) + ' milliseconds.' ); i = 0; startTime = Date.now(); setTimeout(test2, 0); } else { setZeroTimeout(test1); } } setZeroTimeout(test1); // 通过递归 setTimeout 达到 100 计数 function test2() { if (++i == 100) { let endTime = Date.now(); printOutput( '100 iterations of setTimeout(0) took ' + (endTime - startTime) + ' milliseconds.' ); } else { setTimeout(test2, 0); } } }
结论如下:
2. Performance 面板分析
我们打开 Performance 面板,看看更直观的可视化界面中,两个版本的定时器是如何分布的:
左边的 postMessage 版本的定时器分布非常密集,大概在 5ms 以内就执行完了所有的计数任务
而右边的 setTimeout 版本相比较下分布的就很稀疏了,而且通过上方的时间轴可以看出,前四次的执行间隔大概在 1ms 左右,到了第五次就拉开到 4ms 以上
3. 无延迟的定时器的作用
你可能会有疑问,什么应用场景下需要用到无延迟的定时器?其实在 React 的源码中,做时间切片的部分就用到了:
const channel = new MessageChannel(); const port = channel.port2; // 每次 port.postMessage() 调用就会添加一个宏任务 // 该宏任务为调用 scheduler.scheduleTask 方法 channel.port1.onmessage = scheduler.scheduleTask; const scheduler = { scheduleTask() { // 挑选一个任务并执行 const task = pickTask(); const continuousTask = task(); // 如果当前任务未完成,则在下个宏任务继续执行 if (continuousTask) { port.postMessage(null); } }, };
React 把任务切分成很多片段,这样就可以通过把任务交给 postMessage 的回调函数,来让浏览器主线程拿回控制权,进行一些更优先的渲染任务。
以上就是浅析JavaScript定时器setTimeout的时延问题的详细内容,更多关于JavaScript setTimeout时延的资料请关注脚本之家其它相关文章!