toFrankie / blog

种一棵树,最好的时间是十年前。其次,是现在。
20 stars 1 forks source link

如何实现一个准确的倒计时功能 #339

Open toFrankie opened 3 months ago

toFrankie commented 3 months ago

配图源自 Freepik

前言

倒计时、计时器是一个很常见的业务场景。要求很简单,但做起来也不太简单:

如果你将要实现的计时要体现在 DOM 上,它永远不可能百分百准确。

JavaScript 是单线程的(指主线程),注定了无法一边执行 JS 代码、一边更新 DOM。即便是 HTML5 提出的 Web Worker,它是可以在主动创建一些后台执行的线程,可它不能直接操作 DOM,它传递信息给主线程,也会受到 Event Loop 的影响,该排队还是得排队。

但就人眼来说,几毫秒、几十毫米的误差基本是无感的,这就可以算是一个准确、合格的倒计时。

通常要考虑的问题有:

setTimeout 和 setInterval

我认为还是要聊一聊 setTimeout 和 setInterval。

看个例子:

setTimeout(() => {
  console.log('Hi~')
}, 1000)

众所周知的原因,它至少 1s 之后才能打印 Hi~

setTimeout(fn, delay)delay 是最小开始执行时间,而且只会多不会少。

再看:

setInterval(() => {
  console.log('Hi~')
}, 1000)

它跟 setTimeout 一样受 Event Loop 影响,自然不可能完美地每秒打印一次 Hi~

用 setTimeout 模拟:

setTimeout(function tick() {
  console.log('Hi~')
  setTimeout(tick, 1000)
}, 1000)

🙋 提问:它跟 setInterval 版本功能上等效的吗?

答案是不一样的,setInterval 会产生一种“漂移”(drift)现象。

在 Google 上搜索「setInterval drift」关键词,可以看到很多相关的讨论帖子,比如:

怎么理解漂移呢?

<!DOCTYPE html>
<html lang="en">
  <body>
    <div id="time"></div>
    <script>
      window.onload = function () {
        const element = document.getElementById('time')
        const startTime = performance.now()
        let count = 0

        setInterval(() => {
          count++
          const currentTime = performance.now()
          const time = (currentTime - startTime) / 1000
          const rate = count / time
          element.innerHTML = `${count} call in ${time.toFixed(3)}s, or ${rate.toFixed(6)} calls per second.`
        }, 1000)
      }
    </script>
  </body>
</html>

CodePen Demo

它在 Chrome 126 表现很好,几乎是每一秒更新一次。

34 call in 34.001s, or 0.999965 calls per second.

在 Firefox 127 上,当执行了大概 300 次之后,约漂移了 1s 左右。Safari 漂移也较为明显。

317 call in 318.242s, or 0.996097 calls per second.

据查,Chrome 有做“自动修正”的处理(源码),即便是执行了 300 多次,甚至更多时,其漂移也很低,几乎可以忽略。尽管这种修正并不是规范所要求,但应该是开发者想要的结果。

除此之外,当页面挂起后台,为了省电和减少 CPU 占用,不同浏览器会采用一些策略,暂停或延长定时器的 Delay Time。

挂起后台的情况包含但不限于:有其他处于活跃状态的标签、窗口最小化、网页内容完全不可见、屏幕锁定、移动设备回到桌面等。

小结:

关于 Event Loop 推荐两个不错的视频:

不靠谱版本

尽管 setTimeout 和 setInterval 很多问题,但还是要用到它,我们要做的是尽可能减少误差。

假设有示例如下:

<div id="countdown">0 days, 0 hours, 0 minutes, 0 seconds</div>
window.onload = function () {
  // 倒计时时长(秒)
  const seconds = 90
  countdown(seconds)
}

function countdown(seconds) {
  // TODO: 待实现...
}

