UniApp实现的倒计时功能与倒计时组件(鸿蒙系统适配版)
前言
在移动应用开发中,倒计时功能是一个常见而实用的需求,比如秒杀活动倒计时、验证码重发倒计时、限时优惠等场景。本文将详细介绍如何使用UniApp框架开发一个功能完善、性能优异的倒计时组件,并重点关注在鸿蒙系统下的特殊适配,确保组件在华为设备上也能完美运行。
需求分析
一个优秀的倒计时组件应具备以下功能:
- 精确的时间计算
- 多种格式的时间展示(天时分秒、时分秒、分秒等)
- 自定义样式
- 事件通知(开始、结束、每秒更新等)
- 支持暂停、恢复、重置等操作
- 适配多端,特别是鸿蒙系统
技术选型
我们将使用以下技术栈:
- UniApp作为跨平台开发框架
- Vue3组合式API提供响应式编程体验
- TypeScript增强代码的类型安全
- 秒级计时使用setTimeout实现,毫秒级计时使用requestAnimationFrame
- 鸿蒙系统特有API调用
基础倒计时组件实现
核心逻辑设计
首先,我们来实现一个基础的倒计时组件:
vue
<template>
<view class="countdown-container" :class="{'harmony-countdown': isHarmony}">
<text v-if="showDays && formattedTime.days > 0" class="countdown-item days">
{{ formattedTime.days }}
</text>
<text v-if="showDays && formattedTime.days > 0" class="countdown-separator">天</text>
<text class="countdown-item hours">{{ formattedTime.hours }}</text>
<text class="countdown-separator">:</text>
<text class="countdown-item minutes">{{ formattedTime.minutes }}</text>
<text class="countdown-separator">:</text>
<text class="countdown-item seconds">{{ formattedTime.seconds }}</text>
<text v-if="showMilliseconds" class="countdown-separator">.</text>
<text v-if="showMilliseconds" class="countdown-item milliseconds">
{{ formattedTime.milliseconds }}
</text>
</view>
</template>
<script lang="ts">
import { defineComponent, ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue';
import { isHarmonyOS } from '@/utils/platform';
interface TimeObject {
days: string;
hours: string;
minutes: string;
seconds: string;
milliseconds: string;
}
export default defineComponent({
name: 'CountdownTimer',
props: {
endTime: {
type: [Number, String, Date],
required: true
},
format: {
type: String,
default: 'HH:mm:ss'
},
showDays: {
type: Boolean,
default: false
},
showMilliseconds: {
type: Boolean,
default: false
},
autoStart: {
type: Boolean,
default: true
},
millisecondPrecision: {
type: Boolean,
default: false
}
},
emits: ['start', 'end', 'update', 'pause', 'resume'],
setup(props, { emit }) {
// 剩余时间(毫秒)
const remainingTime = ref(0);
// 倒计时状态
const isPaused = ref(!props.autoStart);
// 是否已结束
const isEnded = ref(false);
// 是否为鸿蒙系统
const isHarmony = ref(false);
// 计时器ID
let timerId: number | null = null;
let rafId: number | null = null;
// 上一次更新时间
let lastUpdateTime = 0;
// 格式化后的时间对象
const formattedTime = computed((): TimeObject => {
const total = remainingTime.value;
// 计算天、时、分、秒、毫秒
const days = Math.floor(total / (1000 * 60 * 60 * 24));
const hours = Math.floor((total % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((total % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((total % (1000 * 60)) / 1000);
const milliseconds = Math.floor((total % 1000) / 10);
// 格式化
return {
days: days.toString().padStart(2, '0'),
hours: hours.toString().padStart(2, '0'),
minutes: minutes.toString().padStart(2, '0'),
seconds: seconds.toString().padStart(2, '0'),
milliseconds: milliseconds.toString().padStart(2, '0')
};
});
// 计算结束时间的时间戳
const getEndTimeStamp = (): number => {
if (typeof props.endTime === 'number') {
return props.endTime;
} else if (typeof props.endTime === 'string') {
return new Date(props.endTime).getTime();
} else if (props.endTime instanceof Date) {
return props.endTime.getTime();
}
return 0;
};
// 初始化倒计时
const initCountdown = () => {
const endTimeStamp = getEndTimeStamp();
const now = Date.now();
if (endTimeStamp <= now) {
remainingTime.value = 0;
isEnded.value = true;
emit('end');
return;
}
remainingTime.value = endTimeStamp - now;
if (!isPaused.value) {
startCountdown();
emit('start');
}
};
// 开始倒计时
const startCountdown = () => {
if (isPaused.value || isEnded.value) return;
// 记录开始时间
lastUpdateTime = Date.now();
// 根据精度选择不同的计时方法
if (props.millisecondPrecision) {
requestAnimationFrameTimer();
} else {
setTimeoutTimer();
}
};
// setTimeout计时器
const setTimeoutTimer = () => {
if (timerId !== null) clearTimeout(timerId);
timerId = setTimeout(() => {
updateTime();
if (remainingTime.value > 0 && !isPaused.value) {
setTimeoutTimer();
}
}, 1000) as unknown as number;
};
// requestAnimationFrame计时器
const requestAnimationFrameTimer = () => {
if (rafId !== null) cancelAnimationFrame(rafId);
const frame = () => {
updateTime();
if (remainingTime.value > 0 && !isPaused.value) {
rafId = requestAnimationFrame(frame);
}
};
rafId = requestAnimationFrame(frame);
};
// 更新时间
const updateTime = () => {
const now = Date.now();
const deltaTime = now - lastUpdateTime;
lastUpdateTime = now;
remainingTime.value -= deltaTime;
if (remainingTime.value <= 0) {
remainingTime.value = 0;
isEnded.value = true;
emit('end');
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
} else {
emit('update', formattedTime.value);
}
};
// 暂停倒计时
const pause = () => {
if (isPaused.value || isEnded.value) return;
isPaused.value = true;
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
emit('pause');
};
// 恢复倒计时
const resume = () => {
if (!isPaused.value || isEnded.value) return;
isPaused.value = false;
lastUpdateTime = Date.now();
startCountdown();
emit('resume');
};
// 重置倒计时
const reset = () => {
pause();
initCountdown();
if (props.autoStart) {
resume();
}
};
// 暴露方法给父组件
const publicMethods = {
pause,
resume,
reset
};
// 监听endTime变化
watch(() => props.endTime, reset);
onMounted(() => {
isHarmony.value = isHarmonyOS();
initCountdown();
});
onUnmounted(() => {
if (timerId !== null) {
clearTimeout(timerId);
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
});
return {
remainingTime,
formattedTime,
isPaused,
isEnded,
isHarmony,
...publicMethods
};
}
});
</script>
<style>
.countdown-container {
display: flex;
align-items: center;
}
.countdown-item {
background-color: #f1f1f1;
padding: 4rpx 8rpx;
border-radius: 6rpx;
min-width: 40rpx;
text-align: center;
font-weight: bold;
}
.countdown-separator {
margin: 0 6rpx;
}
/* 鸿蒙系统专属样式 */
.harmony-countdown .countdown-item {
background: linear-gradient(to bottom, #f5f5f5, #e8e8e8);
border-radius: 12rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1);
color: #333;
font-family: 'HarmonyOS Sans', sans-serif;
}
</style>
鸿蒙系统适配工具函数
为了更好地适配鸿蒙系统,我们需要一些工具函数来检测系统环境并进行相应调整:
ts
// utils/platform.ts
/**
* 检测当前环境是否为鸿蒙系统
*/
export function isHarmonyOS(): boolean {
// #ifdef APP-PLUS
const systemInfo = uni.getSystemInfoSync();
const systemName = systemInfo.osName || '';
const systemVersion = systemInfo.osVersion || '';
// 鸿蒙系统识别
return systemName.toLowerCase().includes('harmony') ||
(systemName === 'android' && systemVersion.includes('harmony'));
// #endif
return false;
}
/**
* 优化鸿蒙系统下的定时器性能
*/
export function optimizeHarmonyTimer(): void {
// #ifdef APP-PLUS
if (!isHarmonyOS()) return;
try {
// 在鸿蒙系统中优化定时器性能
// 1. 使用plus API进行原生层优化
if (plus && plus.device && plus.device.setWakeLock) {
// 防止设备休眠影响计时器精度
plus.device.setWakeLock(true);
}
// 2. 优化requestAnimationFrame循环
if (typeof requestAnimationFrame !== 'undefined') {
const originalRAF = window.requestAnimationFrame;
window.requestAnimationFrame = function(callback) {
return originalRAF.call(window, (timestamp) => {
// 在鸿蒙系统上增加高精度时间戳处理
if (window.performance && window.performance.now) {
timestamp = window.performance.now();
}
return callback(timestamp);
});
};
}
} catch (e) {
console.error('鸿蒙计时器优化失败', e);
}
// #endif
}
/**
* 释放鸿蒙系统优化资源
*/
export function releaseHarmonyOptimization(): void {
// #ifdef APP-PLUS
if (!isHarmonyOS()) return;
try {
if (plus && plus.device && plus.device.setWakeLock) {
// 释放锁屏
plus.device.setWakeLock(false);
}
} catch (e) {
console.error('释放鸿蒙优化资源失败', e);
}
// #endif
}
高级倒计时功能
1. 秒杀倒计时组件
秒杀倒计时通常需要展示天、时、分、秒,并且有特定的样式和动画效果:
vue
<template>
<view class="seckill-countdown">
<view class="seckill-title">
<text>{{title}}</text>
</view>
<view class="seckill-timer">
<view class="time-block" v-if="showDays">
<text class="time-num">{{formattedTime.days}}</text>
<text class="time-unit">天</text>
</view>
<view class="time-block">
<text class="time-num">{{formattedTime.hours}}</text>
<text class="time-unit">时</text>
</view>
<view class="time-block">
<text class="time-num">{{formattedTime.minutes}}</text>
<text class="time-unit">分</text>
</view>
<view class="time-block">
<text class="time-num">{{formattedTime.seconds}}</text>
<text class="time-unit">秒</text>
</view>
</view>
<view class="seckill-status">
<text v-if="isEnded">活动已结束</text>
<text v-else>火热抢购中</text>
</view>
</view>
</template>
<script>
import CountdownTimer from '@/components/CountdownTimer.vue';
import { computed, ref } from 'vue';
export default {
name: 'SeckillCountdown',
props: {
title: {
type: String,
default: '限时秒杀'
},
endTime: {
type: [Number, String, Date],
required: true
},
showDays: {
type: Boolean,
default: true
}
},
setup(props) {
// 复用基础倒计时组件逻辑
const countdownRef = ref(null);
const remainingTime = ref(0);
const isEnded = ref(false);
// 格式化时间
const formattedTime = computed(() => {
const total = remainingTime.value;
const days = Math.floor(total / (1000 * 60 * 60 * 24));
const hours = Math.floor((total % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((total % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((total % (1000 * 60)) / 1000);
return {
days: days.toString().padStart(2, '0'),
hours: hours.toString().padStart(2, '0'),
minutes: minutes.toString().padStart(2, '0'),
seconds: seconds.toString().padStart(2, '0')
};
});
// 更新剩余时间
const updateRemainingTime = () => {
const endTimeStamp = typeof props.endTime === 'number' ?
props.endTime : new Date(props.endTime).getTime();
const now = Date.now();
if (endTimeStamp <= now) {
remainingTime.value = 0;
isEnded.value = true;
return;
}
remainingTime.value = endTimeStamp - now;
// 使用setTimeout模拟倒计时
setTimeout(updateRemainingTime, 1000);
};
// 初始化
updateRemainingTime();
return {
countdownRef,
formattedTime,
isEnded,
showDays: props.showDays
};
}
};
</script>
<style>
.seckill-countdown {
background: linear-gradient(135deg, #ff6b6b, #ff3366);
padding: 20rpx;
border-radius: 16rpx;
color: #fff;
}
.seckill-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 12rpx;
}
.seckill-timer {
display: flex;
justify-content: center;
margin: 16rpx 0;
}
.time-block {
position: relative;
margin: 0 8rpx;
text-align: center;
}
.time-num {
display: block;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 8rpx;
padding: 8rpx 12rpx;
min-width: 60rpx;
font-size: 32rpx;
font-weight: bold;
}
.time-unit {
display: block;
font-size: 24rpx;
margin-top: 4rpx;
}
.seckill-status {
font-size: 28rpx;
text-align: center;
margin-top: 10rpx;
}
/* 动画效果 */
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.time-num {
animation: pulse 1s infinite;
}
</style>
2. 验证码倒计时按钮
验证码倒计时是移动应用中的另一个常见场景:
vue
<template>
<view class="verification-code">
<input
type="text"
class="code-input"
placeholder="请输入验证码"
maxlength="6"
v-model="code"
/>
<button
class="send-btn"
:disabled="isCounting || !isMobileValid"
:class="{'counting': isCounting, 'harmony-btn': isHarmony}"
@click="sendCode"
>
<text v-if="!isCounting">获取验证码</text>
<text v-else>{{countdown}}秒后重发</text>
</button>
</view>
</template>
<script lang="ts">
import { defineComponent, ref, computed, onUnmounted } from 'vue';
import { isHarmonyOS } from '@/utils/platform';
export default defineComponent({
name: 'VerificationCodeButton',
props: {
mobile: {
type: String,
default: ''
},
duration: {
type: Number,
default: 60
}
},
emits: ['send'],
setup(props, { emit }) {
const code = ref('');
const countdown = ref(props.duration);
const isCounting = ref(false);
const isHarmony = ref(false);
let timer: number | null = null;
// 验证手机号是否有效
const isMobileValid = computed(() => {
return /^1[3-9]\d{9}$/.test(props.mobile);
});
// 发送验证码
const sendCode = () => {
if (isCounting.value || !isMobileValid.value) return;
// 触发发送事件
emit('send', props.mobile);
// 开始倒计时
startCountdown();
};
// 开始倒计时
const startCountdown = () => {
isCounting.value = true;
countdown.value = props.duration;
timer = setInterval(() => {
if (countdown.value <= 1) {
stopCountdown();
} else {
countdown.value--;
}
}, 1000) as unknown as number;
};
// 停止倒计时
const stopCountdown = () => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
isCounting.value = false;
countdown.value = props.duration;
};
// 检测系统环境
onMounted(() => {
isHarmony.value = isHarmonyOS();
});
// 组件销毁时清理定时器
onUnmounted(() => {
if (timer !== null) {
clearInterval(timer);
}
});
return {
code,
countdown,
isCounting,
isMobileValid,
isHarmony,
sendCode
};
}
});
</script>
<style>
.verification-code {
display: flex;
align-items: center;
width: 100%;
}
.code-input {
flex: 1;
height: 80rpx;
border: 1rpx solid #ddd;
border-radius: 8rpx;
padding: 0 20rpx;
margin-right: 20rpx;
}
.send-btn {
width: 220rpx;
height: 80rpx;
line-height: 80rpx;
text-align: center;
font-size: 28rpx;
color: #fff;
background-color: #007aff;
border-radius: 8rpx;
}
.send-btn:disabled {
background-color: #ccc;
color: #fff;
}
.send-btn.counting {
background-color: #f5f5f5;
color: #999;
border: 1rpx solid #ddd;
}
/* 鸿蒙系统特有样式 */
.harmony-btn {
border-radius: 20rpx;
background: linear-gradient(to bottom, #0091ff, #007aff);
font-family: 'HarmonyOS Sans', sans-serif;
box-shadow: 0 4rpx 12rpx rgba(0, 122, 255, 0.3);
}
.harmony-btn:disabled {
background: #e1e1e1;
box-shadow: none;
}
.harmony-btn.counting {
background: #f8f8f8;
color: #666;
border: none;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
</style>
鸿蒙系统适配关键点
在为鸿蒙系统进行适配时,需要注意以下几个方面:
1. 性能优化
鸿蒙系统的渲染引擎与Android有所不同,因此在处理高频刷新的倒计时组件时需要特别注意性能优化:
-
使用requestAnimationFrame代替setInterval:对于毫秒级的倒计时,使用requestAnimationFrame可以获得更平滑的效果。
-
避免DOM频繁更新:减少不必要的DOM更新,可以使用计算属性缓存格式化后的时间。
-
使用硬件加速 :在样式中添加
transform: translateZ(0)
来启用硬件加速。
css
.countdown-item {
transform: translateZ(0);
backface-visibility: hidden;
}
2. UI适配
鸿蒙系统有其独特的设计语言,需要对UI进行相应调整:
-
字体适配:鸿蒙系统推荐使用HarmonyOS Sans字体。
-
圆角和阴影:鸿蒙系统的设计更倾向于使用较大的圆角和更柔和的阴影。
-
渐变色:适当使用渐变色可以使UI更符合鸿蒙系统风格。
3. 系统API适配
鸿蒙系统提供了一些特有的API,可以利用这些API提升用户体验:
js
// #ifdef APP-PLUS
if (isHarmonyOS()) {
// 使用鸿蒙特有的震动反馈API
if (plus.os.name === 'Android' && plus.device.vendor === 'HUAWEI') {
try {
// 调用华为设备的特殊振动API
plus.device.vibrate(10);
} catch (e) {
console.error('震动API调用失败', e);
}
}
}
// #endif
应用场景
电商秒杀
在电商应用中,秒杀活动是一个典型的倒计时应用场景。用户需要清楚地看到活动开始还有多长时间,或者活动结束还有多长时间。
验证码获取
注册或登录时,发送验证码后通常需要倒计时防止用户频繁点击发送按钮。
考试计时
在在线教育应用中,考试时间倒计时是一个重要功能,需要精确的时间显示。
健身计时器
在健身应用中,可以用倒计时组件实现HIIT训练计时器、休息时间倒计时等功能。
性能优化技巧
1. 使用防抖和节流
对于用户交互频繁的场景,可以使用防抖和节流技术避免不必要的计算:
js
// 防抖函数
function debounce(fn, delay) {
let timer = null;
return function(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 节流函数
function throttle(fn, interval) {
let last = 0;
return function(...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}
2. 使用Web Worker
对于复杂的时间计算,可以使用Web Worker在后台线程中进行,避免阻塞主线程:
js
// 创建Worker
const worker = new Worker('/countdown-worker.js');
// 监听Worker消息
worker.onmessage = function(e) {
remainingTime.value = e.data.remainingTime;
};
// 发送消息给Worker
worker.postMessage({
action: 'start',
endTime: getEndTimeStamp(),
interval: 1000
});
// 组件销毁时终止Worker
onUnmounted(() => {
worker.terminate();
});
3. 使用RAF优化动画
对于需要流畅动画效果的倒计时,使用requestAnimationFrame可以获得更好的性能:
js
let lastTime = 0;
let rafId = null;
function animate(timestamp) {
if (!lastTime) lastTime = timestamp;
const deltaTime = timestamp - lastTime;
if (deltaTime >= 16) { // 约60fps
lastTime = timestamp;
updateCountdown();
}
rafId = requestAnimationFrame(animate);
}
rafId = requestAnimationFrame(animate);
// 清理
function cleanup() {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
总结
本文详细介绍了如何使用UniApp实现功能完善的倒计时组件,并重点关注了鸿蒙系统的适配问题。通过合理的架构设计、性能优化和UI适配,我们可以开发出在各平台(特别是鸿蒙系统)上都能流畅运行的倒计时组件。
倒计时虽然是一个看似简单的功能,但要实现精确、稳定且性能良好的倒计时组件,需要考虑许多细节问题。希望本文对你在UniApp开发中实现倒计时功能有所帮助。