如何实现一个精准倒计时

518 阅读4分钟

前言

setTimeout会坑到每一个不懂它原理的人,或早或晚——沃兹基硕德。

作为实现定时任务的函数,setTimeout是每位前端入行时认识的基础功能之一了。它的使用或轻于鸿毛,被用在轮播图切换等地方,岁月静好,或重于泰山,被用在hack竞态问题,岌岌可危;还有一个被经常使用,但要么不出问题,要么一出就是大问题的场景——倒计时。用它做短信获取倒计时的同学表示没有问题,用它做需要长时间显示倒计时场景的同学可就没有那么幸运了,一般会碰到计时不准的问题,要么越来越慢,要么切换到其他标签页,切回来时发现时间静止了。

关于原因就不从盘古开天辟地开始讲了,关键词事件循环机制。而文章的起因是刚解决了一个bug,姑且记录一下。

问题表现

具体功能是一个长时间的倒计时,倒计时的周期可以是几天,用户有可能长时间停留在页面。bug的体现就是倒计时时长不准,比如刷新页面以后会发现新显示的倒计时和当前的差距很大。简化后的代码如下:

function countDown(endTime, nowTime) {
  if (nowTime >= endTime) {
    // 停止显示倒计时
    return
  }

  const duration = endTime - nowTime
  // 根据duration计算天时分秒

  setTimeout(() => {
    countdown(endTime, nowTime + 1000)
  }, 1000)
}

// endTime是未来某天的时间戳
countDown(endTime, Date.now())

问题分析

可以看出,这种写法除了存在由于事件循环机制导致的延迟执行问题外,至少还包括这两个问题:

    1. 使用了本地时间,有可能不是那么可靠
    1. 每次执行函数都需要把endTime传入,再重复计算duration,造成资源浪费

第一个问题很好解决,从服务端获取时间即可,第二个问题稍微改一下写法:

function countDown(endTime, nowTime) {
  let duration = endTime - nowTime
  if (duration <= 0) {
    // 停止显示倒计时
    return
  }

  // 根据duration计算天时分秒
  
  setTimeout(doCountDown, 1000)
  
  function doCountDown() {
    duration -= 1000
    if (duration <= 0) {
      // 停止显示倒计时
      return
    }
    
    // 根据duration计算天时分秒
    
    setTimeout(doCountDown, 1000)
  }
}

// endTime是未来某天的时间戳,serverTime是从服务器获取的当前时间
countDown(endTime, serverTime)

剩下就是解决最关键的时长显示不准问题了。

问题解决

思路是在倒计时开始时记录一个开始时间,每次执行倒计时函数时,计算当前时间和开始时间的实际间隔与理论上的时间间隔的差值,在下次执行倒计时函数时,提前执行一点。

function countDown(endTime, nowTime) {
  let duration = endTime - nowTime
  if (duration <= 0) {
    // 停止显示倒计时
    return
  }

  // 根据duration计算天时分秒
  
  const interval = 1000
  const startTime = Date.now()
  let count = 0

  setTimeout(doCountDown, interval)
  
  function doCountDown() {
    let offset, nextTime
    count++
    offset = Date.now() - (startTime + count * interval)
    nextTime = interval - offset
    if (nextTime < 0) {
      nextTime = 0
    }
  
    duration -= interval
    if (duration <= 0) {
      // 停止显示倒计时
      return
    }
    
    // 根据duration计算天时分秒
    
    setTimeout(doCountDown, nextTime)
  }
}

// endTime是未来某天的时间戳,serverTime是从服务器获取的当前时间
countDown(endTime, serverTime)

长时间盯着倒计时,似乎是没有问题了,误差会被控制在1秒内。

新的问题

但这种方式仍有一个缺点:页面不活跃一段时间回来,你将看到倒计时的时间疯狂跳动,一直跳到正常的时间点。这是由于浏览器的策略导致的,简单粗暴的解决方式也有,那就是判断误差大于一定值的时候,重新请求服务器时间,重新执行倒计时。另外别忘了在离开页面时销毁定时器。

function countDown(endTime, nowTime) {
  let duration = endTime - nowTime
  if (duration <= 0) {
    // 停止显示倒计时
    return
  }

  // 根据duration计算天时分秒
  
  const interval = 1000
  const startTime = Date.now()
  let count = 0

  // 你在外面定义的timer变量
  timer = setTimeout(doCountDown, interval)
  
  function doCountDown() {
    let offset, nextTime
    count++
    offset = Date.now() - (startTime + count * interval)
    if (offset > 600 * interval) {
      // 重新倒计时
      return
    }
    nextTime = interval - offset
    if (nextTime < 0) {
      nextTime = 0
    }
  
    duration -= interval
    if (duration <= 0) {
      // 停止显示倒计时
      return
    }
    
    // 根据duration计算天时分秒
    
    // 你在外面定义的timer变量
    timer = setTimeout(doCountDown, nextTime)
  }
}

// endTime是未来某天的时间戳,serverTime是从服务器获取的当前时间
countDown(endTime, serverTime)

有没有一种可能,需求就是要在切换到其他程序的时候保持倒计时,比如根据倒计时发送心跳数据,那有办法实现吗?答案是有的,上web worker。

问题拓展

无非就是把倒计时的逻辑放到worker里,页面端与worker通信获取倒计时数据:

const worker = new Worker('time-count-worker.js')
worker.onmessage = (event) => {
  // 处理event.data数据
}
worker.postMessage('countDown')

同样别忘了在离开页面时执行worker.terminate()

到这一步,精准倒计时大功告成。