// 倒计时展示形式
function renderCounter(timeLeft) {
  const secondInMillisecond = 1000
  const minuteInMillisecond = secondInMillisecond * 60
  const hourInMillisecond = minuteInMillisecond * 60
  const dayInMillisecond = hourInMillisecond * 24

  const dayLeft = Math.floor(timeLeft / dayInMillisecond)
  const hourLeft = Math.floor((timeLeft % dayInMillisecond) / hourInMillisecond)
  const minuteLeft = Math.floor((timeLeft % hourInMillisecond) / minuteInMillisecond)
  const secondLeft = Math.floor((timeLeft % minuteInMillisecond) / secondInMillisecond)

  const html = `${dayLeft} days, ${hourLeft} hours, ${minuteLeft} minutes, ${secondLeft} seconds`
  document.getElementById('countdown').innerHTML = html
}

其中 countdown() 方法接收一个剩余的秒数 seconds

我不关心是用本地时间,还是服务器时间算出来的,你只需告诉我剩余多少秒就行。

简陋版本:

function countdown(seconds) {
  const startTime = Date.now()
  const endTime = startTime + seconds * 1000

  let timeLeft = endTime - startTime

  const timer = setInterval(() => {
    timeLeft -= 1000

    if (timeLeft <= 0) {
      clearInterval(timer)
      renderCounter(0)
      return
    }

    renderCounter(timeLeft)
  }, 1000)

  renderCounter(timeLeft)
  // 🙋
}

假设在 🙋 处有一个耗时的同步任务,比如:

function longRunningTask() {
  for (let i = 0; i < 1000000000; i++) {
    // do something...
  }
}

实际中,耗时任务不应放在主线程中执行,这里只用于表达上述例子的缺点。

那么 setInterval 第一次回调的执行就可能发生在 N 秒之后,这样页面上的倒计时就更不准了。会出现过了 5s 之后,倒计时可能只减去 1s 的情况,显然这不是我们想要的。

即便没有耗时任务,如果被挂起后台,执行频率会变低,甚至暂停,重新回到前台剩余时间就不准了。

因此,timeLeft(剩余时间)要在 setInterval 回调函数内重新计算,修改如下:

function countdown(seconds) {
  const startTime = Date.now()
  const endTime = startTime + seconds * 1000

  const timer = setInterval(() => {
    const now = Date.now()
    const timeLeft = endTime - now

    if (timeLeft <= 0) {
      clearInterval(timer)
      renderCounter(0)
      return
    }

    renderCounter(timeLeft)
  }, 1000)

  renderCounter(endTime - startTime)
}

这样,至少可以确保下一次更新的时候,剩余的时间是“准确”的。

假设在相对理想的环境中,页面上只剩下这个倒计时了,也没有阻塞主线程的(同步)任务,它几乎可以每秒执行一次 renderCounter,最起码人眼感知不到其中的误差。

但现实是,在不同浏览下,随着 setInterval 不停地执行,其 Delay Time 会产生偏差。比如 Safari 和 Firefox 可能会增加几毫秒,而 Chrome 甚至会“自动修复”这种时间偏差(这应该是开发者所期待的),也就是说 Delay Time 甚至会减少。

所以,页面看到的效果有可能是:

0 days, 0 hours, 1 minutes, 30 seconds
↓
0 days, 0 hours, 1 minutes, 28 seconds
↓
...

原因是:假设刚好在剩余 1m 30s 的时候 renderCounter(),由于 Delay Time 的偏差(假设多了 10ms),导致下一次执行时得到 1m 28s < timeLeft < 1m 29s 的结果,导致页面跳过 29s 显示了 28s(前面使用了 Math.floor() 来换算)的问题。

如果页面有其他耗时任务或者挂起后台时,这种偏差只会更明显。

综上,这个方案缺点如下:

  1. 挂起后台时,setInterval() 仍在执行,占用 CPU 资源。
  2. 可能会出现跳秒的情况,也就是说,倒计时不是一直 -1,偶尔会 -2
  3. Date.now() 受系统时钟影响。

改进版本

当页面挂起时,如果不想让定时器一直在后台执行,可以借助 visibilitychange 事件来处理。

