资讯动态

Vue数据大屏实战:手写数字滚动与翻牌器组件,解决路由与打包兼容问题

发布时间:2026/10/2 4:46:35 来源:尧图企业网站定制
Vue项目实战手写数字滚动和翻牌器组件让数据大屏真正“动”起来如果你做过数据可视化大屏一定遇到过这样的需求页面上那几个核心指标数字不能干巴巴地直接刷出来领导要的是“有科技感”——数字刷新时要像老虎机一样滚动起来或者像老式机场航班表一样一格一格地翻动。这就是今天要聊的 Vue 数字滚动组件和翻牌器组件。这两个组件在 Vue 生态里其实有不少现成库比如 vue-count-to、vue-flip-countdown但真拿到项目里用总会碰到几个尴尬时刻要么样式和设计稿对不上要么打包完字体错位要么在路由切换后动画直接失灵。所以我在实际项目中更倾向于自己封装一套核心逻辑并不复杂反而能完全贴合业务需求。这篇博文我就把数字滚动和翻牌器的实现思路、核心代码、踩坑记录全部摊开讲从组件设计到性能优化再到和 Vue Router、Webpack 打包相关的兼容问题手把手带你写出一套能直接怼到生产环境里的组件。1. 项目概述与场景拆解1.1 数字滚动和翻牌器分别是什么先对齐一下概念。数字滚动组件指的是页面上的数值从旧值过渡到新值的过程中数字以垂直滑动或快速跳变的形式呈现常见效果是“滚动数字”或“数字累加”。它在数据大屏、实时监控面板、游戏积分页面里非常常见——每秒刷新一次的在线人数、成交金额、CPU 使用率配上滚动效果后视觉上立刻有了“实时跳动”的感觉。翻牌器组件则是模拟机械翻牌显示屏split-flap display的效果。每一位数字由多张“牌”组成更新时上半部分和下半部分分别翻转像老式机场航班信息牌一样。这种效果在倒计时、竞猜比分、抽奖号码等场景里很受欢迎看起来颗粒感更足、复古气息更浓。这两种效果在实现上有本质区别数字滚动是连续的位移动画每一帧都在计算数字的位置翻牌器是离散的翻转动画靠 CSS 3D transform 驱动翻完一张牌就定格。搞清楚这个区别后面写代码时就不会混为一谈。1.2 为什么自己写而不是用现成库说实话Vue 社区里现成的滚动数字组件不少但我在几个真实项目中得到的教训是通用库解决“能用”的问题解决不了“好用”的问题。第一个坑是定制成本。大屏项目经常会把数字设计成特殊字体甚至要做渐变描边、光影效果。现成库往往把数字渲染成 canvas 或者固定结构的 div你想改样式就得读源码、覆盖样式折腾下来比自己写还慢。第二个坑是依赖体积和兼容性。有些库底层用了 Web Animations API老版本浏览器不支持有些库在 SSR 或微前端环境下会报错。自己写组件依赖只有 Vue 本身出了问题也能立刻定位。第三个坑最关键业务场景里数字滚动的格式五花八门有要千分位分隔的有要保留两位小数的有要显示正负号的有要带单位的。自己封装时所有格式逻辑都掌握在自己手里想要什么花样随时加。所以我的建议是如果只是临时展示一个数字用现成库没问题如果要长期维护、深度定制自己写的组件往往才是最省心的方案。下面就从零开始讲清楚每个环节怎么实现。2. 设计与实现的前置准备2.1 组件目录设计与数据流规划动手写代码前先规划好组件结构。我这里采用了两个独立组件 一个组合式函数composable的方案搭配 Vue 3 的 composition API 编写如果项目还在用 Vue 2也可以用 options API 平移过去核心逻辑不变。src/ components/ ScrollNumber/ index.vue FlipCard/ index.vue composables/ useAnimationFrame.js两个组件的边界很清楚ScrollNumber负责数字滚动展示FlipCard负责翻牌效果。useAnimationFrame是一个共享的 requestAnimationFrame 封装用于处理动画循环和组件卸载时的自动清理。为什么不用同一个组件通过 prop 切换模式我在一开始确实这么干过结果组件的逻辑越来越臃肿两个效果的优化方向也不一样。拆开之后每个组件只做一件事维护起来清爽得多。2.2 组件对外 API 设计组件设计最重要的不是内部实现而是对外暴露的接口。一个组件的 API 决定了下游使用方的开发体验。我在项目中摸索了一套比较顺手的接口规范这里展示一下。ScrollNumber组件的 props 设计如下属性类型默认值说明valueNumber/String0要展示的目标值durationNumber2000动画时长单位毫秒delayNumber0延迟启动动画的时间formatFunctionnull数值格式化函数separatorString,千分位分隔符decimalNumber0保留小数位数easingFunctioneaseOutQuad缓动函数autoStartBooleantrue是否在值变化时自动播放事件方面只保留一个transition-end动画播放完成后触发方便父组件做后续操作比如联动其他图表刷新。FlipCard组件稍微复杂一点因为涉及位数管理和前后值对比属性类型默认值说明valueNumber/String0目标数字durationNumber600每次翻牌动画时长digitsNumber0显示的位数0表示自动按位数heightNumber40每张牌的高度单位pxwidthNumber28每张牌的宽度单位pxautoStartBooleantrue是否自动开始这些接口不是凭空想出来的而是从几个实际项目中抽出来的公共需求。比如千分位分隔符不同国家地区习惯不一样有人用逗号有人用空格所以做成可配置项而不是写死。2.3 缓动函数的选择动画效果给人感觉“专不专业”很大程度上取决于缓动函数easing function。如果使用线性动画数字滚动会显得机械如果匀速更新翻牌器的翻动节奏也会非常生硬。我在项目中维护了一个小的缓动函数集合// src/utils/easing.js export const easeOutQuad (t) 1 - (1 - t) * (1 - t); export const easeOutCubic (t) 1 - Math.pow(1 - t, 3); export const easeInOutCubic (t) t 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t 2, 3) / 2; export const easeOutElastic (t) { if (t 0 || t 1) return t; return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) 1; };数字滚动里最常用的是easeOutCubic前期速度较快后期慢下来视觉上很“顺滑”。翻牌器则不同它是离散动作不用缓动函数而是用固定时长的翻转动画配合 CSS 的transition或animation控制。这个细节体现了两种效果的本质差异一个是位置插值一个是状态切换。弄清楚之后实现起来就不会混淆了。3. 数字滚动组件核心实现3.1 数字滚动的渲染结构ScrollNumber组件的核心渲染结构并不复杂把数字按位拆开每一位用一个垂直滑动的容器包裹容器内部有一列数字通过 transform 平移来控制当前显示哪一个数字。每一位数字的模板大概是这样的div classscroll-number div classscroll-number__digit v-for(digit, index) in displayDigits :keyindex div classscroll-number__digit-inner :style{ transform: translateY(-${digit * 10}%) } span v-fornum in 10 :keynum{{ num - 1 }}/span /div /div /div这里的原理是每个数字列里放十个数字0-9每个数字占容器高度的 10%当前要显示的数字是几就把容器向上平移digit * 10%。当数字从 5 变为 8 时只需要平滑地改变 translateY 的值数字就会从 5 的位置滚到 8 的位置视觉上就是滚动效果。用百分比的 translation 而不是像素值好处是自适应字体大小和容器高度不需要手动测量数字高度。这个技巧我在实际项目里验证过比用 px 值稳妥得多。3.2 核心逻辑数值变化的动画驱动组件内部的核心逻辑分为三步监听 value 变化、记录上一次的值、驱动动画从旧值过渡到新值。这里不能直接用transition属性做动画因为数字滚动涉及到“多位数字同时变化”而且每一位的变化量可能都不一样。比如从 199 变成 202百位的 1 要滚到 2十位的 9 要滚到 0个位的 9 要滚到 2三位的滚动距离完全不同。所以我采用了requestAnimationFrame手动驱动// ScrollNumber 组件核心逻辑 import { ref, watch, onBeforeUnmount, computed } from vue; import { easeOutQuad } from ../utils/easing; export function useScrollNumber(value, duration, easingFn) { const displayValue ref(0); let frameId null; let startTime 0; let fromValue 0; let toValue 0; const animate (timestamp) { if (!startTime) startTime timestamp; const progress Math.min((timestamp - startTime) / duration, 1); const easedProgress easingFn(progress); displayValue.value fromValue (toValue - fromValue) * easedProgress; if (progress 1) { frameId requestAnimationFrame(animate); } else { displayValue.value toValue; // 触发自定义完成事件 emit(transition-end, toValue); } }; const start () { fromValue displayValue.value; toValue Number(value.value) || 0; startTime 0; cancelAnimationFrame(frameId); frameId requestAnimationFrame(animate); }; watch(value, start, { immediate: true }); onBeforeUnmount(() { cancelAnimationFrame(frameId); }); return { displayValue }; }这段代码有几个关键细节动画开始前先取消上一次的动画帧避免快速触发多次动画时出现“抢跑”问题动画结束时把显示值精确设置为目标值避免因为浮点误差导致最后一位显示不正常组件卸载时取消动画防止内存泄漏。3.3 数字格式化的细节处理数字滚动组件最容易出问题的地方不是滚动本身而是格式化。背景信息里提到了“vue 打包后 布局异常”这虽然不完全只是格式化的锅但数字格式化如果没做对一样会造成布局抖动。一个完整的数字格式化逻辑要处理这几件事千分位分隔、小数位补零、正负号处理、单位拼接。// 数字格式化函数 const formatNumber (num, options) { const { decimal 0, separator ,, prefix , suffix , } options; let fixedNum Number(num).toFixed(decimal); let [integerPart, decimalPart] fixedNum.split(.); // 千分位处理 integerPart integerPart.replace(/\B(?(\d{3})(?!\d))/g, separator); let result integerPart; if (decimal 0) { result . decimalPart; } return prefix result suffix; };这里我踩过一个坑toFixed返回的是字符串对于很大的数字Number(num)可能会丢失精度。如果展示的是超大数值例如用户 ID、金额的分单位建议统一用字符串传入并在内部解析或者在接口层面做好精度控制。还有一个容易忽略的点数字滚动过程中displayValue是小数格式化时要先四舍五入到指定小数位否则滚动过程中数字会“抖”得很厉害。这个细节是调试了几个项目才发现的。3.4 滚动速度与视觉节奏的把控数字滚动不是越快越好也不是越慢越酷关键是要让用户盯住数字时能看清中间过程。根据实际项目的反馈我总结了一套参数建议数值变化幅度推荐时长说明较小1-99800-1200ms短促利落不拖沓中等100-99991500-2500ms有足够时间看清滚动过程较大10000以上2500-4000ms给足视觉缓冲避免闪烁感当然这只是参考值实际还要考虑业务场景。在股票行情里数字刷新可能只需要 500ms在大屏展示的年度业绩总结里加大到 4 秒反而更有仪式感。另外一个提升视觉质感的细节是“位间延迟”。当数字变化跨多个位时可以让低位先滚、高位后滚产生一种“数据正在逐位计算”的感觉。实现起来也很简单给每一位的动画添加一个微小的延迟// 每一位的延迟数字越大延迟越大从右往左依次延迟 const digitDelay (digitIndex, totalDigits) { return (totalDigits - digitIndex - 1) * 60; // 单位 ms };这样个位先动十位后动百位再后动视觉层次丰富了很多。大屏项目里用这个细节能让整个页面看起来更“贵”。4. 翻牌器组件的实现4.1 翻牌器的核心原理split-flap 结构翻牌器的实现思路和数字滚动完全不同。它不是让数字滑动而是模拟一套“翻牌”机械结构。每一张牌由上下两半组成上半部分显示旧数字的上半截下半部分显示新数字的下半截通过中间轴的旋转完成“翻”的动作。经典的 split-flap 效果拆解开来是这样的当前状态上半部分显示旧数字下半部分也显示旧数字看起来是一张完整卡片。翻牌开始上半部分不动一张“新牌”从上方覆盖下来这半张牌显示新数字的上半截同时绕水平轴旋转从垂直逐渐变成水平。中间瞬间牌面完全水平观众看到的是“半截旧数字 半截新数字”混合的画面。翻牌完成牌的下面半边翻转出来显示新数字的下半截最终新旧数字完成切换。用 DOM 结构实现时每一张牌需要三层结构上层牌负责显示新数字的上半截、下层牌负责显示旧数字的下半截、以及一个中间旋转容器control group。这三层的 CSS 变换关系需要仔细处理否则翻动的透视感完全出不来。下面是我在项目里稳定使用的结构div classflap-card :style{ width: width px, height: height px } !-- 静态背景始终显示旧数字的下半截 -- div classflap-card__half flap-card__half--bottom span classflap-card__num{{ previousValue }}/span /div !-- 静态前景始终显示旧数字的上半截 -- div classflap-card__half flap-card__half--top span classflap-card__num{{ previousValue }}/span /div !-- 翻转的上半部分 -- div classflap-card__fold flap-card__fold--top :class{ is-flipping: isFlipping } div classflap-card__fold-inner div classflap-card__half flap-card__half--top span classflap-card__num{{ currentValue }}/span /div /div /div !-- 翻转的下半部分 -- div classflap-card__fold flap-card__fold--bottom :class{ is-flipping: isFlipping } div classflap-card__fold-inner div classflap-card__half flap-card__half--bottom span classflap-card__num{{ currentValue }}/span /div /div /div /div这个结构的关键是静态层始终显示“旧数字”翻转层在动画中间才显露出“新数字”。所以翻牌器更新数字的流程一定是先把新数字放到翻转层里触发翻转动效动画结束后再更新静态层里的数字。4.2 CSS 3D 翻转的关键写法翻牌效果依赖 CSS 3D transform几个关键属性不能写错.flap-card { perspective: 300px; position: relative; } .flap-card__fold { position: absolute; left: 0; width: 100%; height: 50%; overflow: hidden; backface-visibility: hidden; transform-origin: center bottom; transform-style: preserve-3d; } .flap-card__fold--top { top: 0; transform-origin: center bottom; } .flap-card__fold--bottom { bottom: 0; transform-origin: center top; } .flap-card__fold--top.is-flipping { animation: flipTop 300ms ease-in forwards; } .flap-card__fold--bottom.is-flipping { animation: flipBottom 300ms ease-out forwards; } keyframes flipTop { 0% { transform: rotateX(0deg); } 100% { transform: rotateX(-90deg); } } keyframes flipBottom { 0% { transform: rotateX(90deg); } 100% { transform: rotateX(0deg); } }这里有几个必须注意的细节backface-visibility: hidden必须加上否则翻到背面时会看到镜像内容效果立刻露馅。perspective的值很关键太小了翻转时会严重变形太大了立体感消失我在 300px 到 500px 之间测试过多次300px 是最合适的。翻转的上半部分和下半部分动画不能同时进行要先翻上半部分再翻下半部分否则两个折叠面会在中线处打架。同时翻转层的宽高要精确控制。外层卡片高度设为height: 40px那么每个半层必须在内部设置为height: 20px且内部数字要固定在该半层的可视范围中央。很多翻牌器看起来“歪”就是因为数字定位没有跟着半层走。4.3 翻牌器的数据更新与联动翻牌器组件的数据更新逻辑比数字滚动要复杂一些因为涉及“前值”和“后值”的状态管理。我的实现方式是维护一个prevValue和一个currentValue监听外部 value 变化时只更新currentValue然后交给 CSS 动画去处理动画结束再同步prevValue。const props defineProps({ value: [Number, String], duration: { type: Number, default: 600 }, }); const prevValue ref(props.value); const currentValue ref(props.value); const isFlipping ref(false); const startFlip (oldVal, newVal) { if (Number(oldVal) Number(newVal)) return; currentValue.value newVal; isFlipping.value true; // 上半部分先翻 setTimeout(() { // 动画结束后同步前值 prevValue.value newVal; isFlipping.value false; }, props.duration); }; watch(() props.value, (newVal, oldVal) { startFlip(oldVal, newVal); }, { immediate: true });这里有个比较容易出错的地方如果duration设置为 600ms而上下两个半层的动画分别是 300ms add 300ms那么上半部分的动画结束后要无缝衔接下半部分。CSS 动画里用了forwards填充模式所以上半部分翻转后保持在 90 度位置下半部分跟随翻转视觉上是连贯的。但在 Chrome 和 Safari 等不同浏览器上CSS 动画的触发时机有细微差异。生产环境里我见过翻牌器在 Safari 上出现“闪一下”的情况——原因是backface-visibility在部分场景下没有生效。后来我把翻转的中间状态也手动加上transform: perspective(300px) rotateX(...)同时给上下半层都显式设置transform-style: preserve-3d问题就解决了。4.4 翻牌器同时翻转多张牌的性能考量大屏项目里翻牌器往往不止一个。一个显示“在线用户数”的数据卡如果数字是六位数一次刷新就要同时翻转六张牌整个大屏有十几个指标的话同时翻转的牌面多达几十上百张。CSS 3D transform 是比较消耗 GPU 的尤其是perspective和preserve-3d会创建额外的渲染层。我在压测时发现一次翻转超过 60 张牌帧率会明显下降。优化方案有这几个只在卡片状态变化时添加动画类不变化时保持静态避免每一帧都触发重绘。对不常更新的指标延长刷新轮询间隔减少翻转频率。如果数字位数很多可以只翻转变化的位不变化的位保持静态。比如从 100000 变成 100001只需要翻转个位。在动画结束时主动触发浏览器compositing比如给容器加will-change: transform但用完记得移除否则会一直占用内存。这些优化做完后我在实际项目里用 30 张牌同时翻转测试帧率稳定在 50fps 以上大屏展示完全够用。5. 常见问题与排查技巧实录5.1 数字滚动有锯齿感 / 数字抖动这是数字滚动组件最高频的问题。原因基本就一个滚动过程中displayValue在持续变化但格式化函数对小数位的处理不一致。比如滚动中间值是 123.456格式化保留两位小数显示 123.46下一秒滚动到 123.455还是显示 123.46但数字列内部的 transform 却在来回微调视觉上就产生了“锯齿抖动”。解决办法是在格式化之前先做一次舍入const rounded Math.round(displayValue.value * Math.pow(10, decimal)) / Math.pow(10, decimal);除了格式化问题还有可能是数字列使用了模糊的 transform。当数字字体很大、容器没有整数宽高时translateY 的百分比计算会出现亚像素值导致字体渲染模糊。解决办法是把每一位的数字列容器高度设置成精确的整数 px同时设置font-variant-numeric: tabular-nums让等宽数字生效。5.2 翻牌器动画触发后只翻了一半就停住这个问题几乎都是 CSS 动画填充模式出的错。上半层动画用了forwards但下半层的动画没有正确触发或者两个半层的动画时间不一致导致下半层还停留在初始状态。排查思路是先打开 DevTools检查两个半层的animation状态。如果只有flip-top在运行说明下半层的类名或时间没跟上。更稳妥的做法是在 JavaScript 里控制动画开始而不是依赖两个独立 CSS 动画的衔接// 上半层动画结束后立即触发下半层动画 setTimeout(() { foldBottomEl.classList.add(is-flipping); }, props.duration / 2);这种方式虽然多写几行代码但逻辑更可控不会因为 CSS 时序问题出错。另外要检查是否给两个半层分别显式设置了transform-origin。上半层以底部为中心翻转下半层以顶部为中心翻转如果写反了动画看起来就像“翻跟头”而不是“纸牌翻面”。5.3 路由切换后数字滚动 / 翻牌器不更新热搜词里有“vue路由拦截器”“vue路由参数”“vue路由”这些和组件问题确实有关联。我在一个项目里就遇到过大屏从路由 A 切到路由 B 后翻牌器组件不再更新数字的问题。原因是组件没有销毁而 Vue 的keep-alive包裹了路由页面。路由切换时组件被缓存但组件的watch监听没有重新绑定外部状态更新的值虽然传到了组件里组件却没有触发动画。解决办法是给滚动组件增加一个手动刷新的方法并在activated生命周期里重新触发ScrollNumber refscrollNumRef :valuecurrentCount /onActivated(() { scrollNumRef.value?.refresh(); });同时在组件的watch里要注意deep属性。如果传入的 value 是对象包含数字和格式化配置默认的浅监听可能不会触发需要显式设置{ deep: true }或者在父组件里用计算属性生成不可变的新值。5.4 打包后数字字体错位、翻牌器布局异常热搜词里“vue 打包后 布局异常”是很多新手都会遇到的问题尤其是大屏项目本地开发好好的打包上线后数字上下错位、翻牌器叠在一起。这种情况绝大多数是 CSS 压缩和浏览器默认样式差异导致的。大屏项目的 HTML 结构往往很复杂打包后 class 名被压缩如果某些样式选择器写得太宽泛比如.card div就可能影响到数字滚动列的布局。我的经验是给组件的根节点加上一个独特的命名空间 class比如sn-container和fc-card所有内部样式都限定在这个 class 下避免全局污染。同时检查打包工具是否开启了 CSS 的autoprefixer确保transform、perspective等属性对应的浏览器前缀都补齐了。另外还有个容易忽略的点翻牌器组件内部使用了绝对定位的position: absolute如果外层容器没有设置position: relative打包后布局就会飘。这种问题在开发环境里可能因为其他样式巧合而“看起来正常”打包后样式顺序变化才会暴露。我这里整理了一份速查表方便大家对照排查问题现象直接原因排查方法数字滚动抖动格式化舍入不一致检查是否先舍入再格式化数字滚动字体模糊transform 亚像素设置整数高度 tabular-nums翻牌只翻一半CSS 动画时序改用 JS 控制动画阶段翻牌器翻转方向错transform-origin 写反检查上下半层的翻转中心路由切换后不更新keep-alive 缓存onActivated 中手动刷新打包后布局异常样式作用域和前缀加命名空间 class大屏卡顿GPU 层过多限量触发翻牌动画这些坑基本覆盖了我从最初自写翻牌器到现在积累的大部分问题。强烈建议在开发环境就用生产模式构建检查一遍哪怕只是npm run build跑一下再预览也能提前暴露绝多数打包后的 CSS 问题。写在最后数字滚动和翻牌器属于典型的“看着简单、做起来要抠细节”的前端组件。核心算法其实很简单真正的难点在于格式化、时序控制、浏览器兼容和性能优化这些看不见的地方。我写这两个组件时最大的体会就是不要一上来就追求花哨效果先把静态展示做扎实再把动画一层一层加上去每一步都验证通过后再走下一步同时务必提前确认好项目里是否用了 keep-alive、打包工具如何压缩 CSS、目标浏览器是否支持 3D transform这些因素直接决定了组件的稳定边界。希望这篇实战记录能帮你少踩几个坑。如果你在自己的项目里遇到过其他奇葩问题也欢迎按这个思路去排查——大多数“玄学问题”最后都能在 CSS 时序和动画帧里找到答案。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