setInterval推入任务队列后的时间不准确
定时器代码:
setInterval(fn(), N);
上面这句代码的意思其实是fn()将会在N秒之后被推入任务队列。
所以,在setInterval被推入任务队列时,如果在它前面有很多任务或者某个任务等待时间较长比如网络请求等,那么这个定时器的执行时间和我们预定它执行的时间可能并不一致。
比如:
const startTime = new Date().getTime();
let count = 0;
// 耗时任务
setInterval(() => {
let i = 0;
while (i++ < 1000000000);
}, 0);
setInterval(() => {
count++;
console.log(
'与原设定的间隔时差了:',
new Date().getTime() - (startTime + count * 1000),
'毫秒',
);
}, 1000);
// 输出:
// 与原设定的间隔时差了:699 毫秒
// 与原设定的间隔时差了:771 毫秒
// 与原设定的间隔时差了:887 毫秒
// 与原设定的间隔时差了:981 毫秒
// 与原设定的间隔时差了:1142 毫秒
// 与原设定的间隔时差了:1822 毫秒
// 与原设定的间隔时差了:1891 毫秒
// 与原设定的间隔时差了:2001 毫秒
// 与原设定的间隔时差了:2748 毫秒
// ...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
可以看出来,相差的时间是越来越大的,越来越不准确。
# 函数操作耗时过长导致的不准确
考虑极端情况,假如定时器里面的代码需要进行大量的计算(耗费时间较长),或者是DOM操作。这样一来,花的时间就比较长,有可能前一次代码还没有执行完,后一次代码就被添加到队列了。也会到时定时器变得不准确,甚至出现同一时间执行两次的情况。
最常见的出现的就是,当我们需要使用ajax轮询服务器是否有新数据时,必定会有一些人会使用setInterval,然而无论网络状况如何,它都会去一遍又一遍的发送请求,最后的间隔时间可能和原定的时间有很大的出入。
// 做一个网络轮询,每一秒查询一次数据。
const startTime = new Date().getTime();
let count = 0;
setInterval(() => {
let i = 0;
while (i++ < 10000000); // 假设的网络延迟
count++;
console.log(
'与原设定的间隔时差了:',
new Date().getTime() - (startTime + count * 1000),
'毫秒',
);
}, 1000);
// 输出:
// 与原设定的间隔时差了:567 毫秒
// 与原设定的间隔时差了:552 毫秒
// 与原设定的间隔时差了:563 毫秒
// 与原设定的间隔时差了:554 毫秒(2次)
// 与原设定的间隔时差了:564 毫秒
// 与原设定的间隔时差了:602 毫秒
// 与原设定的间隔时差了:573 毫秒
// 与原设定的间隔时差了:633 毫秒
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# setInterval缺点与setTimeout的不同
再次强调,定时器指定的时间间隔,表示的是何时将定时器的代码添加到消息队列,而不是何时执行代码。所以真正何时执行代码的时间是不能保证的,取决于何时被主线程的事件循环取到,并执行。
setInterval(fn(), N);
// 即:每隔N秒把fn事件推到消息队列中
2

上图可见,setInterval每隔100ms往队列中添加一个事件;100ms后,添加T1定时器代码至队列中,主线程中还有任务在执行,所以等待,some event执行结束后执行T1定时器代码;又过了100ms,T2定时器被添加到队列中,主线程还在执行T1代码,所以等待;又过了100ms,理论上又要往队列里推一个定时器代码,但由于此时T2还在队列中,所以T3不会被添加(T3被跳过),结果就是此时被跳过;这里我们可以看到,T1定时器执行结束后马上执行了T2代码,所以并没有达到定时器的效果。
综上所述,setInterval有两个缺点:
- 使用
setInterval时,某些间隔会被跳过。 - 可能多个定时器会连续执行。
可以这么理解:每个setTimeout产生的任务会直接push到任务队列中;而setInterval在每次把任务push到任务队列前,都要进行一下判断(看上次的任务是否仍在队列中,如果有则不添加,没有则添加)。
因而我们一般用setTimeout模拟setInterval,来规避掉上面的缺点。
来看一个经典的例子来说明他们的不同:
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
2
3
4
5
做过的朋友都知道:是一次输出了5个5。那么问题来了:是每隔1秒输出一个5?还是一秒后立即输出5个5?答案是:一秒后立即输出5个5,因为for循环了五次,所以setTimeout被5次添加到时间循环中,等待一秒后全部执行。
为什么是一秒后输出了5个5呢?简单来说,因为for是主线程代码,先执行完了,才轮到执行setTimeout。
# setTimeout模拟setInterval
综上所述,在某些情况下,setInterval缺点是很明显的,为了解决这些弊端,可以使用setTimeout代替。
- 在前一个定时器执行完前,不会向队列插入新的定时器(解决缺点一)。
- 保证定时器间隔(解决缺点二)。
具体实现如下:
// 1. 写一个 interval 方法
let timer = null;
function interval(func, wait) {
const interv = function () {
func.call(null);
timer = setTimeout(interv, wait);
};
timer = setTimeout(interv, wait);
}
// 2. 和 `setInterval()` 一样使用它
interval(() => {}, 20);
// 3. 终止定时器
if (timer) {
window.clearSetTimeout(timer);
timer = null;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
JS中的计时器无法做到精确计时:
- 计算机硬件没有原子钟,无法做到精确计时。
- 操作系统的计时函数本身也有少量偏差,由于JS的计时器最终调用的是操作系统的函数,也就携带了一些偏差。
- 按照W3C的标准,浏览器实现计时器时,如果嵌套层级超过了5层,则最少是4ms的延迟。
- 受事件循环的影响,计时器的回调函数只能在浏览器渲染主线程空闲时运行,因此带来了偏差。