function countdown(seconds) {
  const startTime = Date.now()
  const endTime = startTime + seconds * 1000

  const paint = () => {
    const now = Date.now()
    const timeLeft = endTime - now

    if (timeLeft <= 0) {
      clearInterval(timer)
      renderCounter(0)
      return
    }

    renderCounter(timeLeft)
  }

  let timer = setInterval(paint, 1000)

  handleVisibilityChange({
    hiddenFn: () => {
      clearInterval(timer)
    },
    visibleFn: () => {
      if (timer) clearInterval(timer)
      timer = setInterval(paint, 1000)
    },
  })

  renderCounter(endTime - startTime)
}

function handleVisibilityChange({ hiddenFn = () => {}, visibleFn = () => {} }) {
  document.addEventListener('visibilitychange', event => {
    if (document.visibilityState === 'hidden') {
      hiddenFn(event)
      return
    }
    visibleFn(event)
  })
}

该方案的缺点:

  1. 处理后台执行的方式过于复杂。
  2. 未解决跳秒问题。
  3. 未解决 Date.now() 受系统时钟影响的问题。

进阶版本

可以考虑 requestAnimationFrame,它会在页面重绘之前执行指定的回调函数。

出于省电和性能考虑,当页面挂起时,该 API 会暂停执行。

它执行频率跟屏幕刷新率有关。比如屏幕刷新率为 60Hz,表示每秒刷新 60 次,即每 16.67ms 刷新一次以确保画面不卡顿。其他常见的 90Hz、120Hz、144Hz 的刷新率同理。

比如:

function countdown(seconds) {
  const startTime = Date.now()
  const endTime = startTime + seconds * 1000
  renderCounter(endTime - startTime)

  let rafId = requestAnimationFrame(function paint() {
    const now = Date.now()
    const timeLeft = endTime - now

    if (timeLeft <= 0) {
      renderCounter(0)
      cancelAnimationFrame(rafId)
      return
    }

    renderCounter(timeLeft)
    rafId = requestAnimationFrame(paint)
  })
}

在刷新率为 60Hz 的显示器下,每秒执行 60 次,倒计时是足够准确了。但执行太频繁了,也不是我们想要的,还不如 setInterval(() => {}, 333) 呢。

可以结合 setTimeout 解决频繁执行的问题,然后要解决的是:如何获取下一次更新的时间?

引入一个 Document Timeline,此时间轴对于每个文档(document)来说都是唯一的,并在文档的生命周期中持续存在。其时间原点(Time Origin)可通过 performance.timeOrigin 获取。

要获取当前文档自创建以来(即相对于时间原点)所经过的时间,有两种方式:

它们都返回一个相对高精度的毫秒数,但又有点区别。

举个例子:以 60Hz 的屏幕为例,页面每 16.67ms 更新一次。假设第三次更新完(当前时间记为 50ms),接着马上执行下一次 Tick,若时间过了 5ms,此时 document.timeline.currentTimeperformance.now() 分别为 50ms、55ms。等这次 Tick 执行完那一刻它俩的值又将同步,以此类推。

简单来说,document.timeline.currentTime 是当前帧起始那一刻相对于时间原点经过的毫秒数。而 performance.now() 是“真正”当前时间相当于时间原点经过的毫秒数。所以,实际表现后者总是比前者大一点。

接着,我们尝试修改下:

function countdown(seconds) {
  const startTime = document.timeline ? document.timeline.currentTime : performance.now()
  const endTime = startTime + seconds * 1000

  const paint = () => {
    const now = document.timeline ? document.timeline.currentTime : performance.now()
    const timeLeft = endTime - now

    if (timeLeft <= 0) {
      renderCounter(0)
      return
    }

    const roundedTimeLeft = Math.round(timeLeft / 1000) * 1000
    renderCounter(roundedTimeLeft)

    const nextTime = startTime + (seconds * 1000 - roundedTimeLeft) + 1000
    const nextDelay = nextTime - performance.now()

    setTimeout(() => requestAnimationFrame(paint), nextDelay)
  }

  paint()
}

