数据大屏、营销落地页、后台首页,经常会出现销售额、用户数、订单量这类统计指标。如果这些数字一加载就直接显示最终结果,页面会显得平淡;而让数字从 0 逐渐滚动到目标值,既能吸引用户注意,也能强化数据增长的感觉。在 Vue 3 中实现这类动画非常简单,核心思路是:在一段时间内,按照一定的插值算法不断更新一个响应式变量,浏览器每次重绘时把中间值渲染到页面上。本文将从原理、实现、封装与常见坑四个层面,完整讲解数字滚动动画的落地过程。

一、数字滚动的核心原理与 requestAnimationFrame
数字滚动动画本质上是一个补间动画:已知起始值(通常是 0)和目标值,在指定的动画时长内,按时间进度计算出每一帧应该显示的中间值。计算公式可以简化为:当前值 = 起始值 + (目标值 - 起始值) × 进度,其中进度是一个 0 到 1 之间的小数。
驱动这个进度更新的方式有三种常见选择:setInterval、setTimeout 递归调用和 requestAnimationFrame。前两者按固定时间间隔执行,与浏览器渲染节奏不同步,容易出现掉帧或过度计算;而 requestAnimationFrame(以下简称 rAF)由浏览器在下一次重绘之前调用回调函数,与屏幕刷新率天然对齐,动画更流畅,且在页面切到后台时会自动暂停,节省资源。因此在实现数字滚动这类视觉动画时,rAF 是首选方案。
一个最小的原生实现如下:
function animateNumber(from, to, duration, onUpdate) {
const startTime = performance.now();
function frame(now) {
const elapsed = now - startTime;
// 计算进度,限制在 0 到 1 之间
const progress = Math.min(elapsed / duration, 1);
const value = from + (to - from) * progress;
onUpdate(value);
if (progress < 1) {
requestAnimationFrame(frame);
}
}
requestAnimationFrame(frame);
}上面的代码中,performance.now() 返回高精度时间戳,rAF 的回调参数 now 同样是时间戳,两者相减得到已经过去的毫秒数。当进度达到 1 时停止递归调用,动画结束。这个线性插值的效果是匀速滚动,真实项目中通常会配合缓动函数让动画更自然。
二、封装成 Vue 3 组合式函数 useCountUp
Vue 3 的 Composition API 非常适合把动画逻辑抽成可复用的组合式函数。我们把动画状态定义为 ref,目标值变化时自动重新播放动画,组件卸载时自动清理,使用方只需要关心结果值。
import { ref, watch, onUnmounted } from 'vue';
export function useCountUp(target, options = {}) {
const { duration = 1500, decimals = 0 } = options;
const displayValue = ref(0);
let rafId = null;
// easeOutQuart 缓动函数:先快后慢
const easeOutQuart = (t) => 1 - Math.pow(1 - t, 4);
function animate(from, to) {
if (rafId !== null) cancelAnimationFrame(rafId);
const startTime = performance.now();
const step = (now) => {
const progress = Math.min((now - startTime) / duration, 1);
const eased = easeOutQuart(progress);
const value = from + (to - from) * eased;
displayValue.value = parseFloat(value.toFixed(decimals));
if (progress < 1) {
rafId = requestAnimationFrame(step);
}
};
rafId = requestAnimationFrame(step);
}
watch(target, (newVal, oldVal) => {
animate(oldVal ?? 0, newVal);
}, { immediate: true });
onUnmounted(() => {
if (rafId !== null) cancelAnimationFrame(rafId);
});
return { displayValue };
}使用时只需传入一个响应式的目标值,例如 const target = ref(88888),然后在模板中渲染 displayValue。当 target 被 API 请求结果更新时,watch 会触发动画重新播放,且从上一次的值平滑过渡到新值,而不是生硬地跳回 0 再滚一遍,这一细节对体验提升明显。
缓动函数的选择也很关键。常用的 easeOutQuart 呈现先快后慢的节奏,符合用户对数据快速增长然后趋于稳定的直觉;如果想要回弹效果,可以使用 easeOutBack;需要匀速则直接返回进度值本身。主流缓动库中都有几十种曲线公式,都是一行代码就能移植的纯函数,可以按需替换。
三、封装为通用组件并处理格式化
除了组合式函数,更常见的交付形态是一个独立组件。组件负责接收 props、控制动画触发时机(如进入视口时才播放),并把数字格式化为千分位字符串。进入视口触发可以借助 IntersectionObserver,避免页面还没滚动到该区域动画就已经播完。
<template>
<span ref="rootEl">{{ formatted }}</span>
</template>
<script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue';
const props = defineProps({
target: { type: Number, required: true },
duration: { type: Number, default: 1500 },
decimals: { type: Number, default: 0 }
});
const displayValue = ref(0);
const rootEl = ref(null);
let rafId = null;
const formatted = computed(() =>
displayValue.value.toLocaleString('zh-CN', {
minimumFractionDigits: props.decimals,
maximumFractionDigits: props.decimals
})
);
function play() {
const startTime = performance.now();
const step = (now) => {
const p = Math.min((now - startTime) / props.duration, 1);
const eased = 1 - Math.pow(1 - p, 4);
displayValue.value = props.target * eased;
if (p < 1) rafId = requestAnimationFrame(step);
else displayValue.value = props.target; // 确保最终值精确
};
rafId = requestAnimationFrame(step);
}
let observer = null;
onMounted(() => {
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
play();
observer.disconnect();
}
}, { threshold: 0.3 });
observer.observe(rootEl.value);
});
onUnmounted(() => {
if (rafId) cancelAnimationFrame(rafId);
observer && observer.disconnect();
});
</script setup>这个组件用到了几个实用技巧:第一,toLocaleString 自带千分位分隔符,比手写正则更可靠;第二,IntersectionObserver 的回调只在元素可见性变化时触发,播完动画立即 disconnect,开销极低;第三,格式化逻辑放在 computed 中,动画每帧只更新原始数值,格式化由 Vue 的响应式系统自动处理,逻辑清晰且易于测试。
四、常见坑与替代方案对比
实现过程中有几个容易踩的坑需要提醒。首先是小数精度问题:如果直接把插值结果赋给响应式变量,浮点误差会导致页面出现类似 88887.999999 的难看数字,解决方式是每帧用 toFixed 处理精度,并在动画结束时强制把最终值设为目标值,确保收尾干净。其次是动画结束值与目标值不一致:由于缓动函数只有在进度等于 1 时才精确到达目标,一定要保证最后一帧执行赋值,不要在进度小于 1 时提前结束。
第三是组件卸载后的清理。如果用户在动画播放中途离开了页面,未取消的 rAF 回调会继续引用已卸载的组件状态,在 onUnmounted 中调用 cancelAnimationFrame 是必须的动作。同理,使用 setInterval 实现的版本必须在卸载时 clearInterval,否则可能造成内存泄漏。
关于替代方案也值得了解。用 setInterval 每 16 毫秒加一个固定增量,实现最简单,但帧率与屏幕刷新不同步,且后台标签页仍会持续执行;用 CSS 动画配合 CSS 变量(通过 @property 注册可动画的数字变量)可以做到接近零 JS 开销,但兼容性依赖较新的浏览器特性,且难以处理中途改变目标值的场景。综合来看,基于 rAF 的组合式函数方案在流畅度、可控性和兼容性之间取得了最好的平衡,是 Vue 3 项目中实现数字滚动动画的推荐做法。
Vue 3数字滚动动画requestAnimationFrame修改时间:2026-08-31 12:45:15