vue3版本-时间倒计时TimeCountDown

371 阅读4分钟

一、前言

TimeCountDown 是一个实用的倒计时组件,用于展示剩余时间并自动更新。它适用于以下场景:

  • 活动倒计时(如限时促销、抢购活动)
  • 任务倒计时(如会议开始、截止日期提醒)
  • 验证码倒计时

该组件支持自定义展示格式,提供了灵活的插槽机制,并具有时间精度控制和后台切换恢复功能。

屏幕录制 2025-08-01 143919.gif

二、组件设计思路

  1. 数据结构设计

    • 使用 leftSeconds 存储剩余秒数
    • 使用 isExpired 标记是否过期
    • 提供 leftDayleftHMS 格式化展示
  2. 实现功能

    • 倒计时隔秒自动更新
    • 组件卸载时清除定时器
    • 支持后台时间恢复
    • 支持数据同步更新
    • 使用插槽提供自定义展示

三、实现步骤详解

1. 创建组件基础结构

首先,创建 TimeCountDown.vue 文件,定义基本的组件结构:

<script setup lang="ts">
import type { PropType } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
// 引入工具函数: 在下面有申明
import { cancelRaf, rafTimeout } from '@/utils/raf'
import { getRemainingSecondsInfo } from '@/utils'

defineOptions({ name: 'TimeCountDown' })
// 后续代码...
</script>

<template>
  <div class="countdown-container">
    <!-- 内容 -->
  </div>
</template>

<style scoped lang="less">
/* 样式 */
</style>

2. 定义 Props 和 响应式数据

// 定义接口
interface DateInfo extends Recordable {
  local_leftSeconds: number
  local_isExpired: boolean
  local_leftDay?: number
  local_leftHMS?: string
}

// 定义props
const props = defineProps({
  record: {
    type: Object as PropType<DateInfo>,
    default: () => ({ local_leftSeconds: 0, local_isExpired: true })
  },
  update: {
    type: Boolean,
    default: false
  }
})

// 响应式数据
const isExpired = ref(false)
const leftSeconds = ref(0)
const leftDay = ref(0)
const leftHMS = ref('00:00:00')

3. 实现倒计时核心逻辑

// 定时器引用
let countdownTimer: { id?: number } | null = null
let cur_timestamp = 0

/**
 * 更新倒计时状态
 */
function updateCountdown(minusSeconds = 0) {
  const next_leftSeconds = leftSeconds.value - minusSeconds
  if (next_leftSeconds > 0) {
    const { d, h, m, s } = getRemainingSecondsInfo(next_leftSeconds)
    const hms = `${doubleNumStr(h || 0)}:${doubleNumStr(m || 0)}:${doubleNumStr(s || 0)}`

    isExpired.value = false
    leftSeconds.value = next_leftSeconds
    leftDay.value = d
    leftHMS.value = hms

    // 如果需要同步更新外部数据
		/**注: 问题1:此处为什么不用 emit('update:record', record) 请看后面注释*/ 
    if (props.update) {
      Object.assign(props.record, {
        local_isExpired: false,
        local_leftSeconds: next_leftSeconds,
        local_leftDay: d,
        local_leftHMS: hms
      })
    }

    // 设置下一次更新
    countdownTimer = rafTimeout(() => {
      const now = +new Date()
      // 实际时间消耗
      const diffTime = Math.floor((now - cur_timestamp) / 1000)
      cur_timestamp = now
      // 页面退到后台的时候不会计时,对比时间差,大于1s的修正倒计时
      updateCountdown(Math.max(diffTime, 1))
    }, 1000)
  } else {
    isExpired.value = true
    if (props.update) {
      Object.assign(props.record, {
        local_isExpired: true,
        local_leftSeconds: next_leftSeconds
      })
    }
  }
}

/**
 * 启动倒计时
 */
function startCountdown() {
  stopCountdown() // 清除现有定时器
  if (leftSeconds.value > 0) {
    cur_timestamp = +new Date()
    updateCountdown(0)
  }
}

/**
 * 停止倒计时
 */
function stopCountdown() {
  if (countdownTimer) {
    cancelRaf(countdownTimer)
    countdownTimer = null
  }
}

4. 实现辅助函数和生命周期处理

// 数字格式化辅助函数
export const doubleNumStr = (num: number) => `0${num || 0}`.slice(-2)

/**
 * 监听props变化
 */
