倒计时功能算是日常开发中非常常见的功能了,在各种电商秒杀活动、短信验证码登录等场景中随处可见,但是如何实现一个更加精准的倒计时呢?
简单实现一个定时器
要实现一个简单的倒计时非常简单,我们直接使用 setInterval 就能完成一个最基本的倒计时组件,代码如下:
let remain = 5; // 倒计时剩余时间
let ele = $('#timer');
clearInterval(timer);
showTime(remain);
timer = setInterval(countdown, 1000);
function countdown() {
if (remain === 0) {
clearInterval(timer);
return false;
}
remain--;
showTime(remain);
}
function showTime(time) {
const hour = Math.floor(time / 60 / 60 % 24);
const min = Math.floor(time / 60 % 60);
const sec = Math.floor(time % 60);
ele.text(hour + ":" + min + ":" + sec);
}

分析问题
上面的代码运行起来看起来很完美,但是这样的倒计时真的准确吗,让我们来做个试验,我们将在倒计时运行时模拟创建一个耗时 3 秒的任务,代码如下:
let remain = 50 * 1000; // 倒计时剩余时间 ms
let ele = $('#timer');
let delay = 10;
clearInterval(timer);
showTime(remain);
timer = setInterval(countdown, delay);
function countdown() {
if (remain === 0) {
clearInterval(timer);
return false;
}
remain-=delay;
showTime(remain);
}
function showTime(time) {
const hour = Math.floor(time / 1000 / 60 / 60 % 24).toString().padStart(2, '0');
const min = Math.floor(time / 1000 / 60 % 60).toString().padStart(2, '0');
const sec = Math.floor(time / 1000 % 60).toString().padStart(2, '0');
const msec = Math.floor(time % 1000).toString().padStart(2, '0');
ele.text(hour + ":" + min + ":" + sec + ":" + msec);
}
function hangTheBrowser() {
let i = 0;
let then = Date.now()
while (true) {
var now = Date.now()
if (now - then > 1000) {
if (i++ >= 3) {
break;
}
console.log(i)
then = now
}
}
}

上面的示例中可以看出,当我们点击按钮时,计时器实际上停止了,更令人担忧的是,它恢复的那一刻是从暂停的那一刻恢复的,从而错过了其它时间段,因此如果用上面那段计时器代码来处理一些关键性的倒计时场景时,结果肯定是不准确的。
造成上面结果的原因是因为 JavaScript 是单线程的,所谓单线程,就是指一次只能完成一个任务,如果有多个任务就必须要排队,前面的一个任务完成了,再执行后面的任务,以此类推。
JavaScript 在运行时,除了正在运行的主线程,还存在一个 task queue,即任务队列,里面是各种需要当前程序处理的异步任务(如setTimeout、setInterval、ajax 等,实际上,根据异步任务的类型,存在多个任务队列)。
因此在执行定时器的过程中有同步 ui 事件的代码,同步代码会立即执行,此时倒计时任务会被挂起,直至同步任务执行完毕。
setInterval or setTimeout ?
还有一个问题就是 setTimeout 和 setInterval 该如何选择?可能有些人会有疑问了,setTimeout 是延迟一定毫秒后执行,setInterval 是每隔一定毫秒后执行,倒计时当然是最适合用 setInterval 了。但是真相并不像这两句话一样简单。首先我们上面说了 javascript 是单线程执行的,所以以上这两个方法都是会被线程阻塞的。比如 setInterval 延迟设置为 1000,如果内部的执行是一个耗时超过 1 秒的操作,那么每次重复执行的时候会造成 2 个问题:
- 执行被阻塞,预期是 1000 毫秒执行一次,实际上必须在阻塞结束后才执行。
- 当使用
setInterval时,仅当没有该定时器的任何其他代码实例时,才将定时器代码添加到队列中。这确保了定时器代码加入到队列中的最小时间间隔为指定间隔。但是这种setInterval定时器的规则可能会出现跳过间隔或连续运行定时器代码的情况(具体可参考高程中关于高级定时器的介绍)。
第一点阻塞是不可避免的,这里的阻塞不仅仅有回调中的,还有浏览器中的方方面面的阻塞,比如用户的一些操作行为,其他定时器等外部的阻塞,所以这也就是无论我们如何做,页面开久了,定时器都会不准,或者说变慢的根本原因。
要解决第二个问题,最简单的方法便是使用链式 setTImeout 调用,使用一个递归来造成每隔多久执行一次的功能。这样做的好处是在前一个定时器代码执行完毕之前,不会向队列插入新的定时器代码,确保不会有任何缺失的隔离。而且它可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免了连续的运行。
解决方案
前面的例子中,我们可以看到阻塞恢复的那一刻是从暂停的那个时间点恢复的,我们是否可以通过一些其它的方式来解决这种偏差呢。
使用 Date 对象纠偏
这是最简单且有效的方法,只需要在每次倒计时函数里面通过 Date 日期对象来计算出每次的偏差,然后在下次倒计时启动时减掉这个偏差即可。代码如下:
let remain = 20 * 1000; // 倒计时剩余时间 ms
let ele = $('#timer');
let delay = 1000;
let start = +new Date();
clearInterval(timer);
showTime(remain);
timer = setTimeout(countdown, delay);
function countdown() {
if (remain === 0) {
clearTimeout(timer);
return false;
}
let current = +new Date();
let offset = current - start; // 计算每次偏差
remain -= offset;
start = current;
showTime(remain);
timer = setTimeout(countdown, delay);
}
function showTime(time) {
const hour = Math.floor(time / 1000 / 60 / 60 % 24).toString().padStart(2, '0');
const min = Math.floor(time / 1000 / 60 % 60).toString().padStart(2, '0');
const sec = Math.floor(time / 1000 % 60).toString().padStart(2, '0');
ele.text(hour + ":" + min + ":" + sec);
}
function hangTheBrowser() {
let i = 0;
let then = Date.now()
while (true) {
var now = Date.now()
if (now - then > 1000) {
if (i++ >= 3) {
break;
}
then = now
}
}
}

