Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
toFrankie opened this issue May 26, 2024 · 0 comments
Open

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

toFrankie opened this issue May 26, 2024 · 0 comments
Labels
2024 2024 年撰写 JS 与 JavaScript、ECMAScript 相关的文章 前端 与 JavaScript、ECMAScript、Web 前端相关的文章

Comments

@toFrankie
Copy link
Owner

toFrankie commented May 26, 2024

配图源自 Freepik

前言

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

  • 准确性高
  • 性能好

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

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

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

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

它在 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 多次,甚至更多时,其漂移也很低,几乎可以忽略。尽管这种修正并不是规范所要求,但它应该是开发者想要的结果。

小结:

  • setTimeout(fn, delay) 的 delay 是最小开始执行时间,而且只会多不会少。
  • setInterval(fn, delay) 的 delay 会左右“漂移”,累计执行次数越多,漂移越明显。在 Chrome 浏览器下有“修正”处理,每次实际执行的 delay 与传入的值很接近,可以当作没有误差。

关于 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 updateUI(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() 方法接收一个剩余的秒数。不关心是用本地时间,还是服务器时间算出来的,你只需告诉我剩余多少秒就行。

简陋版本:

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

  let timeLeft = endTime - startTime

  const timer = setInterval(() => {
    timeLeft -= 1000 // 减 1s

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

    updateUI(timeLeft)
  }, 1000)

  updateUI(timeLeft)
  // 🙋
}

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

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

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

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

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

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

因此,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)
      updateUI(0)
      return
    }

    updateUI(timeLeft)
  }, 1000)
  
  updateUI(endTime - startTime)
}

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

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

但现实是,在不同浏览下,随着 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 的时候 updateUI(),由于 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)
      updateUI(0)
      return
    }

    updateUI(timeLeft)
  }

  let timer = setInterval(paint, 1000)
  
  handleVisibilityChange({
    hiddenFn: () => {
      clearInterval(timer)
    },
    visibleFn: () => {
      if (timer) clearInterval(timer)
      timer = setInterval(paint, 1000)
    },
  })
  
  updateUI(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
  updateUI(endTime - startTime)

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

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

    updateUI(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) {
      updateUI(0)
      return
    }

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

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

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

  paint()
}

考虑 Document APIHigh Resolution Time API 兼容性。

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

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

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

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

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

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

const nextDelay = nextTime - performance.now()

这种方案的优点:

  • 时间更准确,可以解决用户修改系统时钟导致计时可能不准确的问题。
    • 使用 Date 会受到时钟偏差和系统时钟调整的影响。
    • 使用 High Resolution Time 不受系统时钟影响。
  • 当浏览器挂起时自动暂停,恢复前台时继续,且准确性不受影响。

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

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

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

微信小程序版本

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

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

  • document.timeline.currentTime
  • window.performance.now()
  • window.requestAnimationFrame()

但微信提供了 wx.getPerformance() 方法,通过 wx.getPerformance().now() 可以得到一个类似 window.performance.now() 的结果,但不完全相同:

  • wx.getPerformance().now() 返回自 1970 年 1 月 1 日开始以来的毫秒数。
  • window.performance.now() 返回自 performance.timeOrigin 开始以来的毫秒数。

需要注意的是,我目前不确定 wx.getPerformance().now() 日后是否会有变动,因为小程序文档并未指出其返回值包含 now 方法,但实测开发工具和真机是可用的。有点 wx.onAppRoute() 的味道,虽然文档一直未提到过,但一直可用。

改造如下:

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

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

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

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

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

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

  paint()
}

TODO: 确认是否受系统时钟调整的影响?

其他

未完待续...

References

@toFrankie toFrankie added 2024 2024 年撰写 JS 与 JavaScript、ECMAScript 相关的文章 前端 与 JavaScript、ECMAScript、Web 前端相关的文章 labels May 26, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2024 2024 年撰写 JS 与 JavaScript、ECMAScript 相关的文章 前端 与 JavaScript、ECMAScript、Web 前端相关的文章
Projects
None yet
Development

No branches or pull requests

1 participant