抛弃 setInterval!用 requestAnimationFrame 实现毫秒级精准倒计时
从"时间漂移"到"帧级精准",让你的倒计时不再"偷跑"或" lag"
前言:为什么 setInterval 不够用了?
做过倒计时的前端都知道,用 setInterval 写个倒计时看似简单,但上线后总会收到奇怪的反馈:
- "倒计时怎么快了 2 秒?" ------ 浏览器节流/休眠导致
- "切个 tab 回来时间不对了" ------ 后台标签页 setInterval 被降频到 1s 一次
- "0.01 秒跳动不均匀" ------ 16.6ms 的渲染帧 vs 10ms 的倒计时粒度不匹配
js
// 传统方案的问题:时间漂移累积
setInterval(() => {
remainingTime -= 10 // 假设 10ms 间隔
// 实际可能 15ms 才执行一次,100 次后就漂移了 500ms!
}, 10)
核心矛盾 :setInterval 是"基于调用"的,而我们需要的是"基于时间"的倒计时。
requestAnimationFrame:帧级精度的时间守护者
rAF 的优势:
- 与屏幕刷新同步:60Hz/120Hz 显示器自动适配
- 后台标签页自动节流:切 tab 时暂停,切回来自动补偿
- 高精度时间戳 :
performance.now()微秒级精度
核心思路
scss
rAF 循环
↓
获取当前时间 performance.now()
↓
计算已过去的时间 = 当前 - 开始时间
↓
剩余时间 = 总时长 - 已过去
↓
更新 UI
↓
如果剩余 > 0,继续 rAF
基础实现:毫秒级倒计时 Hook
js
// composables/usePreciseCountdown.js
import { ref, computed, onMounted, onUnmounted } from 'vue'
export function usePreciseCountdown(targetMs, options = {}) {
const {
immediate = true,
onFinish = () => {},
onTick = () => {}
} = options
// 状态
const isRunning = ref(false)
const isFinished = ref(false)
const startTime = ref(0)
const elapsed = ref(0)
let rafId = null
let pausedAt = 0 // 记录暂停时已过时间
// 计算属性:格式化的剩余时间
const remaining = computed(() => {
const left = Math.max(0, targetMs - elapsed.value)
return {
total: left,
seconds: Math.floor(left / 1000),
milliseconds: Math.floor((left % 1000) / 10), // 取 0-99
formatted: `${Math.floor(left / 1000)}.${String(Math.floor((left % 1000) / 10)).padStart(2, '0')}`
}
})
const tick = (timestamp) => {
if (!isRunning.value) return
// 首次运行记录开始时间
if (!startTime.value) {
startTime.value = timestamp - pausedAt
}
// 计算已过时间(关键:基于时间差,而非累加)
elapsed.value = timestamp - startTime.value
// 触发回调
onTick(remaining.value)
if (elapsed.value >= targetMs) {
// 结束
elapsed.value = targetMs
isRunning.value = false
isFinished.value = true
onFinish()
rafId = null
} else {
// 继续下一帧
rafId = requestAnimationFrame(tick)
}
}
const start = () => {
if (isRunning.value) return
isRunning.value = true
isFinished.value = false
// 恢复时重置 startTime,保持 elapsed 连续
startTime.value = performance.now() - elapsed.value
rafId = requestAnimationFrame(tick)
}
const pause = () => {
if (!isRunning.value) return
isRunning.value = false
pausedAt = elapsed.value
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
}
const reset = (newTarget = targetMs) => {
pause()
elapsed.value = 0
pausedAt = 0
startTime.value = 0
isFinished.value = false
if (immediate) start()
}
onMounted(() => {
if (immediate) start()
})
onUnmounted(() => {
if (rafId) cancelAnimationFrame(rafId)
})
return {
remaining,
isRunning,
isFinished,
start,
pause,
reset,
elapsed
}
}
使用方式:
vue
<template>
<div class="countdown">
<span class="seconds">{{ remaining.seconds }}</span>
<span class="decimal">.{{ String(remaining.milliseconds).padStart(2, '0') }}</span>
</div>
<div class="controls">
<button @click="start" :disabled="isRunning">开始</button>
<button @click="pause" :disabled="!isRunning">暂停</button>
<button @click="() => reset(30000)">重置 30s</button>
</div>
</template>
<script setup>
import { usePreciseCountdown } from './composables/usePreciseCountdown'
const { remaining, isRunning, start, pause, reset } = usePreciseCountdown(
60000, // 60 秒
{
immediate: false,
onTick: (time) => {
// 可以在这里发事件给父组件
// console.log('剩余:', time.formatted)
},
onFinish: () => {
console.log('倒计时结束!')
}
}
)
</script>
<style scoped>
.countdown {
font-family: 'Roboto Mono', monospace;
font-size: 4rem;
font-weight: 700;
color: #ff6b6b;
text-align: center;
padding: 2rem;
}
.decimal {
font-size: 2.5rem;
color: #ff8787;
}
</style>
进阶:视觉优化与性能
1. 减少不必要的渲染
rAF 每 16ms(60fps)调用一次,但如果只显示 0.01 秒(10ms)精度,可以节流渲染:
js
// 在 tick 函数中加入渲染节流
let lastRender = 0
const tick = (timestamp) => {
// ... 时间计算逻辑
// 每 30ms 渲染一次(约 33fps),足够显示 0.01 秒
if (timestamp - lastRender > 30) {
// 触发 Vue 响应式更新
renderTime.value = remaining.value
lastRender = timestamp
}
rafId = requestAnimationFrame(tick)
}
2. 后台标签页优化
利用 visibilitychange 检测页面可见性,切回时自动校准:
js
const handleVisibilityChange = () => {
if (document.hidden) {
// 页面隐藏时记录时间
pausedAt = performance.now()
} else {
// 切回时补偿时间差
const drift = performance.now() - pausedAt
startTime.value += drift // 调整起始时间,消除漂移
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
3. 数字跳动动画
让倒计时更有"紧迫感":
vue
<template>
<div class="countdown-container">
<transition name="flip" mode="out-in">
<span :key="remaining.formatted" class="time-text">
{{ remaining.formatted }}
</span>
</transition>
</div>
</template>
<style>
.flip-enter-active, .flip-leave-active {
transition: all 0.15s ease-out;
}
.flip-enter-from {
transform: translateY(-20px);
opacity: 0;
}
.flip-leave-to {
transform: translateY(20px);
opacity: 0;
}
</style>
完整对比:rAF vs setInterval
| 特性 | setInterval | requestAnimationFrame |
|---|---|---|
| 精度 | 受事件循环影响,可能累积漂移 | 帧级同步,基于时间差计算,无漂移 |
| 后台运行 | 继续执行(可能被节流) | 自动暂停,切回自动补偿 |
| 性能 | 固定频率执行,可能浪费计算 | 与渲染同步,自然节流 |
| 电池消耗 | 后台仍占用 CPU | 后台自动优化 |
| 代码复杂度 | 简单 | 需管理时间戳,稍复杂 |
| 适用场景 | 秒级粗略倒计时 | 毫秒级精准计时、动画同步 |
实战场景:秒杀倒计时组件
vue
<template>
<div class="seckill-countdown" :class="{ urgent: remaining.total < 10000 }">
<div class="label">距离结束仅剩</div>
<div class="time">
<span class="num">{{ remaining.formatted }}</span>
<span class="unit">秒</span>
</div>
<div class="progress-bar">
<div class="progress" :style="{ width: progressPercent + '%' }" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { usePreciseCountdown } from './usePreciseCountdown'
const props = defineProps({
endTime: Number // 结束时间戳
})
const targetMs = computed(() => props.endTime - Date.now())
const { remaining, isFinished } = usePreciseCountdown(targetMs, {
onFinish: () => {
// 触发秒杀结束事件
emit('finish')
}
})
const progressPercent = computed(() => {
return (remaining.value.total / targetMs.value) * 100
})
</script>
<style scoped>
.urgent .num {
color: #ff0000;
animation: pulse 0.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
</style>
总结
| 要点 | 说明 |
|---|---|
| 核心原理 | 用 performance.now() 计算时间差,而非累加 |
| 精度保障 | rAF + 时间戳校准,消除 setInterval 的漂移问题 |
| 性能优化 | 合理节流渲染、处理后台标签页 |
| 扩展性 | 可轻松添加暂停、重置、进度条等功能 |
适用场景 :秒杀倒计时、验证码 60s 倒计时、游戏计时器、在线考试限时等任何需要可靠时间保证的场景。
代码已整理为可复用的 Vue3 Hook,直接复制到项目即可使用。如果对你有帮助,欢迎点赞收藏,评论区交流优化思路 👇