定时器不准确的情景
定时器,相信每个写前端的小伙伴都用过,分别是在window对象上的setInterval()
方法和setTimeout()
方法,前者是重复调用一个函数或执行一个代码片段,在每次调用之间具有固定的时间间隔。后者则是,一旦定时器到期,就会执行一个函数或指定的代码片段。
一个非常好用的api,但是在一些场景下,他们也并不是完全靠谱的。
1 delay参数超过最大值
参数 delay
会被转换成一个有符号 32 位整数。也就是2的31次方 ,这也就将delay
限制到了 2147483647 毫秒(大约 24.8 天)以内。
但是,在js中number类型的数字采用64位浮点格式表示,除去符号位和标识指数的位数,有效存储数字的位数有52位,我们可以通过Number.MAX_VALUE
查看number类型的最大安全值(1.7976931348623157e+308),也就是在正负2的53次方之间(-9007199254740992 -> +9007199254740992),如果我们将定时器的 延迟时间设置为超过2147483647
的数字,定时器会立刻执行。
setInterval(() => {
console.log('我会立刻执行');
}, 2147483648);
setInterval(() => {
console.log('我会每间隔2147483647毫秒执行一次');
}, 2147483647);
同理setTimeout()
方法也是一样的
setTimeout(() => {
console.log('我会立刻执行');
}, 2147483648);
setTimeout(() => {
console.log('我会在2147483647毫秒后执行');
}, 2147483647);
2. 多层嵌套
一旦对 setTimeout
的嵌套调用被安排了 5 次,浏览器将强制执行 4 毫秒的最小超时。什么意思,我们直接来看代码
let n = 0;
const fn = () => {
n != 0 && console.log('setTimeout', Date.now());
// 嵌套开启10个定时器
if (n < 10) {
n++;
setTimeout(fn, 0);
}
};
fn();
我们发现,我们定义的delay
为0,但是只有前四次是同时执行的,后面的相隔了最小4毫秒
。
3. 非活动标签
为了优化后台标签的加载损耗(以及降低耗电量),浏览器会在非活动标签中强制执行一个最小的超时延迟。
简单来说就是,如果你的一个窗口开启了定时器,当你切换到其它窗口,你的定时器执行频率会被改变,甚至定时器失效,下面我们来实验下:
let now = Date.now();
setInterval(() => {
console.log('setInterval', Date.now() - now);
now = Date.now();
}, 200);
window.addEventListener('visibilitychange', () => {
document.hidden ? console.log('窗口隐藏了') : console.log('窗口显示了');
});
我们可以看到,我们设置的执行时间是200毫秒
,当我们的窗口隐藏后在,定时器的执行频率居然变成了1秒
,这是setInterval
的结果,那么setTimeout
呢?
let now = Date.now();
for (let i = 0; i < 10; i++) {
setTimeout(() => {
console.log('setTimeout', Date.now() - now);
now = Date.now();
}, 200 * i);
}
window.addEventListener('visibilitychange', () => {
if (document.hidden) {
now = Date.now();
console.log('窗口隐藏了');
for (let i = 0; i < 10; i++) {
setTimeout(() => {
console.log('setTimeout', Date.now() - now);
now = Date.now();
}, 200 * i);
}
} else {
console.log('窗口显示了')
}
});
我们可以发现,setTimeout
方法也有同样的问题。
如何解决
例如,我们现在要做一个页面倒计时的功能,这里提供两种思路:
1. 当用户离开页面时记录下离开的时间并关闭定时器,当用户重新回来后计算倒计时的开始时间并重新开始倒计时
let timer;
let duration = 60
function run () {
timer = setInterval(() => {
time.innerText = duration--
}, 1000)
}
// 记录页面隐藏的时间
let leaveTime = undefined;
window.addEventListener('visibilitychange', () => {
// 监听页面隐藏,关闭定时器,记录时间
if (document.hidden) {
clearInterval(timer)
leaveTime = Date.now();
} else {
// 监听页面显示,开启定时器,
duration -= Math.floor((Date.now() - leaveTime) / 1000)
time.innerText = duration
run()
}
});
run()
2. 使用web Worker
Web Worker 为 Web 内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。
const worker = new Worker('./webWork.js');
// 通知开启60秒的倒计时
worker.postMessage(60)
// 接收倒计时秒数
worker.onmessage = function (e) {
time.innerText = e.data
}
webWork.js
let intervalId = null;
let duration = 0;
addEventListener('message', (event) => {
// 如果传入的倒计时秒数不为0,则开启定时器
if (event.data !== 0) {
duration = event.data;
postMessage(duration)
intervalId = setInterval(function () {
postMessage(--duration);
}, 1000);
} else if (event.data === 0) {
duration = 0
clearInterval(intervalId);
intervalId = null;
}
})
4. 超时延迟
setTimeout属于宏任务,在调用 setTimeout()的线程结束之前,函数或代码片段不能被执行。
setInterval(() => {
console.log('setTimeout', Date.now());
})
for (let i = 0; i < 10; i++){
console.log(i)
}
console.log('同步代码', Date.now());
尽管 setTimeout 以零延迟来调用函数,但这个任务已经被放入了队列中并且等待下一次执行,并不是立即执行。
5. API不存在
在 WebExtension中(浏览器扩展),setTimeout() 不会可靠工作。扩展开发者应当使用 alarms
API 作为替代。