场景分析
对于商城类的网站,我们经常可以见到秒杀、抢购的活动倒计时,对于我们前端来说,通常的就是会从服务端查出一个截止时间,然后我们计算出剩余时间,通过计时器进行每秒一次的倒计时,来改变页面上的显示时间,达到倒计时变化的效果。
通常来说,我们在这里的第一想法都是通过 setInterval 去实现。因为 setInterval 和这样的场景再合适不过了。如果说这个剩余时间比较短的话,还不会出现什么问题。一旦这个剩余时间比较大,并且用户在当前页面停留时间比较久的话,setInterval 倒计时就会执行很多次,那么我们就会发现页面上的倒计时和实际时间会有较大的偏差。
那么问题究竟出来哪里了呢?
计时器
JS 中最常用的计时器有两种,分别是 setInterval 和 setTimeout 。下面来分析一下两种计时器 setInterval 和 setTimeout 差别。
相同点
1、setTimeout 和 setInterval 都属于宏任务;
2、均在指定的时间间隔后,调用函数或计算表达式;
不同点
1、setTimeout 在指定的时间后执行一次就停止了;而 setInterval 是会一直循环的执行下去;
2、setInterval 每执行一次就会将对应的函数丢到任务队列中。若任务队列中存在同样的任务,也就是说上一次的任务尚未执行,则当前任务会被忽略掉,而不会丢进任务队列当中;
问题分析
由于 setTimeout 和 setInterval 都是将对应的函数丢进任务队列,而不是立即执行,所以函数实际执行的时间一定是大于指定的时间间隔的,而不可能等于该时间。
这里涉及到 JS 的代码执行顺序问题, JS 属于单线程,代码执行的时候首先是执行主线程的任务,也就是同步的代码,如果遇到了函数或者异步的代码块,并不会立即执行,而是丢进对应的任务队列中,任务队列是先进先出,待主线程的代码执行完毕以后,才会依次的执行任务队列中的函数。所以,在执行对应的计时器任务时,是指定的时间间隔 + 主线程执行时间 + 任务队列前面函数的执行时间,必然是大于指定的计时器时间的。
因此,对于 setInterval 来说,每次将函数丢进任务队列中,而每次函数的实际执行时间又都是大于指定的时间间隔的,一旦执行的次数多了,误差就会越来越大。
解决方案
上面已经分析过了,使用 setInterval 的问题是没有办法保证函数每次都在指定的时间间隔后去执行,所以如何调整函数的执行时间,使其能够在预期的时间间隔后执行,就成了问题的关键。
这里通常的方案是通过 setTimeout + 递归的思路去解决,通过递归实现和 setInterval 一样的无限循环执行的效果,通过 setTimeout 去实现指定时间间隔后执行代码的效果,但是这个时间间隔却不能是固定的,不然会有和 setInterval 类似的误差问题。这里需要在每次执行的时候,通过计算实际的执行时间和理论上应该执行的时间做对比,计算出误差,并减去对应的误差,使得实际的实行时间和理论的执行时间保持一致。
具体实现
// 指定的时间间隔
const interval = 1000;
// 倒计时剩余的时间(毫秒)
let ms = 1000000;
// 执行次数
let count = 0;
// 记录开始时的时间戳
const start = new Date().getTime();
// 用于计时器赋值,便于清空
let timeCounter;
// 首次执行
if (ms >= 0) {
timeCounter = setTimeout(countDownStart, interval);
}
function countDownStart() {
// 每次执行函数,count进行累加,便于计算时间间隔
count++;
// 计算误差 = 当前实际执行函数的时间 - 理论上应该执行的时间
const offset =
new Date().getTime() - (start + count * interval);
// 那么下一次 setTimeout 的执行时间 = 指定的时间间隔 - 误差时间
const next = interval - offset;
if (next < 0) {
next = 0;
}
// 计算倒计时剩余的时间
ms -= interval;
console.log(
`误差:${offset} ms,下一次执行:${next} ms 后,离活动开始还有:${ms} ms`
);
if (ms < 0) {
// 如果倒计时结束,则清空计时器
clearTimeout(timeCounter);
} else {
// 如果倒计时未结束,则进行递归继续倒计时
timeCounter = setTimeout(countDownStart, next);
}
}
奇怪的知识点
由于 JS 代码的执行顺序是主线程 -> 微任务 -> 宏任务,而 setTimeout 和 setInterval 都属于宏任务,所以如果我们想要某一段代码的执行顺序放到最后,那么我们就可以通过 setTimeout 包裹该代码段即可实现。
console.log(1)
setTimeout(() => {
console.log(2)
})
console.log(3)
结果: 1 3 2
当 setTimeout 的延迟时间没有赋值时,就会默认取到 0 ,0 就意味着立即执行,没有任何的延迟。但是 setTimeout 内部的代码段是属于宏任务,所以依然会等主线程先执行完,才会执行内部的代码段。