【源码共读】| 简易实现毫秒级渲染的倒计时组件

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

【若川视野 x 源码共读】第46期 | 分析 vant4 源码,学会用 vue3 + ts 开发毫秒级渲染的倒计时组件。点击查看详情

倒计时组件我们在开发中也会经常用到,以vant为例,他是怎么实现毫秒级渲染的呢

组件功能分析:

  • 启动
  • 暂停
  • 重置

源码分析

首先,我们来到仓库查看对应的组件
github.com/youzan/vant...

整个源码很简单,利用了浏览器的requestAnimationFrame来实现毫秒级渲染
developer.mozilla.org/en-US/docs/...

以显示器60hz为例,这个api能够实现16.67ms渲染一次,如果将倒计时的函数放进去,即可实现毫秒级渲染

tsx 复制代码
import { ref, computed, onActivated, onDeactivated, onBeforeUnmount } from 'vue'
import { raf, cancelRaf, inBrowser } from '../utils'

// 当前类型定义
export type CurrentTime = {
  days: number
  hours: number
  total: number
  minutes: number
  seconds: number
  milliseconds: number
}

export type UseCountDownOptions = {
  time: number
  millisecond?: boolean
  // 改变触发
  onChange?: (current: CurrentTime) => void
  // 完成后回调
  onFinish?: () => void
}

// 初始配置
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR

// 格式化
function parseTime(time: number): CurrentTime {
  const days = Math.floor(time / DAY)
  const hours = Math.floor((time % DAY) / HOUR)
  const minutes = Math.floor((time % HOUR) / MINUTE)
  const seconds = Math.floor((time % MINUTE) / SECOND)
  const milliseconds = Math.floor(time % SECOND)

  return {
    total: time,
    days,
    hours,
    minutes,
    seconds,
    milliseconds
  }
}

function isSameSecond(time1: number, time2: number): boolean {
  return Math.floor(time1 / 1000) === Math.floor(time2 / 1000)
}

export function useCountDown(options: UseCountDownOptions) {
  let rafId: number
  let endTime: number
  let counting: boolean
  let deactivated: boolean

  const remain = ref(options.time)
  const current = computed(() => parseTime(remain.value))

  // 暂停
  const pause = () => {
    counting = false
    cancelRaf(rafId)
  }

  // 获取当前剩余时间
  const getCurrentRemain = () => Math.max(endTime - Date.now(), 0)

  // 设置剩余时间
  const setRemain = (value: number) => {
    remain.value = value
    options.onChange?.(current.value)

    if (value === 0) {
      pause()
      options.onFinish?.()
    }
  }

  const microTick = () => {
    // 判断是否是浏览器环境,如果是浏览器环境,使用 requestAnimationFrame
    // export function raf(fn: FrameRequestCallback): number {
    //   return inBrowser ? requestAnimationFrame(fn) : -1;
    // }
    rafId = raf(() => {
      // in case of call reset immediately after finish
      if (counting) {
        setRemain(getCurrentRemain())

        if (remain.value > 0) {
          microTick()
        }
      }
    })
  }

  const macroTick = () => {
    rafId = raf(() => {
      // in case of call reset immediately after finish
      if (counting) {
        const remainRemain = getCurrentRemain()

        if (!isSameSecond(remainRemain, remain.value) || remainRemain === 0) {
          setRemain(remainRemain)
        }

        if (remain.value > 0) {
          macroTick()
        }
      }
    })
  }

  const tick = () => {
    // should not start counting in server
    // see: https://github.com/vant-ui/vant/issues/7807
    // 如果不是浏览器环境,直接返回
    if (!inBrowser) {
      return
    }
    // 是否启用毫秒级别的渲染
    if (options.millisecond) {
      microTick()
    } else {
      macroTick()
    }
  }

  const start = () => {
    if (!counting) {
      endTime = Date.now() + remain.value
      counting = true
      tick()
    }
  }

  const reset = (totalTime: number = options.time) => {
    pause()
      remain.value = totalTime
  }

  onBeforeUnmount(pause)

  // https://vuejs.org/guide/built-ins/keep-alive.html#lifecycle-of-cached-instance
  // 在 activated 钩子函数中重新开始定时器,避免缓存的定时器无法启动的问题
  onActivated(() => {
    if (deactivated) {
      counting = true
      deactivated = false
      tick()
    }
  })

  onDeactivated(() => {
    if (counting) {
      pause()
      deactivated = true
    }
  })

  return {
    start,
    pause,
    reset,
    current
  }
}

简易实现

既然知道了他的原理,我们可以来简易实现一下

html 复制代码
<!DOCTYPE html>
<html lang="en">

  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>

  <body>
    <span id="countDown"></span>
    <div>
      <button id="start">start</button>
      <button id="stop">stop</button>
      <button id="reset">reset</button>
    </div>

    <script>
      const TIMER = (initialMilliseconds) => {
        const SECOND = 1000;
        const MINUTE = 60 * SECOND;
        const HOUR = 60 * MINUTE;
        const DAY = 24 * HOUR;

        const parseTime = (time) => {
          const days = Math.floor(time / DAY);
          const hours = Math.floor((time % DAY) / HOUR);
          const minutes = Math.floor((time % HOUR) / MINUTE);
          const seconds = Math.floor((time % MINUTE) / SECOND);
          const milliseconds = Math.floor(time % SECOND);

          return { days, hours, minutes, seconds, milliseconds };
        }

        const updateCountDown = (time, countDownElement) => {
          const { days, hours, minutes, seconds, milliseconds } = parseTime(time)
          let timeStr = `剩余时间:${days}天 ${hours}小时 ${minutes}分钟 ${seconds}秒 ${milliseconds}毫秒`
          countDownElement.innerHTML = timeStr;
        }

        let handler;
        const start = (countDownElement) => {
          let endTime = new Date().getTime() + initialMilliseconds;
          const step = () => {
            const now = new Date().getTime();
            const msTillEnd = endTime - now;
            if (msTillEnd > 0) {
              updateCountDown(msTillEnd, countDownElement);
              handler = window.requestAnimationFrame(step);
            } else {
              updateCountDown(0, countDownElement);
            }
          };
          handler = window.requestAnimationFrame(step);
        }

        const stop = () => {
          window.cancelAnimationFrame(handler);
        }

        return { start, stop, updateCountDown, initialMilliseconds };
      };

      const INITIAL_MILLISECONDS = 3000
      const { start, stop, updateCountDown } = TIMER(INITIAL_MILLISECONDS);

      const countDownElement = document.getElementById('countDown');
      const startButtonElement = document.getElementById('start');
      const stopButtonElement = document.getElementById('stop');
      const resetButtonElement = document.getElementById('reset');

      updateCountDown(INITIAL_MILLISECONDS, countDownElement)
      startButtonElement.addEventListener('click', () => {
        start(countDownElement);
      });
      stopButtonElement.addEventListener('click', () => {
        stop();
      });
      resetButtonElement.addEventListener('click', () => {
        updateCountDown(INITIAL_MILLISECONDS, countDownElement)
                        });


    </script>
</body>

</html>

至此,我们就完成了毫米级倒计时组件的实现。

通过阅读源码,我们可以了解到组件使用requestAnimationFrame实现毫秒级渲染,并在onActive函数中注册事件以避免缓存的定时器无法启动的问题。同时还会判断是否处于浏览器环境,以避免服务端渲染时出错的问题。

相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax