1. 先搞清楚需求数字滚动和翻牌器到底是什么做数据可视化大屏这几年我几乎在每个项目里都会遇到同一个需求把关键指标用一种更“显眼”的方式展示出来——要么是一串数字快速滚动到最后的目标值要么是一个个数字卡片像老式翻页钟一样翻出最新数字。前者就是数字滚动后者是翻牌器。它们本质上都不复杂但要做好、做得顺滑、做得耐用还是有很多门道的。这篇文章我会基于 Vue 3 组合式 API 手写一个小项目一个可复用的数字滚动组件和一个翻牌器组件。看完你不仅能直接复制到自己的项目里用还能理解背后的动画原理以后遇到任何“数字动效”的需求都能自己搞定。先说结论这两类组件都适合放在数据可视化大屏、实时监控看板、运营报表、比赛计分等场景。数字滚动适合“从旧值过渡到新值”的连续变化比如在线人数从 10086 涨到 10230翻牌器适合“快速切换、逐位更新”的离散刷新比如交易额每隔几秒钟整体刷新一次。把这两个应用场景搞反了视觉上会非常突兀。1.1 两种动效的视觉差异数字滚动更像一个“过程动画”它把数值从 A 点到 B 点的变化过程用动画表现出来了。用户能看到数字在跳动但不会逐位去读重点在于“趋势感”——数据正在往上走有种生机勃勃的气氛。翻牌器则强调“瞬间更新”每次刷新都像打卡机翻页码视觉上是一个明确的“啪”一下的切换适合那种需要提醒用户“数据刚刚更新了”的场合。这两种效果还可以混用比如大屏中间用数字滚动展示累计销售额旁边用翻牌器展示今日订单量。这也是我这次在示例项目里采用的做法左侧一个滚动数字右侧一组四位翻牌器两个组件共享同一份数据源每三秒刷新一次时各自动起来。混用的时候要注意节奏不能让两个动画同时抢视觉焦点否则页面会显得很吵。1.2 为什么不用现成库自己动手写值得吗省流值得。流行的方案有 vue-count-to、CountUp.js 配合 Vue 的 wrapper还有各种大屏模板自带的滚动组件。但自己写有几个实打实的好处体积可控。一个滚动组件压缩完也就几 KB不用为了一个小动画引入一个几十 KB 的动画库。样式完全自定义。现成库的默认样式常常带背景、边框、渐变色改起来比重新写还费劲。容易和业务逻辑耦合。真实项目里数字往往有千分位、小数位、前缀后缀、单位切换甚至需要从接口拿数据后先格式化再滚动。把这些都做成 props业务代码会干净很多。理解原理比会调用重要。面试里问“数字滚动怎么实现”如果只会回答“我用过 count-to”基本等于没答到点子上。当然如果你只是临时做一个 demo用现成库完全没问题。但如果你预感到这个效果要在多个项目里复用我强烈建议手写一次。写完之后你会对 requestAnimationFrame 动画、组件封装边界、格式化兜底这些东西有更深的理解这些才是前端里真正值钱的经验。2. 数字滚动组件核心原理与完整实现2.1 核心思路requestAnimationFrame 驱动补间动画数字滚动的本质就是在一个时间区间内不断地计算“中间值”再把它渲染到页面上。专业一点叫补间动画tween animation。有几个关键选择要说明白为什么不用 setIntervalsetInterval 有最小间隔限制而且掉帧、后台标签页节流都是问题。requestAnimationFrame 由浏览器在下一帧渲染之前调用帧率与屏幕刷新率同步动画更顺滑而且在标签页不可见时会自动暂停不浪费 CPU。为什么不用 CSS transitionCSS 的 transition 和 animation 只能针对确定的属性做过渡数字本身是文本内容不能直接做 transition。虽然可以用 property 注册自定义属性做数字动画但兼容性一般而且处理千分位、小数点、动态时长时很别扭。所以 JS 驱动是更通用、更可控的方案。核心公式是标准线性插值currentValue startValue (endValue - startValue) * progressprogress 范围是 0 到 1代表动画进度。如果直接线性插值数字会匀速滚动看久了有点生硬。所以一般会加一个缓动函数 easing。我常用 easeOutCubicprogress 1 - Math.pow(1 - progress, 3)这个函数让数字先快后慢更接近真实世界的运动规律观感上比匀速滚动自然得多。2.2 组件设计一组够用的 props我在真实项目里把滚动数字组件设计成了这样一组 props参数类型默认值说明valueNumber0目标数值变化时自动触发滚动durationNumber800动画时长单位 msdecimalsNumber0小数位数thousandBooleantrue是否显示千分位逗号prefixString数字前缀比如 ¥suffixString数字后缀比如 人easingFunctioneaseOutCubic缓动函数这里有个细节前缀和后缀我倾向于由父组件通过插槽或者字符串传入而不是让组件内部去判断是货币还是人数。这样组件保持纯粹——它只负责“把数字变过去”展示格式交给调用方。插槽方案比字符串更灵活以后想在前缀里加个图标或者发光样式都不用改组件内部代码。2.3 核心实现代码Vue 3 组合式 API这个组件基于 Vue 3.2 的 script setup 写法动画全靠 requestAnimationFrame不依赖任何第三方库。template span classscroll-number slot nameprefix{{ prefix }}/slot span :data-valuedisplayValue{{ displayText }}/span slot namesuffix{{ suffix }}/slot /span /template script setup import { ref, watch, computed, onBeforeUnmount } from vue const props defineProps({ value: { type: Number, default: 0 }, duration: { type: Number, default: 800 }, decimals: { type: Number, default: 0 }, thousand: { type: Boolean, default: true }, prefix: { type: String, default: }, suffix: { type: String, default: }, easing: { type: Function, default: (t) 1 - Math.pow(1 - t, 3) } }) const displayValue ref(props.value) let rafId null function format(value) { let fixed value.toFixed(props.decimals) if (!props.thousand) return fixed let parts fixed.split(.) parts[0] parts[0].replace(/\B(?(\d{3})(?!\d))/g, ,) return parts.join(.) } const displayText computed(() format(displayValue.value)) function animateTo(target) { if (rafId) cancelAnimationFrame(rafId) const from displayValue.value const to Number(target) if (from to) return const startTime performance.now() const tick (now) { const elapsed now - startTime const progress Math.min(elapsed / props.duration, 1) const eased props.easing(progress) const current from (to - from) * eased displayValue.value current if (progress 1) { rafId requestAnimationFrame(tick) } else { displayValue.value to rafId null } } rafId requestAnimationFrame(tick) } watch(() props.value, (val) { animateTo(val) }, { immediate: true }) onBeforeUnmount(() { if (rafId) cancelAnimationFrame(rafId) }) /script这段代码的核心是animateTo函数几个细节解释一下performance.now()获取的是高精度时间戳比Date.now()更稳定避免系统时间被调整导致动画异常。Math.min(elapsed / duration, 1)保证进度不会超过 1否则最后会超出目标值又跳回来。displayValue用 ref动画过程中每一帧都修改它触发 computed 重新格式化再渲染到页面上。Vue 3 的响应式更新粒度很小一个文本节点的更新非常便宜不用担心性能。onBeforeUnmount里 cancel 掉动画这是必须的。组件销毁后 requestAnimationFrame 还在跑轻则控制台报错严重时会把已销毁组件的 DOM 更新一遍造成不可预料的画面残留。2.4 千分位和小数点格式化里的坑格式化是我写这个组件时踩坑最多的地方。toFixed有个隐患它返回的是字符串而且会四舍五入。如果decimals 0你希望显示的是整数但动画过程中会出现 99.6、100.2 这种中间值直接Math.round比toFixed更合适。我这里用toFixed是因为动画过程中保留小数位反而更真实比如金额保留两位滚动时能看到小数跳动观感很像老式计算器。千分位正则/\B(?(\d{3})(?!\d))/是经典写法但要注意它只能处理整数部分。所以代码里先split(.)只处理整数部分再把小数部分拼回来。如果你直接把正则作用在整个字符串上小数位也会被插入逗号比如 1234.56 会变成 1,234.5,6看着极其诡异。另外动画过程中频繁调用toFixed和正则替换每次都会生成新字符串这在单摆数字上完全没问题但如果一屏有几十个这样的组件建议把格式化结果做一层缓存或者用useMemoized思路处理。我在自己项目里测试过50 个滚动组件同时动画帧率还是能稳定在 60fps所以一般不用做额外优化。3. 翻牌器组件用 CSS 3D 做老式翻页数字钟3.1 翻牌器的物理结构拆解老式翻页数字钟split-flap display是怎么工作的每一个数字位都是一组卡片卡片上半部分固定在顶部下半部分可以翻转。时间更新时上半部分先翻下露出新数字的上半部分接着下半部分翻上来新数字完全露出来。一个简单的翻牌器其实只需要模拟“翻转”这两个动作。放到 Web 里我把每个数字位做成一个 3D 容器里面放四层内容静态上半部分保存当前显示数字的上半段静态下半部分保存更新后数字的下半段动态上半遮罩动画开始时从旧数字上半段翻到新数字上半段动态下半遮罩动画后半段从新数字下半段翻起来听起来复杂但 CSS 3D transform 可以做得非常简洁。每个翻牌器位的 HTML 结构大致是这样div classflip-card !-- 上半部分静态数字变化前是旧值 -- div classflip-card__half flip-card__half--top span0/span /div !-- 下半部分静态数字变化后是新值 -- div classflip-card__half flip-card__half--bottom span0/span /div !-- 机械结构所需的两个动态部分 -- div classflip-card__flip flip-card__flip--top span旧/span /div div classflip-card__flip flip-card__flip--bottom span新/span /div /div这 4 个部分是翻牌效果的骨架也是很多翻牌插件变形的基准。理解它的关键在于静态的上半部分和下半部分负责在动画结束后显示最终值两个动态部分负责在动画过程中“演”出翻转过程动画结束后隐藏。3.2 CSS 3D 翻转核心代码给每个动态部分加上 3D 定位和翻转动画。我用的是 Vue 里的 class 切换控制动画先看 CSS.flip-card { perspective: 400px; position: relative; width: 60px; height: 80px; border-radius: 4px; background: #1a1a2e; color: #fff; } .flip-card__half { position: absolute; left: 0; width: 100%; height: 50%; overflow: hidden; line-height: 80px; text-align: center; } .flip-card__half--top { top: 0; border-bottom: 1px solid rgba(0,0,0,0.2); } .flip-card__half--bottom { bottom: 0; border-top: 1px solid rgba(0,0,0,0.2); } .flip-card__half--top span, .flip-card__half--bottom span { display: block; width: 100%; height: 80px; } .flip-card__half--top span { line-height: 80px; } .flip-card__half--bottom span { line-height: 0; } .flip-card__flip { position: absolute; left: 0; width: 100%; height: 50%; overflow: hidden; backface-visibility: hidden; } .flip-card__flip--top { top: 0; transform-origin: bottom center; animation: flipTop 0.3s ease-in forwards; } .flip-card__flip--top span { display: block; width: 100%; height: 80px; line-height: 80px; text-align: center; } .flip-card__flip--bottom { bottom: 0; transform-origin: top center; transform: rotateX(90deg); animation: flipBottom 0.3s ease-out 0.3s forwards; } .flip-card__flip--bottom span { display: block; width: 100%; height: 80px; line-height: 0; text-align: center; } keyframes flipTop { from { transform: rotateX(0deg); } to { transform: rotateX(-90deg); } } keyframes flipBottom { from { transform: rotateX(90deg); } to { transform: rotateX(0deg); } }几点说明perspective: 400px是关键它给整个卡片创造了纵深感数字翻转时才有“厚度”。数值越小透视越强太大的话几乎看不出立体感。backface-visibility: hidden保证旋转到背面时不显示镜像内容否则会把数字倒着透出来。动画时长的选择上半翻转 0.3s下半翻转再 0.3s中间用animation-delay: 0.3s衔接。总时长 0.6s用户能明显感知到一次“啪”的翻动又不至于太慢。如果你做的是高频刷新的大屏可以缩到 0.2s 0.2s。下半部分翻转前先rotateX(90deg)动画到0deg目的是从“贴在一起”的状态翻开。3.3 从 Vue 数据到翻牌动作如何触发一次完整的翻牌CSS 只管动画Vue 管数据。触发一次翻牌在组件内部可以这样做接收到新的 value把当前显示值暂存为 oldValue把新值赋给下半部分的静态内容和翻转遮罩通过 class 切换触发 CSS 动画动画结束后把新值同步到上半部分静态内容。这里最容易犯的错误是动画结束后“状态没同步”。比如上半部分的静态内容必须等下半翻上来后再更新否则会出现新数字提前露出来的 bug。我把封装逻辑写出来script setup import { ref, watch, nextTick, onBeforeUnmount } from vue const props defineProps({ value: { type: Number, default: 0 }, duration: { type: Number, default: 600 } }) const displayValue ref(0) // 当前稳定的显示值 const topValue ref(0) // 上半部分静态值 const bottomValue ref(0) // 下半部分静态值 const flipTopVal ref(0) // 翻转遮罩上的旧值 const flipBottomVal ref(0) // 翻转遮罩上的新值 const flipping ref(false) let timer null function flipTo(newVal) { if (flipping.value) return flipping.value true topValue.value displayValue.value bottomValue.value newVal flipTopVal.value displayValue.value flipBottomVal.value newVal nextTick(() { timer setTimeout(() { displayValue.value newVal topValue.value newVal flipping.value false }, props.duration) }) } watch(() props.value, flipTo) onBeforeUnmount(() { if (timer) clearTimeout(timer) }) /script这里有个很关键的点动态部分的动画 class 不是一直挂在元素上的。我的做法是模板里用一个flipping状态控制动态元素是否添加is-flippingclass触发时把它设为 true动画结束后设为 false下一次触发时再置 true。这样每次赋值都能重新触发动画否则动画只会在第一次播放后续数字变化看不出任何效果。3.4 多位数翻牌器数组渲染与位对齐单个数位的翻牌器做出来后多位数只是把 value 转成字符串数组再循环渲染。但有一个细节每个数位必须独立翻转。比如数字从 1999 变成 2000四个位应该同时翻而不是从个位到千位先后翻。这要求组件内部把新值按位拆分一次性触发所有位的动画。还要注意位数的稳定性。如果数字从 99 变成 100位数从 2 位变成 3 位直接循环数组的话容器宽度会突变。我的做法是固定最大位数给翻牌器组件加一个maxLength属性不足的位前面补零。这样显示更整齐也更贴近真实翻牌器显示屏的行为。实际使用时父组件里这样拆分数据const orderDigits computed(() { const str String(store.orders).padStart(4, 0) return str.split().map(Number) })这样数字 42 会显示成 0042四个位各自持有一个 0~9 的数字。如果业务要求不显示前导零可以改成用Math.max(0, ...)计算实际需要渲染的位数但保持容器宽度固定避免布局跳动。4. 实战在一个数据大屏里把两个组件用起来4.1 场景设定与整体项目结构我这次的示例场景是一个电商实时数据大屏。页面左侧展示“今日实时销售额”用数字滚动组件右侧展示“每分钟订单量”用一个四位数翻牌器。数据由一个模拟接口返回每 3 秒刷新一次。整个项目结构是 Vue 3 Vite Pinia。真实项目里你会用 Vue Router 来做大屏路由比如/screen路由单独加载大屏页面不套后台管理框架。我这里也把页面挂在了路由下顺便说明一下大屏路由和普通页面的区别大屏通常是全屏、无菜单、无滚动条路由配置里可以单独给一个 layout。4.2 组件注册与页面组合页面里使用两个组件的代码大致长这样template div classdashboard div classcard h3今日实时销售额/h3 ScrollNumber :valuesales :duration1000 :decimals2 prefix¥ / /div div classcard h3每分钟订单量/h3 div classflip-group FlipCard v-for(digit, index) in orderDigits :keyindex :valuedigit :duration600 / /div /div /div /template script setup import { ref, computed, onMounted, onBeforeUnmount } from vue import ScrollNumber from ../components/ScrollNumber.vue import FlipCard from ../components/FlipCard.vue import { useDashboardStore } from ../stores/dashboard const store useDashboardStore() const sales computed(() store.sales) const orderDigits computed(() { const str String(store.orders).padStart(4, 0) return str.split().map(Number) }) let timer null const fetchData async () { await store.fetchSimulatedData() } onMounted(() { fetchData() timer setInterval(fetchData, 3000) }) onBeforeUnmount(() { clearInterval(timer) }) /script这个例子的核心思路是把事情拆得很清楚页面只管拉数据和传值动画细节全部在组件内部消化。如果你做的是真正的大型项目建议把“轮询接口”和“组件更新”再拆一层比如用 WebSocket 推送或者 Pinia action 统一管理但页面代码结构基本就是这个骨架。4.3 轮询、Pinia 状态管理和大屏刷新大屏项目里最怕的就是“组件自己在聚众轮询”。如果每个组件都自己 setInterval接口会被打爆页面也会越来越卡。标准做法是在页面级别统一轮询数据放 Pinia store组件只消费 store 里的数据。我这里用了一个简单的 store模拟接口返回数据import { defineStore } from pinia export const useDashboardStore defineStore(dashboard, { state: () ({ sales: 0, orders: 0 }), actions: { async fetchSimulatedData() { // 模拟接口延迟 await new Promise(res setTimeout(res, 200)) this.sales Math.floor(Math.random() * 1000) 500 this.orders Math.floor(Math.random() * 90) 10 } } })这里有个经验轮询频率和数据刷新频率要匹配。动画时长 800~1000ms 的话轮询间隔最好 3 秒以上。如果 1 秒钟轮询一次数字还没滚完又开始下一轮视觉上会非常乱而且组件里得加“动画进行中忽略新值”的逻辑等于给自己找麻烦。我通常会把轮询的时间间隔设计成动画时长的三倍以上留足视觉消化时间。如果你用的是 Vuex逻辑其实一模一样只是 API 名字不同。Pinia 更轻量而且天然支持组合式写法大屏项目里我推荐优先用 Pinia。4.4 大屏打包部署后常见布局异常这里说一个真实遇到过的坑本地开发一切正常npm run build 打包之后放到服务器上大屏布局全乱了。排查下来是两个原因叠加。第一CSS 里用了某些低版本浏览器不支持的属性生产环境压缩后某个分号或者注释被压缩工具处理坏了触发 bug。排查建议是用生产构建在本地 preview 跑一遍不要只在本地 dev 环境测试。第二大屏页面通常固定尺寸我用的是 rem 适配。打包后 CSS 文件中 root 字体大小的计算方式变了导致所有间距都偏了。这个问题的根源是我把适配脚本放到了 index.html 里但打包时 public 目录的脚本加载顺序被调整了。解决方法是把适配脚本写成内联 script 放到 head 最前面确保在任何资源加载之前就计算好 root font-size。后来我干脆改用了viewport单位 transform: scale()等比缩放方案外层容器根据屏幕比例算出缩放比用 CSS 变量控制。这个方案对大屏更友好不依赖 rem 的全局设置也几乎没有打包后的兼容性问题。如果你第一次写大屏建议直接上这个方案能少踩一半坑。5. 常见问题与排查技巧实录5.1 数字滚动到一半突然跳到终点这个问题的典型特征是数字滚动动画执行到一半突然从中间值跳到目标值视觉上像卡了一下。最常见的原因是组件在动画过程中收到了多个连续的新值。第一次 animateTo 还没结束第二次 watch 又触发了此时displayValue是中间值于是第二次动画从中间值开始滚导致进度重算看起来就像跳了一下。解决办法有两种要么在动画过程中把新请求缓存下来等当前动画结束后再滚一次要么直接取消当前动画从当前显示值滚到新值。第二种实现简单体验也还行适合数据刷新频率不高的场景。高频场景用第一种我会维护一个latestTarget当前动画结束后把最新的目标值作为起点再开一轮。5.2 翻牌器背面文字镜像、数字透出这是 3D 翻转最常见的 bug。原因就是缺少backface-visibility: hidden或者父容器没有加perspective。另外如果翻牌器里面用了多层 transform 嵌套记得每一层都加backface-visibility: hidden只加最外层是没用的。另一个容易忽略的点上下两个动态部分里的 span 要和静态部分的行高保持一致。不然翻转过程中文字会发生轻微位移看起来像“错层”。这类 bug 用浏览器 DevTools 的 3D 视图定位非常方便打开后一眼就能看到哪个元素翻转到了屏幕背面。5.3 定时器清理不干净切路由后还在刷数据大屏页面经常是路由懒加载的切换路由后如果 setInterval 没清理数据还在后台更新甚至控制台报错。我在组件里用onBeforeUnmount清理 setInterval本质上就是把定时器的生命周期绑定到组件生命周期上。但有一个地方容易漏Vue Router 的 keep-alive。如果你把大屏页面放在 keep-alive 里组件的onBeforeUnmount不会被触发定时器就永远活着了。遇到这种情况应该监听onDeactivated钩子去清理onActivated时重新拉数据继续轮询。5.4 数字滚动和翻牌器显示不同步大屏上两个组件消费同一份数据但滚动动画有 800ms翻牌器有 600ms所以不同步是必然的。如果业务上要求视觉上“同时更新”可以给两者传入一致的 duration并让动画统一从同一帧开始。我的做法是封装一个 composable 管理动画时钟组件只负责渲染。这样两个组件共享同一个时间轴数字滚动和翻牌器就能同时更新。用 composable 还有一个好处以后想在 Canvas 或者 WebGL 里复用这套数字动画也能直接用。5.5 数据位溢出和负数处理翻牌器通常不支持负数业务上要给组件加一个 clamp 逻辑比如最小值为 0。我的翻牌器组件里写了const safeValue Math.max(0, Math.min(value, maxLength ? Math.pow(10, maxLength) - 1 : Infinity))这样即使接口返回异常数据页面上也不会出现位数炸掉的惨状。如果业务真有负数需求需要重新设计翻转逻辑比如加一个符号位翻转卡片成本会高很多建议在需求评审时就跟产品讲清楚尽量砍掉负数展示。问题原因解决办法数字跳变watch 触发多次动画缓存最新目标或取消当前动画翻牌镜像缺少 backface-visibility三层都加 hidden定时器残留keep-alive 下 unmount 不触发使用 onDeactivated 清理双组件不同步动画时长不一致统一 duration composable 共享时钟数据溢出值超出位数上限组件内 clamp 兜底6. 一些实战要点和后续扩展最后分享几个我在项目里反复验证过的细节。动画时长不要搞太长。数字滚动 800ms 是甜点值超过 1000ms 会让人觉得拖沓大屏上尤其明显。翻牌器控制在 600ms 以内更合适注意上下两段翻转的衔接要留一点重叠不然会有断裂感。性能开销主要不在动画而在文字布局。如果每个数字位都单独用一个 span且排版非常复杂浏览器每一帧都要重排版。建议给每个组件设置固定宽度和行高避免在动画过程中改变字号或字体。我实测过在 1920x1080 大屏上几十个数字同时滚动帧率稳定在 60fps没有任何问题。如果你有大量并发动画也可以用 Vue 的TransitionGroup结合 FLIP 动画来优化但对数字滚动这种简单场景来说requestAnimationFrame 已经足够了过度设计反而难维护。后续扩展方向可以给数字滚动追加“滚动到目标值后闪烁一下”的样式或者结合Intl.NumberFormat支持更复杂的国际化数字格式。翻牌器可以改成自定义翻转图案甚至加入音效模拟老式翻页钟的咔嗒声。这些我已经在个人项目里小范围试验过效果都挺好但要注意音效别做得太响大屏现场环境嘈杂声音小了听不见大了又吵人最好做成可调节的。数值动画看似简单但它对用户体验的提升非常直观。我个人做完这套组件后最大的体会是这种动画组件写得好不好不在炫技而在于边界情况处理得干净不干净——连续触发、组件销毁、格式化兜底这些才是真正花时间的地方。希望这篇文章能帮你在自己的项目里少走几步弯路。