考虑 Document API、[High Resolution Time API](https://caniuse.com/?search=performance.now()) 兼容性。

以下这行处理,目的是避免跳秒现象。举个例子,假设当前 timeLeft 为 2988ms,由于 renderCounter() 里秒数转换是使用了 Math.floor(),它会被转为 2s,但实际上它更接近 3s,因此应该用 Math.round() 作取整操作。

注意,这里是秒数取整,而不是毫秒数取整。

const roundedTimeLeft = Math.round(timeLeft / 1000) * 1000

renderCounter() 之前处理,也便于准确计算出下一秒的时间轴时间。

const nextTime = startTime + (seconds * 1000 - roundedTimeLeft) + 1000

最后,通过下一秒的时间点减去当前时间点,得出延迟时间。

const nextDelay = nextTime - performance.now()

这种方案的优点:

由于这种方案还用到了 setTimeout(),跳秒问题还存在。假设主线程存在耗时任务,没办法及时执行其回调函数,因此可能会出现类似 4s 直接跳到 6s、7s 的情况。

有些文章使用 Web Worker 来实现倒计时,因为它是独立于主线程,可以一直在后台线程进行计时,这样计时倒是准确。如果计时要体现在页面上,得每隔 1s 通知主线程更新 UI(Worker 无法直接操作 DOM)。但是,如果主线程被耗时任务占着,即便主线程接到通知了,但你还是要排队等主线程空闲下来。

因此,根本的解决办法应该是将耗时任务放在 Worker 执行,或者使用时间分片(Time Slicing)方案将耗时任务分成若干小任务,以让出空隙给主线程更新 UI,避免造成页面假死现象。

微信小程序版本

小程序框架的逻辑层并非运行在浏览器中,因此 JavaScript 在 web 中一些能力都无法使用,如 window,document 等。

在小程序里,它们都不能用:

小程序有个 wx.getPerformance().now() 方法(文档未提到),它返回的是自 1970 年 1 月 1 日 0 点开始以来的毫秒数,调试发现其内部返回的就是 Date.now(),所以这玩意在这里压根没用。🙄

既然小程序里面获取不到不受系统时钟影响的当前时间,唯有使用 Date.now() 了,并在 onShow() 时重新校验。

示例如下(小程序代码片段):

import { getServerTime } from '../../utils/index'

// 截止时间:2024/07/28 23:59:59
const DEADLINE_TIME = new Date(2024, 6, 28, 23, 59, 59).getTime()

Page({
  data: {
    formattedCountdown: '',
  },

  async onShow() {
    // TIPS: 小程序需配置请求域名,获取服务器时间根据实际调整,比如发起 HEAD 请求获取 header.date 等方式。
    const now = await getServerTime()
    const secondsLeft = Math.floor((DEADLINE_TIME - now) / 1000)

    this.countdown(secondsLeft)
  },

  onUnload() {
    clearTimeout(this._countdown_timer)
  },

  countdown(seconds) {
    const startTime = Date.now()
    const endTime = startTime + seconds * 1000

    // 避免 onShow 后有多个定时器在跑
    clearTimeout(this._countdown_timer)

    const paint = () => {
      const now = Date.now()
      const timeLeft = endTime - now

      if (timeLeft <= 0) {
        this.renderCounter(0)
        return
      }

      const roundedTimeLeft = Math.round(timeLeft / 1000) * 1000
      this.renderCounter(roundedTimeLeft)

      const nextTime = startTime + (seconds * 1000 - roundedTimeLeft) + 1000
      const nextDelay = nextTime - Date.now()

      this._countdown_timer = setTimeout(() => paint(), nextDelay)
    }

    paint()
  },

  renderCounter(timeLeft) {
    const secondInMillisecond = 1000
    const minuteInMillisecond = secondInMillisecond * 60
    const hourInMillisecond = minuteInMillisecond * 60
    const dayInMillisecond = hourInMillisecond * 24

    const dayLeft = Math.floor(timeLeft / dayInMillisecond)
    const hourLeft = Math.floor((timeLeft % dayInMillisecond) / hourInMillisecond)
    const minuteLeft = Math.floor((timeLeft % hourInMillisecond) / minuteInMillisecond)
    const secondLeft = Math.floor((timeLeft % minuteInMillisecond) / secondInMillisecond)

    const formattedStr = `${dayLeft} days, ${hourLeft} hours, ${minuteLeft} minutes, ${secondLeft} seconds`

    this.setData({ formattedCountdown: formattedStr })
  },
})

References