倒计时时间偏差问题解析

2,043 阅读4分钟

场景分析

对于商城类的网站,我们经常可以见到秒杀、抢购的活动倒计时,对于我们前端来说,通常的就是会从服务端查出一个截止时间,然后我们计算出剩余时间,通过计时器进行每秒一次的倒计时,来改变页面上的显示时间,达到倒计时变化的效果。

image.png

通常来说,我们在这里的第一想法都是通过 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 内部的代码段是属于宏任务,所以依然会等主线程先执行完,才会执行内部的代码段。