跳至主要內容

精准倒计时实现

菜鸡小谢原创大约 2 分钟js

为什么使用setTimeout、setInterval做倒计时不准?

1、主要原因与JavaScript的执行机制有关。JavaScript是单线程的,这意味着它一次只能执行一个任务。当你设置一个计时器时,计时器的回调函数会被放入任务队列中,等待事件循环来处理。然而,如果在计时器到期时,调用栈中有其他任务正在执行,计时器的回调函数就会被延迟执行。这种延迟会导致计时器不准确。

2、此外,浏览器对 setTimeoutsetInterval 的最小时间间隔有一定的限制,通常在4毫秒或更高,这也会影响计时的精度。系统资源的限制,如CPU负载过高,也可能导致计时器的回调函数被延迟执行。

知道了原因,问题就比较好解决了。

既然由于js是单线程机制,那我们就专门开一个线程来进行倒计时不就行了,web workeropen in new window 登场。

web worker 方案

import { ref } from 'vue'

let countDownWorker: Worker | null = null
export default function useCountDownWorker(defaultTime: number) {
  const seconds = ref(defaultTime)
  function initCountDownWorker() {
    if (!countDownWorker) {
      countDownWorker = new Worker(
        new URL('./count-down.work.js', import.meta.url),
        { type: 'module' }
      )
      countDownWorker.postMessage({ type: 'ready', data: defaultTime })
      countDownWorker.addEventListener('message', function (event) {
        const { type, data } = event.data
        switch (type) {
          case 'ready':
            console.log(data)
            break
          case 'data':
            seconds.value = data
            break
          case 'stop':
            break
          case 'end':
            countDownWorker?.terminate()
            countDownWorker = null
            break
          case 'reset':
            seconds.value = data
            break
        }
      })
      countDownWorker.addEventListener('error', function (event) {
        console.log(event)
      })
    }
  }
  function startCountDown() {
    if (!countDownWorker) {
      initCountDownWorker()
    }
    countDownWorker?.postMessage({ type: 'start' })
  }
  function stopCountDown() {
    countDownWorker?.postMessage({ type: 'stop' })
  }
  function resetCountDown() {
    countDownWorker?.postMessage({ type: 'reset', data: defaultTime })
  }
  return { seconds, startCountDown, stopCountDown, resetCountDown }
}

count-down.work.js

let seconds = 0
let interval = null

function countDown() {
  if (interval) {
    clearInterval(interval)
  }
  interval = setInterval(() => {
    if (seconds <= 0) {
      postMessage({ type: 'end' })
      return
    }
    seconds--
    postMessage({ type: 'data', data: seconds })
  }, 1000)
}

function stop() {
  clearInterval(interval)
  postMessage({ type: 'stop' })
}

function reset(data) {
  clearInterval(interval)
  seconds = data
  postMessage({ type: 'reset', data: seconds })
}

function ready() {
  postMessage({ type: 'ready', data: 'it is ready' })
}

addEventListener('message', function (event) {
  const { type, data } = event.data
  switch (type) {
    case 'ready':
      seconds = data
      ready()
      break
    case 'start':
      countDown()
      break
    case 'stop':
      stop()
      break
    case 'reset':
      reset(data)
      break
  }
})

requestAnimationFrame方案

除了单开一个线程外,还有另外一种方案,就是requestAnimationFrameopen in new window方案,因为requestAnimationFrame对回调函数的调用频率通常与显示器的刷新率相匹配,最大限度地减少了阻塞和性能问题。具体实现如下

import { ref, type Ref } from 'vue'
let requestAnimationFrameId: number | null = null
export default function useCountDownRAF(defaultTime: number) {
  const seconds = ref(defaultTime)
  function countDown(seconds: Ref<number>) {
    const end = performance.now() + seconds.value * 1000
    const step = () => {
      const now = performance.now()
      const remaining = Math.max(0, end - now)
      seconds.value = Math.round(remaining / 1000)
      if (remaining > 0) {
        requestAnimationFrameId = requestAnimationFrame(step)
      } else {
        seconds.value = 0
        if (requestAnimationFrameId) {
          cancelAnimationFrame(requestAnimationFrameId)
          requestAnimationFrameId = null
        }
      }
    }
    requestAnimationFrame(step)
  }
  function startCountDown() {
    if (seconds.value <= 0) {
      seconds.value = defaultTime
    }
    countDown(seconds)
  }
  function stopCountDown() {
    if (requestAnimationFrameId) {
      cancelAnimationFrame(requestAnimationFrameId)
      requestAnimationFrameId = null
    }
  }
  function resetCountDown() {
    seconds.value = defaultTime
    stopCountDown()
  }
  return { seconds, startCountDown, stopCountDown, resetCountDown }
}

注意:使用requestAnimationFrame方案时,当页面置于后台时,requestAnimationFrame回调会暂停执行以节省性能,所以需要依托performance.now()(这个更好,Date会存在系统时间被篡改的风险)或者Date.now()来修正倒计时时间。