watch(
  () => props.record.local_leftSeconds,
  newSeconds => {
    if (newSeconds !== leftSeconds.value) {
      leftSeconds.value = newSeconds
      if (newSeconds > 0) startCountdown()
      else stopCountdown()
    }
  },
  { immediate: true }
)

/**
 * 组件卸载时清除定时器
 */
onUnmounted(() => {
  stopCountdown()
})

5. 编写模板和样式

<template>
  <div class="countdown-container">
    <slot v-bind="{ leftDay, leftHMS, isExpired, leftSeconds }">
      <div v-if="isExpired" class="expired-text">已过期</div>
      <div v-else class="countdown-content">
        <span v-if="leftDay" class="countdown-item">{{ leftDay }}天</span>
        <span class="countdown-item">{{ leftHMS }}</span>
      </div>
    </slot>
  </div>
</template>

<style scoped lang="less">
.countdown-container {
  .expired-text {
    color: #faad14;
    background: rgba(250, 173, 20, 0.1);
    padding: 2px 8px;
    border-radius: 4px;
  }
}
</style>

四、工具函数实现

为了使组件正常工作,我们需要实现两个工具函数:

// src/utils/index.ts
/**
 * 获取剩余秒数的详细信息
 * @param seconds 剩余秒数
 * @returns 天、时、分、秒对象
 */
export function getRemainingSecondsInfo(seconds: number) {
  return {
    d: Math.floor(seconds / (3600 * 24)),
    h: Math.floor((seconds % (3600 * 24)) / 3600),
    m: Math.floor((seconds % 3600) / 60),
    s: Math.floor(seconds % 60)
  }
}

// src/utils/raf.ts
/**
 * 取消requestAnimationFrame
 */
export function cancelRaf(timer: { id?: number }) {
  if (timer && timer.id) {
    cancelAnimationFrame(timer.id)
    timer.id = undefined
  }
}

/**
 * 使用requestAnimationFrame实现的setTimeout
 */
export function rafTimeout(callback: () => void, delay = 0, interval = false) {
	let start = performance.now()
	const raf = {
		// 引用类型保存,方便获取 requestAnimationFrame()方法返回的 ID.
		id: requestAnimationFrame(loop)
	}
	function loop(timestamp: number) {

		const elapsed = timestamp - start
		if (elapsed >= delay) {
			callback() // 执行目标函数func
			if (interval) {
				start = timestamp
				raf.id = requestAnimationFrame(loop)
			}
		} else {
			raf.id = requestAnimationFrame(loop)
		}
	}

	return raf
}

/utils/raf.ts 工具为什么使用 requestAnimationFrame模拟 setTimeout
见: JavaScript定时器: 为了写一个时间倒计时组件,我重新封装了倒计时方法

五、组件使用示例

<template>
  <div class="demo-container">
    <h3>基础使用</h3>
    <TimeCountDown :record="countdownRecord" />

    <h3>自定义展示</h3>
    <TimeCountDown :record="countdownRecord">
      <template #default="{ leftDay, leftHMS, isExpired }">
        <div v-if="isExpired" class="custom-expired">已结束</div>
        <div v-else class="custom-countdown">
          剩余: {{ leftDay || 0 }}天 {{ leftHMS }}
        </div>
      </template>
    </TimeCountDown>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import TimeCountDown from './components/TimeCountDown.vue'

// 设置一个1小时后的倒计时
const hourLater = new Date()
hourLater.setHours(hourLater.getHours() + 1)
const totalSeconds = Math.floor((hourLater.getTime() - Date.now()) / 1000)

const countdownRecord = ref({
  local_leftSeconds: totalSeconds,
  local_isExpired: false
})
</script>

六、完整代码

<script setup lang="ts">  
import type { PropType } from 'vue'  
import { onUnmounted, ref, watch } from 'vue'  
import { cancelRaf, rafTimeout } from '@/utils/raf' 
// import { getRemainingSecondsInfo } from '@/utils'  
  function getRemainingSecondsInfo(seconds: number) {
      return { 
          d: Math.floor(seconds / (3600 * 24)),
          h: Math.floor((seconds % (3600 * 24)) / 3600),
          m: Math.floor((seconds % 3600) / 60),
          s: Math.floor(seconds % 60) 
      }
  }
defineOptions({ name: 'TimeCountDown' })  
  
