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

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

【若川视野 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函数中注册事件以避免缓存的定时器无法启动的问题。同时还会判断是否处于浏览器环境,以避免服务端渲染时出错的问题。

相关推荐
Lysun0013 分钟前
[less] Operation on an invalid type
前端·vue·less·sass·scss
J总裁的小芒果19 分钟前
Vue3 el-table 默认选中 传入的数组
前端·javascript·elementui·typescript
Lei_zhen9621 分钟前
记录一次electron-builder报错ENOENT: no such file or directory, rename xxxx的问题
前端·javascript·electron
咖喱鱼蛋24 分钟前
Electron一些概念理解
前端·javascript·electron
yqcoder25 分钟前
Vue3 + Vite + Electron + TS 项目构建
前端·javascript·vue.js
鑫宝Code42 分钟前
【React】React Router:深入理解前端路由的工作原理
前端·react.js·前端框架
Mr_Xuhhh2 小时前
重生之我在学环境变量
linux·运维·服务器·前端·chrome·算法
永乐春秋3 小时前
WEB攻防-通用漏洞&文件上传&js验证&mime&user.ini&语言特性
前端
鸽鸽程序猿3 小时前
【前端】CSS
前端·css
ggdpzhk3 小时前
VUE:基于MVVN的前端js框架
前端·javascript·vue.js