背景介绍
setTimeout是一个宏任务,是由浏览器的定时器线程负责计数,其最小的时间间隔为4ms,会被eventLoop阻塞导致执行时间不符合预期,setTimeout可以模拟setInterval,如果用于驱动动画的话,标签被最小化或隐藏后,再度被激活时动画会出现加速的现象。
这是我之前对于setTimeout的全部认知,直到一次做秒级刷新的大屏展示的项目,在发版之后用户提了一个问题,当页面被最小化时,数据同步出现了问题。
我当时的第一反应是这压根不是代码BUG,因为一个展示大屏被最小化就是一个很不合理的操作,但是静下心来就写了段测试代码,紧接着测试结果完全颠覆了我对setTimeout的认知。
于是,打算借此机会对setTimeout做一次彻底的学习了解,接下来就让我们一起一层层的揭开setTimeout不为人知的面纱吧。
说明:影响
setTimeout延迟的原因有很多,这里不考虑由于单个event loop延迟导致setTimeout延迟增加,简化到最原始情况。
setTimeout的返回值
有没有人好奇,Chrome控制台中输入setTimeout时打印的这个数字,到底是什么?
其实,这个就是setTimeout的返回值,是一个不重复的数字,可以通过这个id来取消setTimeout的执行。
The returned timeoutID is a positive integer value which identifies the timer created by the call to setTimeout(). This value can be passed to clearTimeout() to cancel the timeout.
It is guaranteed that a timeoutID value will never be reused by a subsequent call to setTimeout() or setInterval() on the same object (a window or a worker). However, different objects use separate pools of IDs.
通过MDN的描述,我们可以得知setTimeout()和setInterval()共用一个编号池,技术上,clearTimeout() 和 clearInterval() 可以互换。但是,为了避免混淆,不要混用取消定时函数。
并且,在同一个对象上(一个window或者worker),setTimeout() 或者 setInterval()在后续的调用不会重用同一个定时器编号。但是不同的对象使用独立的编号池。
const id = setTimeout(() => {
console.log('不会执行')
}, 1000)
clearInterval(id) // 是可以取消上面setTimeout执行的,但不建议这么用
setTimeout最大延迟时间
setTimeout的最小延迟时间4ms应该是大家烂熟于心的知识点了,但不知道大家是否知道setTimeout是存在最大延迟时间的。
让我们看下MDN上关于setTimeout最大值的描述:
Maximum delay value
Browsers including Internet Explorer, Chrome, Safari, and Firefox store the delay as a 32-bit signed integer internally. This causes an integer overflow when using delays larger than 2,147,483,647 ms (about 24.8 days), resulting in the timeout being executed immediately.
大概意思是,包括 IE, Chrome, Safari, Firefox 在内的浏览器其内部以32位带符号整数存储延时。这就会导致如果一个延时(delay)大于 2147483647 毫秒 (大约24.8 天)时就会溢出,导致定时器会被立即执行。
setTimeout(() => {
console.log('Right now!')
}, 2147483647 + 1)
这段代码因为超出了最大延迟时间,会被立即执行。
setTimeout最小延迟时间
这应该是大家最熟悉的知识点了,因为这几乎是面试必问的问题,但是你真的了解最小延迟时间吗?
最小延迟时间真的是4ms吗?
让我们做个实验,代码如下
const a = performance.now()
let i = 1
setTimeout(() => {
const b = performance.now()
console.log(`循环次数${i}:`, b - a)
i++
setTimeout(() => {
const c = performance.now()
console.log(`循环次数${i}:`,c - b)
i++
setTimeout(() => {
const d = performance.now()
console.log(`循环次数${i}:`, d - c)
i++
setTimeout(() => {
const e = performance.now()
console.log(`循环次数${i}:`, e - d)
i++
setTimeout(() => {
const f = performance.now()
console.log(`循环次数${i}:`, f - e)
i++
setTimeout(() => {
const g = performance.now()
console.log(`循环次数${i}:`, g - f)
i++
}, 0)
}, 0)
}, 0)
}, 0)
}, 0)
}, 0)
输出结果:
我们可以看到这个执行结果,前4次的延迟时长都小于了4ms,但从第5次开始,延迟时间明显增加了(>4ms),似乎4ms的最小延迟时间无法完全解释这次实验的结果,这又是为什么呢?
4ms最小延迟时间的出处
俗话说,遇事不决查文档。
我们可以看到在2022年2月11日更新的HTML Standard中关于最小延迟时间的相关描述:
计时器被嵌套5层之后,时间间隔被强制设置为最少4ms;
如果timeout小于0,则设置为0;
如果嵌套层级大于5,并且timeout小于4,则设置为4.
可以看到,文档很清楚的写着嵌套5层且延迟时间小于4ms,时间间隔会被强制设为4ms,这也解释了为什么上面的实验结果前4次都是小于4ms,而第4次之后就都变成了大于4ms,关于4ms的传说应该也是来源于此。
但是,文档也很清楚的标明了,4ms的最小限制只是在定时器被嵌套使用且超过5层时才会强制设置,但标准虽然如此,各家浏览器厂商实现的时候却未必会完全遵循,上述的例子,就是在第5层时就强制为4ms了。
区别:标准为
>5层嵌套,Chrome为>=5层嵌套。
不过,这时又有了新的疑问。
非嵌套或嵌套小于5层时,定时器的最小延迟到底是多少?
解答这个问题之前,我们先看一个测试用例:
const fn = () => {
setTimeout(() => {
console.log('first')
}, 1)
setTimeout(() => {
console.log('second')
}, 0)
}
fn()
谁会先输出呢?请思考5s。
输出结果:
为什么1ms的延迟反而比0ms的延迟先执行呢?那如果颠倒一下顺序又如何呢?
const fn = () => {
setTimeout(() => {
console.log('second')
}, 0)
setTimeout(() => {
console.log('first')
}, 1)
}
fn()
输出结果:
结果改变了,看来延迟时间 0ms 和 1ms 的输出顺序与执行顺序有关,现在试图分析一下,0ms 和 1ms 因为都小于或等于最小执行时间,所以最后都按照相同的执行时间来计时,所以最终的输出顺序与执行顺序一致。
这时候,我们来试图猜测一下最小执行时间是多少?已知,4ms只是嵌套且大于5层时生效,而最初的测试也证明了最小延迟可以 < 4ms(接近 1ms),那么大胆的猜测一下Chrome最小延迟时间是1ms(仅对Chrome而言),接下来做个测试便知。
const fn = () => {
setTimeout(() => {
console.log('second')
}, 2)
setTimeout(() => {
console.log('first')
}, 1)
}
fn()
输出结果:
似乎和猜测结果的一致,再参考为什么 setTimeout 有最小时延 4ms ? 这位大佬在文章中列出的chromium源码加以佐证,答案也已经呼之欲出了。
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 中测试 0ms 和 1ms 是上面的输出结果。
推荐:希望大家都可以去看看为什么 setTimeout 有最小时延 4ms ? 这篇文章,里面不仅详细的阐述了setTimeout的最小值,并且还讲述了延迟时间的更新史和浏览器厂商与系统平台、HTML规范之间的博弈与权衡的策略,很值得一看。
实现0延迟的setTimeout
知道了最小延迟时间之后,有没有想过如何实现一个真正的0延迟setTimeout呢?
setTimeout文档中也有解答:
使用window.postMessage()方法来实现。
(function () {
var timeouts = [];
var messageName = 'zero-timeout-message';
// 保持 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延迟计时器,来测试一下第一个用例的嵌套结果。
const a = performance.now()
let i = 1
window.setZeroTimeout(() => {
const b = performance.now()
console.log(`循环次数${i}:`, b - a)
i++
window.setZeroTimeout(() => {
const c = performance.now()
console.log(`循环次数${i}:`,c - b)
i++
window.setZeroTimeout(() => {
const d = performance.now()
console.log(`循环次数${i}:`, d - c)
i++
window.setZeroTimeout(() => {
const e = performance.now()
console.log(`循环次数${i}:`, e - d)
i++
window.setZeroTimeout(() => {
const f = performance.now()
console.log(`循环次数${i}:`, f - e)
i++
window.setZeroTimeout(() => {
const g = performance.now()
console.log(`循环次数${i}:`, g - f)
i++
}, 0)
}, 0)
}, 0)
}, 0)
}, 0)
}, 0)
输出结果:
可以看到结果全部 <1ms,而且不会随着嵌套层数的增多而增加延迟。
似乎我们已经实现了一个比setTimeout要更加即时的定时器了,这个计时器的优势在于大量频繁且间隔时间接近最小延迟时间的调用计时器(尤其是嵌套调用时),0延迟的计时器的效率会十分明显,几乎是百倍级setTimeout。
未被激活的标签页中setTimeout延迟机制
现在,让我们回到文章开头中用户提出的问题,为什么页面最小化时,setTimeout驱动的数据请求出现了问题?
在此之前,我知道一些关于setTimeout在未激活的标签页中是会出现延迟时间变慢的问题,但从没具体了解过。
重新看了文档之后,发现了以下的这段描述:
根据文档中的描述,在未被激活的tabs标签中,最小延迟时间被强制为 >=1000ms(即1s)。
可是,我们的秒级大屏更新时间也是 <= 1000ms(因为setTimeout在实际应用中会被eventLoop阻塞,每次更新时延迟时间 = 1s - 阻塞时间,否则阻塞时间会不断积攒),如果是这样的话,数据同步不会出现大范围的滞后。
让我们继续测试一下。
let lastTime = new Date().getTime()
const fn = () => {
const currentTime = new Date().getTime()
const diffTime = currentTime - lastTime
lastTime = currentTime
setTimeout(fn, 500)
console.log('时间间隔:', `${diffTime}ms`)
}
setTimeout(fn, 500)
我们设计了一个 500ms 执行一次的定时函数,然后将页面最小化,让其处在未激活状态,一段时间后延迟时间会强制为 1s。
输出结果:
可以看到,大抵与文档说明一致,不过,让我们将页面最小化的再久一些。
输出结果:
我们可以看到,延迟时间从 1s 徒然增大到 1min 这又是为什么呢?
setTimeout的时间预算
这个答案,我在MDN上的页面可见性 API一文中找到了答案。
我们可以在这段文档中反复看到一个关键词Budget(预算),文档总结如下:
为了解决隐藏标签中的定时器对性能的影响,浏览器在标签未激活时采取了如下策略:
-
不会调用
requestAnimationFrame()中定义的回调函数,以提升电池的使用寿命; -
setTimeout延迟会比指定的延迟更久; -
未被激活的选项卡每个窗口都有自己的时间预算,
Firefox(以ms为单位),Chrome(以s为单位); -
Firefox窗口在30s后受到限制,而Chrome则是10s后受到限制; -
计时器执行完成,所消耗的执行时间会从其所在窗口的时间预算中减去;
-
时间预算
>=0时才允许使用计时器任务; -
时间预算消耗殆尽之后,
Firefox和Chrome中,预算会以10ms / s的速度重新生成,直到 时间预算>=0时,会再次执行计时器。
未激活状态下的时间预算是多少?
这个没有在文档中查找到,如果哪位小伙伴有相关的文档资料欢迎告知。
但是,我们依然可以通过测试的方法试探出这个预算的大概范围。
这是上面的测试代码强制 1s 延迟时的执行结果的截图:
执行了300次,所以Chrome的时间预算大概就是 300s 也就是 5min 的预算。
如果解决未激活标签页中延迟时间不统一的问题呢?
MDN的文档中,已经给出了解决方案。
总结如下:
-
正在播放音频的选项卡被视为激活状态,所以不受影响;
-
使用
WebSocket和WebRTC的选项卡不受影响; -
IndexedDB进程也不受影响。
所以,综上所述,实现一个秒级大屏的最佳方案应该是 WebSocket 的推的方式,而非 setTimeout 这种拉的方式,当然这需要后台的支持。
但,如果像我们这个项目只能采取 setTimeout 来获取数据的话,那最佳方案应该是:
-
在页面未激活时调用
visibilitychange事件来停止setTimeout的数据请求,并记录下最后一次请求的时间戳 +1s; -
在页面被激活时再次调用
visibilitychange事件,通过另外的接口直接获取未激活触发时所记录的时间戳到当前时间的全部数据; -
在数据返回之后,重新开启秒级请求的定时器。