// 定义props  
const props = defineProps({  
    record: {  
        type: Object as PropType<DateInfo>,  
        default: () => ({ local_leftSeconds: 0, local_isExpired: true })  
    },  
    update: {  
        type: Boolean,  
        default: false  
    }  
})  
interface DateInfo extends Recordable {  
    local_leftSeconds: number  
    local_isExpired: boolean  
    local_leftDay?: number  
    local_leftHMS?: string  
}  
  
// 定时器引用  
let countdownTimer: { id?: number } | null = null  
let cur_timestamp = 0  
const isExpired = ref(false)  
// 当前秒数状态  
const leftSeconds = ref(0)  
const leftDay = ref(0)  
const leftHMS = ref('00:00:00')  
const doubleNumStr = (num: number) => `0${num || 0}`.slice(-2)  
  
/**  
* 更新倒计时状态  
*/  
function updateCountdown(minusSeconds = 0) {  
    const next_leftSeconds = leftSeconds.value - minusSeconds  
    if (next_leftSeconds > 0) {  
        const { d, h, m, s } = getRemainingSecondsInfo(next_leftSeconds)  
        const hms = `${doubleNumStr(h || 0)}:${doubleNumStr(m || 0)}:${doubleNumStr(s || 0)}`  

        isExpired.value = false  
        leftSeconds.value = next_leftSeconds  
        leftDay.value = d  
        leftHMS.value = hms  
        if (props.update) {  
            Object.assign(props.record, {  
                local_isExpired: false,  
                local_leftSeconds: next_leftSeconds,  
                local_leftDay: d,  
                local_leftHMS: hms  
            })  
        }  

        countdownTimer = rafTimeout(() => {  
            const now = +new Date()  
            // 实际时间消耗  
            const diffTime = Math.floor((now - cur_timestamp) / 1000)  
            cur_timestamp = now  
            // 页面退到后台的时候不会计时,对比时间差,大于1s的重置倒计时  
            updateCountdown(Math.max(diffTime, 1))  
        }, 1000)  
    } else {  
        isExpired.value = true  

        if (props.update) {  
            Object.assign(props.record, {  
                local_isExpired: true,  
                local_leftSeconds: next_leftSeconds  
            })  
        }  
    }  
}  
  
/**  
* 启动倒计时  
*/  
function startCountdown() {  
    stopCountdown() // 清除现有定时器  
    if (leftSeconds.value > 0) {  
        cur_timestamp = +new Date()  
        updateCountdown(0)  
    }  
}  
  
/**  
* 停止倒计时  
*/  
function stopCountdown() {  
    if (countdownTimer) {  
        cancelRaf(countdownTimer)  
        countdownTimer = null  
    }  
}  
  
/**  
* 监听props变化  
*/  
watch(  
    () => props.record.local_leftSeconds,  
    newSeconds => {  
        if (newSeconds !== leftSeconds.value) {  
            leftSeconds.value = newSeconds  
            if (newSeconds > 0) startCountdown()  
            else stopCountdown()  
        }  
    },  
    { immediate: true }  
)  
  
/**  
* 组件卸载时清除定时器  
*/  
onUnmounted(() => {  
    stopCountdown()  
})  
</script>  
  
<template>  
<div class="countdown-container">  
    <slot v-bind="{ leftDay, leftHMS, isExpired, leftSeconds }">  
        <div v-if="isExpired" class="expired-text">已过期</div>  
        <div v-else class="countdown-content">  
            <span v-if="leftDay" class="countdown-item">{{ leftDay }}天</span>  
            <span class="countdown-item">{{ leftHMS }}</span>  
        </div>  
    </slot>  
</div>  
</template>  
  
<style scoped lang="less">  
.countdown-container {  
    .expired-text {  
        color: #faad14;  
        background: rgba(250, 173, 20, 0.1);  
        padding: 2px 8px;  
        border-radius: 4px;  
    }  
}  
</style>

七、优化与注意事项

  1. 内存泄漏防护:组件卸载时会清除定时器,避免内存泄漏
  2. 后台时间恢复:使用时间戳对比,确保页面从后台切回时倒计时准确
  3. 数据同步:通过 update 属性控制是否同步更新外部数据

问题解释

Q1.为什么不用 emit('update:record', record)? A1: 用 emit('update:record', record) 不适用于 循环列表 或 v-slots 当中, 若改用 更新单个值 则需要写多个 update:key 因此选用此方法 以下 是 使用 emit('update:record', record) error 截图

出错代码截图

出错页面截图

八、完结撒花

TimeCountDown 组件设计和实现,提供了一个功能完整、性能优良的倒计时解决方案。

希望这个组件对您有所帮助!