首页 / 资讯中心 / 文章详情

Vue2数字滚动动画实战:基于RAF的CountUp组件设计与性能优化

Vue2数字滚动动画实战:基于RAF的CountUp组件设计与性能优化 ★ FEATURED ARTICLE
做数据可视化项目的朋友十有八九遇到过这个需求页面上某个关键指标要从0滚动到8848或者从1200万涨到1500万数字像计数器一样往上翻。这种数字滚动增长动画在Vue2项目里实现起来其实并不复杂但想做好看、做流畅、不卡页面还是有不少门道。这篇文章我打算把vue2里实现数字滚动增长动画的整个思路、组件设计、常见坑全部拆开讲一遍所有代码都是可以直接抄进项目里改改就用的那种适合正在做大屏、数据报表或者运营后台的开发者参考。我最早是在一个运维监控大屏项目里接到的这个需求当时第一反应是去npm上找库后来发现一个简单的数字滚动效果引第三方库反而要处理各种兼容问题、样式覆盖问题不如自己写一个组件。自己写完才发现这里面的核心并不是“数字怎么动”而是动画的生命周期管理、性能控制、各种边界场景的处理。这篇文章就从我踩过的坑出发把整套实现方案完整记录下来。1. 方案选型为什么我选择自己写而不是用现成库1.1 先盘点一下常见的数字滚动实现思路数字滚动增长动画看起来只是“从A变到B”但实现方式至少有四条路可以走。第一种是用CSS的transition或animation配合transform来实现但这种方案只擅长处理位移、缩放、透明度这类属性变化数字本身是文本内容没法通过CSS去“滚动增长”。你最多能做到让整个数字框从底部滑上来本质上不是数字在滚动。第二种是用setInterval定时器每隔16毫秒左右让当前值加一个固定步长。这个方案写法最简单十来行代码就能跑起来但它有一个天生的缺陷setInterval的最小执行间隔不可靠浏览器为了省电会在后台标签页里把定时器或者requestAnimationFrameRAF挂起导致数字跳变或者进度忽快忽慢而且当数值非常大时步长取小了动画半天走不完步长取大了最后一段看起来像在跳档。第三种是requestAnimationFrame配合缓动函数这也是我现在推荐的方案。RAF会跟着屏幕刷新率走60Hz的屏幕每帧回调一次动画天然跟画面垂直同步不会撕裂也不会掉帧。页面切到后台时浏览器会自动暂停回调省资源的同时也避免了无意义的计算。第四种是引入现成库比如vue-count-to、countup.js这类。它们功能确实完整但问题也很现实包体积、样式定制、API风格都要迁就库本身的设计。尤其是Vue2项目里如果用了很多魔改配置插件的某些默认行为反而成了束缚。我最终的选择是自己基于RAF写一个通用组件把动画帧调度交给浏览器把缓动、格式化、暂停、重置这些逻辑全部握在自己手里。1.2 从性能角度看RAF为什么比setInterval更可靠很多人写数字增长时习惯用setInterval代码大概是这样的每隔50毫秒执行一次累加看起来好像也没问题。但如果你在页面上对数字滚动效果较真setInterval的弊端会立刻暴露。第一setInterval的间隔是“目标间隔”而不是“准确间隔”。浏览器线程一旦被其他任务占用定时器回调就会被推后多个回调还可能被合并执行。一个本来应该16毫秒执行一次的回调实际可能是20毫秒、30毫秒甚至更久才跑一次这会让动画节奏完全失控。第二setInterval在页面失去焦点时会被浏览器节流。你可以试一下把页面切到后台再切回来数字动画可能直接从中间直接跳到最终值这在一个需要演示给领导看的项目里是非常尴尬的。第三setInterval无法和屏幕刷新率同步。即使你设了16ms屏幕是60Hz的时候每帧画面刷新16.6ms你的定时器和屏幕刷新之间存在相位差人眼就会感觉到轻微的抖动。RAF则直接由浏览器在每次重绘前调用天然保证每一帧的动画进度都能正确渲染出来。所以我的建议非常明确在Vue2项目里做数字滚动一律用requestAnimationFrame不要用setInterval。setInterval适合的是轮询数据、心跳上报这类对时间精度不敏感的场景用它来驱动动画属于用错了工具。1.3 理解Vue2响应式更新对动画开销的影响自己写数字滚动组件时一个绕不开的问题就是动画过程中每帧都在改变数字意味着每帧都会触发组件的重新渲染。Vue2的响应式系统是基于Object.defineProperty的依赖收集机制每个响应式属性的更新都要触发对应的Watcher更新队列再走虚拟DOM diff和patch流程。Vue3和Vue2处理这种高频更新的差异不小后面我会单独讲这里先说Vue2下怎么做性能最优。最保险的做法是在data里只暴露一个字符串类型的displayValue字段动画过程中每帧只改这个字段。虚拟DOM diff时只比较这个文本节点patch的开销非常小。但如果你偷懒在data里存了一个数字对象比如current: { value: 0 }然后在动画里每帧写current.valueVue2会对这个对象的value属性做依赖收集更新的路径更长。更糟糕的做法是把当前值放进Vuex或者放进一个深层的响应式对象里那样触发的是全局状态更新任何依赖这个状态的地方都可能被通知一遍。所以核心原则很简单动画中间值一定要放在离渲染目标最近的地方。能放组件data就直接放组件data只暴露展示层字段不要在状态管理器里维护“动画进行到哪了”这种临时信息。2. 核心实现一个可直接抄走的CountUp组件2.1 组件API设计先想清楚外部要什么写组件第一步不是写代码而是想清楚这个组件的使用方式。我当时给这个数字滚动组件定的名字叫CountUp它需要支持以下能力目标数值、动画时长、小数位数、千分位分隔、前后缀、暂停与重置。基于这些需求props设计如下属性类型默认值说明valueNumber0目标数值变化时触发动画durationNumber1500动画时长单位毫秒decimalsNumber0保留的小数位数prefixString数字前缀如¥suffixString数字后缀如%separatorBooleantrue是否显示千分位分隔符autoPlayBooleantrue挂载后是否自动开始easingStringeaseOutCubic缓动函数名称使用方式上外部只需要绑定value属性数值一变动画就会自动触发。比如在数据大屏里这样用count-up :valuecpuUsage :duration1200 suffix% :decimals1 /这样做的好处在于组件对外只关心“目标值”不关心外部数据什么时候变、为什么变。只要value改变watch就能感知到自动从旧值滚到新值业务代码里完全不用手动调start方法。2.2 核心动画逻辑从旧值到新值的平滑过渡动画的核心逻辑其实只有三件事记录起始时间、计算当前进度、把进度映射成数值。先看缓动函数。直接线性增长会显得很机械真实场景中的数字增长应该有一个“起步冲、末端稳”的节奏就像汽车起步时加速猛、快到红灯时点刹车一样。我在组件里默认使用easeOutCubic也就是三次缓出函数公式是easeOutCubic(t) { return 1 - Math.pow(1 - t, 3) }当t从0到1时这个函数会快速拉升到一个较高的进度然后逐渐逼近1。用这种缓动做出来的数字滚动观感比线性增长要好很多。如果想让起步更快、末尾更慢可以用easeOutQuart或者easeOutExpo把幂次调高即可。接下来是RAF驱动的核心循环。思路是拿到开始时间startTime每帧回调时用当前时间和startTime的差值除以总时长得到进度progress再把progress经过缓动函数映射用起始值加上目标值和起始值的差乘以映射结果得到当前帧要显示的值。这里有一个非常关键的细节最后一帧必须做“校准”。由于浮点数计算误差和进度条被Math.min截断到1的原因理论上最后一帧就是目标值但实际计算出来的数字可能是9999.9999991这种。所以当progress 1时我直接让displayValue等于格式化的最终目标值保证用户看到的最终数字和业务数据完全一致。2.3 完整组件源码下面是我在项目里实际跑的版本基础功能齐全可以直接复制到Vue2工程里使用template span classcount-up{{ displayValue }}/span /template script const easingMap { linear: (t) t, easeOutCubic: (t) 1 - Math.pow(1 - t, 3), easeOutQuart: (t) 1 - Math.pow(1 - t, 4), easeOutExpo: (t) (t 1 ? 1 : 1 - Math.pow(2, -10 * t)) } export default { name: CountUp, props: { value: { type: Number, required: true }, duration: { type: Number, default: 1500 }, decimals: { type: Number, default: 0 }, prefix: { type: String, default: }, suffix: { type: String, default: }, separator: { type: Boolean, default: true }, autoPlay: { type: Boolean, default: true }, easing: { type: String, default: easeOutCubic } }, data() { return { displayValue: 0, currentFrom: 0, currentTo: 0, rafId: null, paused: false } }, watch: { value: { handler(newVal, oldVal) { this.start(newVal, oldVal) }, immediate: true } }, mounted() { if (!this.autoPlay) { this.displayValue this.formatValue(this.value) } }, beforeDestroy() { this.cancelAnimation() }, methods: { start(to, from) { if (typeof from ! number || isNaN(from)) { from 0 } this.cancelAnimation() this.currentFrom from this.currentTo to const startTime performance.now() const duration this.duration const easingFn easingMap[this.easing] || easingMap.easeOutCubic const step (currentTime) { if (this.paused) { return } const progress Math.min((currentTime - startTime) / duration, 1) const easedProgress easingFn(progress) const currentValue this.currentFrom (this.currentTo - this.currentFrom) * easedProgress this.displayValue this.formatValue(progress 1 ? this.currentTo : currentValue) if (progress 1) { this.rafId requestAnimationFrame(step) } else { this.rafId null } } this.rafId requestAnimationFrame(step) }, formatValue(value) { const fixed value.toFixed(this.decimals) const parts fixed.split(.) let intPart parts[0] if (this.separator) { intPart intPart.replace(/\B(?(\d{3})(?!\d))/g, ,) } const decimalPart parts.length 1 ? .${parts[1]} : return ${this.prefix}${intPart}${decimalPart}${this.suffix} }, cancelAnimation() { if (this.rafId ! null) { cancelAnimationFrame(this.rafId) this.rafId null } } } } /script这个组件有几个设计上的小心思。第一watch的immediate选项让组件挂载时就能根据初始value直接展示最终结果不会出现从0慢慢爬到初始值的情况。第二formatValue里用toFixed处理小数位然后手动加千分位分隔符避免了toLocaleString在不同浏览器和不同语言环境下产生不一致的千分位分隔符。第三cancelAnimation方法统一做动画清理组件销毁前必定调用防止内存泄漏。3. 实操细节从“能跑”到“好用”3.1 格式化里的坑toFixed、千分位和科学计数法格式化数字看起来很简单实际上坑不少。我最早版本直接用toLocaleString来做千分位结果发现在某些Windows环境的中文浏览器下小数部分的分隔符会被处理成全角逗号而且toLocaleString对数字四舍五入的规则在不同引擎里也不是完全一致。后来我改成自己写正则只对整数部分加千分位小数部分保持原样稳定性立刻上来了。toFixed本身也有一个经典坑JavaScript里(0.615).toFixed(2)的结果是0.61而不是你直觉中的0.62。这是因为0.615在二进制浮点表示里实际上是0.614999999...。所以如果业务对四舍五入精度要求很高不能直接用toFixed需要用Math.round对数值本身先做一轮修正const fixed (Math.round(value * 100) / 100).toFixed(this.decimals)不过这里要小心精度放大的溢出问题我只建议在这种场景下对普通量级的数值做处理。如果你的数字动辄上亿并且对精度要求很高建议先确认业务场景是否真的需要小数。还有一个很多人不太注意的点当数字非常大时toFixed不会报错但toLocaleString会输出科学计数法。比如10000000000000000000010的20次方直接格式化会显示成1e20。所以我在formatValue里用的是先toFixed再按字符串拆分的路子因为toFixed之后结果始终是普通数字字符串不会出科学计数法。3.2 数字高度和字体对齐一个审美层面的细节数字滚动的实现做对了但页面看起来可能还是不对劲。我排查过大屏上一块数字总感觉歪的最后发现是字体对齐的问题。数字的宽度在不同字重、不同字号下不一样而且等宽字体和比例字体的表现也完全不同。如果项目允许我建议给数字滚动组件单独指定一个等宽字体比如Barlow Condensed、DIN Alternate或者直接使用font-variant-numeric: tabular-nums。这样动画过程中数字宽度不会来回跳动整个数字区域也不会因为位数变化而产生左右晃动。如果用了竖排或者右对齐布局这个问题会尤其明显。除了字体数字滚动组件在页面上通常不是孤立存在的它往往和图表、标题、单位放在一起。在布局时最好给组件一个明确的最小宽度比如“最高不超过999.9万”这种业务上限数字需要的宽度避免位数增加时把旁边的元素挤得左右平移。3.3 如何让动画只在数字进入可视区域时才播放很多数字滚动组件挂在页面底部用户一打开页面动画早就播完了等用户滚动到底部时只看到最终结果完全体会不到“增长”的视觉反馈。解决思路是引入IntersectionObserver让组件在进入视口后再开始播放。可以在mounted里创建Observer监听组件根元素mounted() { if (IntersectionObserver in window this.autoPlay) { this.observer new IntersectionObserver((entries) { if (entries[0].isIntersecting) { this.restartFromZero() this.observer.disconnect() } }) this.observer.observe(this.$el) } else { this.restartFromZero() } }, methods: { restartFromZero() { if (this.value ! 0) { this.start(this.value, 0) } } }这套逻辑还带来一个额外好处当页面有多个CountUp组件时只有进入视口的那几个才会启动RAF循环其他还在屏幕外的组件完全不会占用CPU。这在数据大屏上非常实用因为大屏往往同时有几十个需要数字滚动的指标位。4. 实战场景数据大屏和业务接入经验4.1 大数据可视化大屏里的接入方式大屏项目是数字滚动增长动画最典型的应用场景。我在大屏项目里通常不是单独使用这个组件而是让它配合轮询数据。后端每隔几秒推一次最新指标前端拿到新值后直接赋值给value属性动画自动触发视觉上看起来就是数字在实时跳动配合ECharts的实时曲线整体大屏的“鲜活感”会立刻提升一个档次。这里推荐一个组织方式把大屏上所有动态指标统一放在一个公共数据模块里管理各区块的CountUp组件只负责接收数值。比如// dashboard-data.js export const dashboardData reactive({ cpuUsage: 0, memoryUsage: 0, requestCount: 0, errorCount: 0 }) // 轮询回调里更新 function onDataReceived(res) { dashboardData.cpuUsage res.cpu dashboardData.requestCount res.totalReq }这样多个组件各自监听自己对应的字段互不干扰。当某个指标数据长时间不变时watch不会触发也就不会有任何动画计算性能开销为零。注意大屏中常见的另一个问题是容器尺寸变化。数字字体如果很大容器宽度不够时动画过程中会抖动。建议数字组件外层盒子预留足够宽度或者使用min-width兜底。我在一个大屏项目里遇到过requestCount从999跳到10000时多出的一个字符把旁边的标题挤歪了后来给数字盒子设了固定宽度右对齐才解决。4.2 在Element UI表格里嵌入数字滚动的注意事项表格场景和大屏不太一样。表格里的单元格可能有很多行每一行都做数字滚动性能压力会成倍放大。我在一个运维告警系统里试过给表格某一列加上CountUp组件一页50行每行都在滚动结果是滚动时CPU占用率明显上升滚动条拖动时fps掉到30以下。问题的根源是每行的动画都各自起了一个RAF循环50个RAF回调同时跑再加上Vue2表格更新时本身就要diff所有行开销直接翻倍。解决办法有两个方向一是只在表格数据刷新时短暂执行动画动画结束后立刻停止比如限制duration在800毫秒以内二是对于关键指标列才用动画普通列直接用纯文本。如果需要表格里很多行同时滚动还有一个更优的架构把RAF循环提升到表格父组件里统一调度通过一个“当前动画时间”驱动所有子组件同步更新。这个方案更复杂但收益大。如果你的项目确实遇到这种需求可以往这个方向去优化。4.3 打印场景下动画组件如何收尾还有一个容易被忽略的场景是打印。页面里如果数字还停留在动画中间态用户直接按CtrlP打印打印出来的PDF里数字可能停在一个非最终值上这在某些需要留档的报表页面里是不可接受的。解决方案是在beforePrint事件里把动画强制跳到终值在afterPrint事件里恢复原状window.addEventListener(beforeprint, this.finishAnimationImmediately) window.addEventListener(afterprint, this.restoreAnimationState)如果你用的是window.print()而不是浏览器的系统打印需要在调用打印前手动触发这个逻辑。我在实际项目中是把“打印前把值校准到终值”做成了组件的一个内置方法业务方只要在调用打印功能前执行一下即可。5. 常见问题与排查实录我踩过的那些坑5.1 组件销毁后动画还在跑控制台疯狂刷错这是新手最容易踩的坑。如果组件在动画播放过程中被销毁比如弹窗关闭、v-if切换页面但RAF回调还在继续回调里访问this.displayValue时就会去更新一个已经不存在的组件实例。Vue2里这种情况一般不会直接报错但会产生内存泄漏控制台有时会出现“You are trying to set a property on a component instance that is no longer mounted”的警告。解决办法就是beforeDestroy里必须调用cancelAnimation前面给出的完整代码已经包含这一步。如果你写了setInterval版本对应就要在beforeDestroy里clearInterval。还有一个容易被忽略的点如果外部在组件销毁时还在修改value属性watch回调里启动的动画同样会残留。所以watch回调里第一步就应该cancel掉上一次未完成的动画再开始新动画。这在前面的start方法里也做了。5.2 数字在快速变化时出现“抢跑”和跳变业务场景里经常遇到数字在动画过程中又变了新值比如轮询接口每2秒一次但动画时长设置了1500毫秒几乎每一次动画刚进行到一半就被打断重来。如果没有处理好会出现两种情况一是数字会突然跳回一个很低的起点再往上滚动视觉上非常突兀二是数字在多次打断后速度越来越快或者越来越慢。正确做法是每次启动新动画时以“当前显示的数值”作为起点而不是以属性变化前那个value作为起点。这样做的好处是多次数据更新时数字会像连续追目标一样平滑地无缝过渡。我上面start方法里是从props变化前的旧值开始这也对只不过如果中间有被打断的情况更平滑的做法是可以额外暴露一个方法让外部以当前显示值为起点继续滚。如果数据更新非常频繁比如每秒3次以上我建议在业务层给数据加一层节流或者debounce只在数据稳定一段时间后再触发动画。大屏轮询一般2到5秒一次问题不大但如果是WebSocket实时推送就需要做这一层控制。5.3 Vue2 keep-alive缓存组件后动画状态错乱有一些页面被keep-alive缓存了再次进入时组件不会重新走mounted而是走activated。如果数字滚动是在mounted里触发的那么第二次进入页面时动画不会重新播放只会停留在最后的值上。解决办法是把动画触发逻辑从mounted挪到activated里并且在deactivated里暂停或清理动画。注意keep-alive组件销毁时走的是deactivated而不是beforeDestroy所以清理逻辑也要放在deactivated里双保险。这里还牵扯到Vue2 keep-alive的另一个潜在问题如果缓存组件里维护了很多RAF定时器页面被缓存后定时器并不会自动停止后台会白白消耗CPU。所以被keep-alive包裹的页面里的动画组件务必在deactivated里统一cancel。我当时排查过一个大屏项目切到别的标签页回来后发现系统音量风扇狂转打开任务管理器一看Chrome的CPU占用到了40%。后来定位到就是keep-alive缓存的几个大屏页面在后台还在跑RAF动画和ECharts的轮询重绘导致浏览器持续满载。清理掉动画后CPU占用立刻降到个位数。5.4 低端设备上动画卡顿的优化手段数字滚动本身体量很小但在低端Android设备或者老机器的Chrome上如果同时有大量DOM更新或者大图渲染RAF动画也会卡。排查思路是打开Performance面板录制一段滚动过程看FPS是否稳定在55以上。如果确认卡顿优先检查几个点页面中是否存在position: fixed和transform连用的元素连续重绘触发Layer层级问题组件根节点是否设置了较重的CSS比如大范围box-shadow、filter: blur()动画过程中是否触发了整页重排比如数字位数的变化把父容器撑大优化手段上最有效的一招是给数字组件根节点加上will-change: contents让浏览器把数字区域独立成合成层。但不要滥用这个属性如果页面上几十个数字组件都加了will-change内存占用反而会上升。5.5 快速定位“动画没触发”的排查清单做完组件之后最常见的线上反馈是“数字变了但是动画没动”。我梳理了一份排查清单照着查基本能解决90%的问题确认value是Number类型如果外部传了字符串123Vue2的watch默认不会对字符串和数字做严格比较不会触发更新确认watch里没有开启deep: true数字变化不需要deep监听确认组件没有被v-show隐藏v-show的元素display: none时RAF回调不会停止但页面不可见容易产生“以为没播”的错觉确认浏览器是否支持requestAnimationFrame老项目兼容性处理时要用polyfill确认没有父组件对子组件提前调用了cancelAnimation6. 从Vue2迁移到Vue3数字滚动组件要改哪些地方6.1 API和生命周期差异这里专门回应一下大家非常关心的vue2和vue3区别。同样一个数字滚动组件迁移到Vue3时主要有三处需要调整。第一是beforeDestroy改成了onBeforeUnmount或者onScopeDispose。如果你在setup里写动画逻辑用onScopeDispose能更自动地处理清理组件卸载时连手动调都不用调scope结束就自动执行清理回调。第二是响应式API的写法更灵活了。Vue3里可以用ref存displayValue也可以在watchEffect里写动画逻辑。我这里给一个最简单的Vue3版本示意const displayValue ref(0) let rafId null watch(() props.value, (newVal, oldVal) { start(newVal, oldVal ?? 0) }, { immediate: true }) onBeforeUnmount(() { if (rafId) cancelAnimationFrame(rafId) })第三点是Vue3的patch机制对动画更友好了。Vue3引入了静态提升、patchFlag和最长递增子序列这些优化对于像文本节点更新这样的小型改动比对范围会控制得比Vue2更精确。也就是说同样一个数字滚动组件放到Vue3里跑渲染层的开销会比Vue2更低动画也会更流畅。这也是vue3 diff相对vue2优化点在实际业务中的直接体现。6.2 在setup中管理动画的注意事项用Composition API写的时候有个非常实用的点是可以用effectScope来隔离副作用。如果你在某一个页面里创建了多个CountUp组件实例每个实例的RAF循环其实没有必要各自调度。Vue3里可以用一个共享的动画时间驱动所有实例这在Vue2里实现起来相对麻烦Vue3的API形态让这种集中管理更自然。不过大部分项目不需要这么极致的优化。普通场景下每个组件自己管理一个RAF回调完全够用关键点还是那个记得清理。6.3 uniapp场景的复用因为热搜词里出现了uniapp vue2项目这里多提一句如果你在uniapp项目里用的还是Vue2语法这个CountUp组件几乎可以无缝迁移。只需要把template里的span替换成view或者text然后注意uniapp的H5端和小程序端对requestAnimationFrame的支持程度。在小程序端建议改用setInterval方案或者用uniapp提供的自定义导航和渲染能力来实现相似的视觉效果。如果是在H5端RAF是可以直接用的组件代码不用改。如果是App端则要区分vue页面和nvue页面nvue里渲染机制不同这个组件就得重新实现了。7. 组件的进阶扩展思路做完基础版之后这个组件其实还能往好几个方向扩展。我可以分享一下目前实践过或考虑过的方向供大家参考。第一个方向是支持数轴上不同显示格式。比如金额类的数字自动转化为“1.2万”、“3.4亿”这个转换逻辑可以内聚到formatValue函数里对外暴露一个unit props传入auto就能根据数值大小自动选择单位。注意自动转单位后动画过程里的值和最终显示值在位数上不一致处理时要让整个动画过程都走同一个格式化函数否则会出现中间过程显示完整数字、结束却变成缩写单位的割裂感。第二个方向是音频或视觉反馈。在一些比赛、答题、PK类页面里数字滚动可以配上音效或者增加数字跳动时轻微放大再缩小的动效。这个在CSS里配合transition就能实现但要注意不能让动画在每帧都触发CSS动画否则会卡。第三个方向是将组件从纯展示升级为可交互。比如支持鼠标悬停暂停、点击加速、双击重置。很多销售战报大屏和游戏化运营页面喜欢这种交互方式代码上其实就是在现有组件上多加几个事件绑定复用start和cancelAnimation方法即可。我个人在实际操作中的体会是数字滚动这个需求看起来很轻但真正写好需要处理的生命周期管理、边界情况、性能调优一点也不少反而因为实现简单很多人容易在细节上翻车。最后再分享一个小技巧如果你的组件要在多个项目里复用记得把easingMap、格式化函数这些纯逻辑单独拆成一个工具文件不要在组件里写死这样以后接Vue3或者跨端项目时RegExp逻辑可以直接复用。
阅读完成 · 觉得有帮助?
咨询建站