前端倒计时实现

前端的倒计时实现。

首先我们要明确,客户端的系统时间不一定是正确的,是可能有较大误差的,甚至根本就是错的。服务端如果仅仅只是返回一个结束时间戳,那是毫无意义的,因为我们无法保证客户端和服务端的时钟是同步的。一般来说,至少要返回一个有效时间长度。

可变的网络延迟

客户端发送 http 请求到服务端,然后服务端返回有效期时长给客户端。这个数据传输过程中就花费了时间,导致客户端最终拿到返回的有效期时长是比真实的要大。正常情况下,这个时间只有几十毫秒,误差不是很大。但在移动网络这些网络状态不好的条件下,可能网络延迟会到达几秒。

对于这种情况,有一个方案可以减少一定的误差,就是取 http 请求时间(http_time)的一半作为校准值,对返回的服务器时间进行修正:server_time - http_time / 2。当然这里是有一个前提的,就是假设客户端到服务端和服务端到客户端的数据传输时间大致相同,即假定网络延时是对称的(实际上并不会这么理想)。

如果业务上对倒计时的准确度要求不高,可以不做传输时延修正处理。

定时器的准确度问题

从服务端拿到的有效时长之后,前端就进行倒计时了。很快我们会想到使用 setTimeout()setInterval() 设置定时任务,每隔一秒将有效时间减一(后面都假设倒计时长为一秒),并修改网页上的显示。直到为 0 后,停止继续执行定时任务。

但是定时器并不保证在准确的延迟内触发,定时器的延迟 dealy 参数只是指定了加入 Event Loop 的最小延迟时间。在 Event Loop 中,定时器消息必须等待队列中的其它消息处理完后才能执行。

所以,如果使用定时器设置每经过一秒就将有效时间减一,可能会因为有其他消息要执行导致定时器的函数执行并不能准确执行,而有着随着时间越来越长,时间的误差越来越大的风险。

方案一:每次都修正延迟时间

为了解决这个问题,有一个方案是,计算间隔两个定时任务的真实时间差。接着用这个真实时间差和我们设置的延迟进行对比校正,设置为下一次的延迟。这个举个例子。比如我们设置时间延迟为 1 s,但是某一次真正执行的时间是在 1.1 s 后,那么我们就设置在 0.9 s 后执行下一次定时任务。这里简单写一个代码实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const start = +new Date();
let validTime = 10;
const delayStep = 1000;
let realDelay = delayStep;
let prev = start;

function cd() {
  setTimeout(() => {
    const now = +new Date();
    validTime -= 1;
    if (validTime <= 0) { // 结束倒计时
      validTime = 0;
      return;
    }
    // 修正 realdelay
    const diff = now - prev - delayStep; // 误差
    realDelay = delayStep - diff;
    prev = now;

    console.log({ diff, validTime, realDelay });
    cd();
  }, realDelay);
}

cd();

不过这种实现没有考虑到页面的休眠问题。比如手机熄屏后,计时器会进入休眠状态,不再计时。对此,我们可以先计算出结束时间戳,然后和当前时间最对比,这样就可以避免页面休眠的问题。下面是方案一改良版实现代码:

演示地址:https://codepen.io/F-star/pen/abOMxeJ?editors=1010

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
let end;
const el = document.querySelector('#count');
const delayElement = document.querySelector('#delay');
function countdown() {
  const now = +new Date();
  const diff = end - now; // 毫秒差
  count = Math.round(diff / 1000);
  if (count <= 0) return;
  el.innerText = count;
  let delay = ((end - count * 1000) - now + 1000) % 1000;
  if (delay == 0) delay = 1000;
  // console.log(delay)
  delayElement.innerText = delay;
  setTimeout(() => {
    this.countdown();
  }, delay)
}

end = +new Date() + 60000;
countdown();

考虑到倒计时在项目中多处使用到,比如验证码倒计时、支付倒计时,有必要抽离一个 CountDown 类,提高代码的复用性和可维护性。

演示地址:https://codepen.io/F-star/pen/BaoabmP?editors=0010

 1
 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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
 * 倒计时类
 */
class CountDown {
  constructor() {
    this.endTime = 0
    this.count = 0
    this.timer = undefined
    this.close = false
    this.handler = () => { }
    this.endHandler = () => { }
  }
  // 设置持续时间
  setDuration(t) {
    this.endTime = new Date().getTime() + t
  }
  setEndTime(endTime) {
    this.endTime = endTime
  }
  bindHandler(fn) {
    this.handler = fn
  }
  // 考虑去掉,可以用 count 为 0 来判断
  bindEndHandler(fn) {
    this.endHandler = fn
  }
  start() {
    this._countdown()
  }
  cancel() {
    this.close = true
  }
  // 私有方法
  _countdown() {
    if (this.close) return
    const now = new Date().getTime()
    const diff = this.endTime - now // 毫秒差
    this.count = Math.round(diff / 1000)
    if (this.count <= 0) this.count = 0

    this.handler(this.count) // count 为 0,也会执行该响应函数
    if (this.count == 0) {
      this.endHandler()
      return
    }

    let delay = ((this.endTime - this.count * 1000) - now + 1000) % 1000
    if (delay == 0) delay = 1000
    this.timer = setTimeout(() => {
      this._countdown()
    }, delay)
  }
}

// 使用示例
const cd = new CountDown()
cd.setDuration(6000)
cd.bindHandler(count => {
  console.log(count)
  // if (count == 5) cd.cancel()
})
cd.bindEndHandler(() => {
  console.log('结束倒计时')
})
cd.start()

方案二

还有另一种解决方案,就是给出一个结束时间戳,然后定时器间隔很短时间 t(比如 100 ms)执行,不断计算剩余的秒数。缺点是会有 t 时间的误差。优点是实现简单。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const start = +new Date();
let validTime = 10;
const end = start + validTime * 1000;

function cd() {
  setTimeout(() => {
    const t = Math.floor((end - new Date()) / 1000);
    console.log(t)
    if (t <= 0) {
      t = 0;
      return
    }
    cd();
  }, 200);
}

cd();

缺点是 1 s 内执行了多次。优点是实现更简单。

客户端在倒计时期间修改系统时间

方案一改良版和方案二因为预先计算出了客户端的结束时间戳,因此如果客户端系统时间进行了修改,必然会导致倒计时出错。对此,我们需要做一些处理。

当发现相隔两次触发的计时任务的间隔本应该为 1s 左右,结果却发生了较严重的超时,比如 2 秒以上。原因就可能有:

  1. 客户端系统时间被修改
  2. 页面进行了休眠
  3. 其他的耗时的异步操作阻塞了计时任务

首先我们一般可以排除 3,其根本问题是页面卡顿,通过修改倒计时代码是无法解决的。对于 1,我们可以根据系统修改前和修改后两个定时触发时间的时间差减去 1 s,对结束时间进行修正。此外还有个更简单的方案,重新请求服务器,获取正确的结束时间替换掉原来的结束时间。

但是,我们无法肯定超时不是原因 2 所导致的。所以,最终我们选择的解决方式是:重新请求服务端,更新结束时间