为什么你的 0.01 秒倒计时看起来一卡一卡的?

抛弃 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,直接复制到项目即可使用。如果对你有帮助,欢迎点赞收藏,评论区交流优化思路 👇

相关推荐
onebyte8bits2 小时前
NestJS 系列教程(十八):文件上传与对象存储架构(Multer + S3/OSS + 访问控制)
前端·架构·node.js·状态模式·nestjs
Ruihong2 小时前
放弃 Vue3 传统 <script>!我的 VuReact 编译器做了一次清醒取舍
前端·vue.js
weixin_456164832 小时前
vue3 父组件向子组件传参
前端
Beginner x_u2 小时前
前端八股整理|CSS|高频小题 01
前端·css·八股
蜡台2 小时前
IDEA LiveTemplates Vue ElementUI
前端·vue.js·elementui·idea·livetemplates
E-cology2 小时前
【泛微低代码开发平台e-builder】使用HTML组件实现页面中部分区域自定义开发
前端·低代码·泛微·e-builder
用户9751470751362 小时前
如何使用Promise.any()处理多个异步操作?
前端
yuki_uix2 小时前
只渲染「必要的部分」:从 DepartmentTree 和 VirtualList 看前端的两种裁剪哲学
前端·面试
苏瞳儿2 小时前
前端/后端-配置跨域
前端·javascript·node.js·vue