一、前言
TimeCountDown 是一个实用的倒计时组件,用于展示剩余时间并自动更新。它适用于以下场景:
- 活动倒计时(如限时促销、抢购活动)
- 任务倒计时(如会议开始、截止日期提醒)
- 验证码倒计时
该组件支持自定义展示格式,提供了灵活的插槽机制,并具有时间精度控制和后台切换恢复功能。
二、组件设计思路
-
数据结构设计:
- 使用
leftSeconds存储剩余秒数 - 使用
isExpired标记是否过期 - 提供
leftDay和leftHMS格式化展示
- 使用
-
实现功能:
- 倒计时隔秒自动更新
- 组件卸载时清除定时器
- 支持后台时间恢复
- 支持数据同步更新
- 使用插槽提供自定义展示
三、实现步骤详解
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>
七、优化与注意事项
- 内存泄漏防护:组件卸载时会清除定时器,避免内存泄漏
- 后台时间恢复:使用时间戳对比,确保页面从后台切回时倒计时准确
- 数据同步:通过
update属性控制是否同步更新外部数据
问题解释
Q1.为什么不用
emit('update:record', record)? A1: 用emit('update:record', record)不适用于 循环列表 或 v-slots 当中, 若改用 更新单个值 则需要写多个update:key因此选用此方法 以下 是 使用 emit('update:record', record) error 截图
八、完结撒花
TimeCountDown 组件设计和实现,提供了一个功能完整、性能优良的倒计时解决方案。
希望这个组件对您有所帮助!