上面的图中可以看到的是,尽管倒计时由于阻塞函数的调用而冻结,但只要主线程释放,计时器就会在正确的时间恢复运行。这样就可以确保计时器始终保持相对准确,而与主线程上的耗时处理无关。
web worker
Web Worker 是HTML5标准的一部分,允许一段JavaScript程序运行在主线程之外的另外一个线程中。因此可以考虑在主线程之外再创建一个 worker 线程用来处理花费大量时间的任务,这样主线程的 UI 渲染就不会被阻塞了。代码如下:
// script.js
$(function() {
let worker = new Worker('lib/worker.js');
worker.addEventListener('message', function(e) {
console.log(e.data)
});
let remain = 50 * 1000; // 倒计时剩余时间 ms
let ele = $('#timer');
let delay = 1000;
let start = +new Date();
let timer;
showTime(remain);
timer = setTimeout(countdown, delay);
function countdown() {
let current = +new Date();
let offset = current - start;
remain -= offset;
start = current;
if (remain <= 0) {
clearTimeout(timer);
return false;
}
showTime(remain);
timer = setTimeout(countdown, delay);
}
function showTime(time) {
const hour = Math.floor(time / 1000 / 60 / 60 % 24).toString().padStart(2, '0');
const min = Math.floor(time / 1000 / 60 % 60).toString().padStart(2, '0');
const sec = Math.floor(time / 1000 % 60).toString().padStart(2, '0');
ele.text(hour + ":" + min + ":" + sec);
}
$('#hangMe').on('click', _ => {
worker.postMessage('hang');
});
})
// worker.js
self.addEventListener('message', function(e) {
console.log(e.data);
if(e.data === 'hang') {
let i = 0;
let then = Date.now()
while (true) {
var now = Date.now()
if (now - then > 1000) {
if (i++ >= 3) {
break;
}
then = now;
console.log(i);
}
}
}
// 与主线程通信
postMessage('我是work线程');
});

上面代码中我们可以看到,把耗时的任务放在了 webwoker 中处理,从而在进行倒计时的过程中不会受到影响。
结论
前端的倒计时误差是不可避免的,除了前面提到的可能造成的误差之外,还有网络传输、页面渲染等造成的误差,有必要的话做请求时间的分布分析,得出一个可用于当做标准量的时间来做差值。一般情况下前端显示的倒计时主要是给用户一个视觉上的提示,真正依赖的时间还是要依靠服务器的时间,我们能做的就是尽可能的减少这种误差。