导读:本期聚焦于公主创作的《Vue 3 数字滚动动画怎么做?从 0 到目标值的平滑过渡实现详解》,敬请观看详情。数据大屏和后台首页中的销售额、访问量等统计指标,如果直接显示最终数字,视觉冲击力往往不够。让数字从 0 平滑滚动到目标值,是一种常见且效果出色的交互方式。本文围绕 Vue 3 展开,介绍如何利用 requestAnimationFrame 编写自定义组合式函数实现数字滚动动画,包括缓动函数的选择、动画时长与帧率控制、响应式数据驱动更新等核心内容,同时对比基于定时器与 CSS 动画的两种替代方案,分析各自优缺点,并给出防止内存泄漏、处理小数位数、组件卸载时清理任务的实用建议,帮助你快速实现一个可复用的数字滚动组件。

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

Vue 3 数字滚动动画怎么做?从 0 到目标值的平滑过渡实现详解

一、数字滚动的核心原理与 requestAnimationFrame

数字滚动动画本质上是一个补间动画:已知起始值(通常是 0)和目标值,在指定的动画时长内,按时间进度计算出每一帧应该显示的中间值。计算公式可以简化为:当前值 = 起始值 + (目标值 - 起始值) × 进度,其中进度是一个 0 到 1 之间的小数。

驱动这个进度更新的方式有三种常见选择:setIntervalsetTimeout 递归调用和 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

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。