☰
Vue 3 数字滚动组件与翻牌器从零实现:大屏数据动画实战指南
2026/10/2 10:44:36 网站建设 项目流程

客户站在大屏前那句话,我记到今天:“你看人家的数字,都是自己滚上去的,我们的怎么就是个干巴巴的数?”那是一个典型的数据大屏项目,上头指标差不多都齐了,就是整体缺一点“活”的感觉。于是我把 Vue 数字滚动组件和翻牌器组件从零实现了一遍,做完以后整个页面的观感完全不一样。这篇文章就是记录这个过程:两种效果的核心原理、可以直接复制的代码、放在大屏项目里会遇到哪些真实问题,以及我怎么处理这些问题的。

如果你正在做数据大屏、数据看板、后台统计页,或者单纯想把数字展示做高级一点,这篇文章应该能帮你省不少弯路。我会把组件拆开揉碎讲清楚,尽量让你看完就能直接用到自己的 Vue 3 项目里。

1. 两种效果,长得像但不是一个东西——先弄清数字滚动和翻牌器的本质差异

很多朋友一提到“数字动起来”,就默认是同一个组件,其实数字滚动和翻牌器是两个视觉语言完全不同的方案。我在一开始也混在一起做,后来发现需求方嘴上说“都要”,实际上要的是两种东西。

1.1 数字滚动:用连续变化表达趋势感

数字滚动,说人话就是“数字从起始值平滑地增长或减少到目标值”,类似计价器、温度计、汽车仪表盘的读数变化。它强调的是一个连续过程,你的视觉焦点会跟着数字一点点爬升,从而在潜意识里建立起“这个数据正在增长”的认知。

典型应用场景有这些:

  • 大屏上的销售额、订单量、访客数,希望数字从 0 滚到目标值,制造“今日数据正在实时累计”的错觉。
  • 排行榜上的分数变化,比如主播热度从 100 万涨到 130 万,有一个平滑递增的过程。
  • 竞赛场景里的比分变化,数字平滑跳动,观众能感受到节奏。
  • 后台数据卡片,比如待办数量、消息未读数,刷新数据时有一个过渡,而不是“啪”地一下变掉。

数字滚动的核心诉求是“趋势”,所以它天生适合表现增长、下降、累计这类有方向感的数值。

1.2 翻牌器:用离散机械感强调“变化瞬间”

翻牌器走的是另一条路。它的视觉原型是机场航班的翻页显示屏、老式机械翻页钟,每个数字位是一张独立的卡片,数值变化时卡片“啪”一声翻过去,带有明显的机械感和仪式感。翻牌器强调的不是过程的连续,而是“这一刻发生了改变”的冲击力。

典型应用场景:

  • 倒计时、秒表、时钟类组件,每一秒翻一页,节奏感非常强。
  • 抽奖活动里的中奖号码滚动,数字位一格一格翻转,现场气氛直接拉满。
  • 导弹发射、火箭点火这类大屏特效,数字倒数配合翻牌效果,仪式感比普通数字滚动强一个量级。
  • 车牌号、工号、订单编号这类固定位数的编码展示,用翻牌器显得更工业风。

翻牌器的核心诉求是“仪式感”,它适合表现变化本身,而不太适合表达连续趋势。

1.3 怎么选:从项目需求反推组件方案

我之前吃过一次亏,项目里需要展示实时在线人数,客户说“要滚动效果”,我上来就用了翻牌器。结果人数每一两秒就跳一次,翻牌卡片噼里啪啦翻个不停,观众的眼睛不知道该盯哪张牌,反而很累。后来换成数字滚动,平滑过渡,视觉上舒服得多。

反过来,如果是抽奖环节需要的倒计时,用数字滚动会显得软绵绵的,完全没有那种“最后一秒要揭晓了”的紧张感,翻牌器才是对的。

所以我的选型逻辑很简单:

场景维度选数字滚动选翻牌器
数据变化频率高频、平滑,适合每秒多次更新低频,每秒一到两次,甚至更慢
视觉诉求表达趋势、累计、增长表达变化、仪式感、机械感
数字位数可变位数也能接受固定位数效果最好
典型场景销售额、访客数、分数爬升倒计时、抽奖号码、时钟秒表
数值变化形态连续数值离散数字位

另外还有一个实际考量:翻牌器的实现成本更高,单个数字位的 DOM 结构和 CSS 变换比数字滚动复杂,如果页面里同时出现十几个翻牌器,性能压力也更大。纯展示趋势的场景,数字滚动性价比更高。

2. 数字滚动组件从零实现——动画循环、缓动曲线和组件封装细节

数字滚动的原理听起来很简单:起一个定时器,每隔一段时间更新一次数字文本,从起始值慢慢推进到目标值,到了就停。但真正把它做出来能直接用于项目,你会发现有很多细节值得推敲。

2.1 为什么用 requestAnimationFrame 而不是 setInterval

我在第一版实现里用的是setInterval(16),跟 60 帧刷新率强行对齐,结果在性能波动时动画会出现明显的跳帧和卡顿。后来换成了requestAnimationFrame(后续简称 rAF),它的执行频率由浏览器根据屏幕刷新率自动决定,页面渲染一次就回调一次,动画和屏幕刷新天然同步,视觉上要平滑得多。

从原理上说,rAF 还有一个隐藏优势:当页面切换到后台标签页时,浏览器会自动暂停 rAF 回调,不会像 setInterval 那样在后台继续空转消耗 CPU。这一点在数据大屏常驻页面的场景里尤其重要,后面讲性能优化的时候我还会提到。

2.2 核心动画循环和缓动函数

数字滚动不能匀速推进,因为人眼对匀速运动的观感是“死板”。现实世界里的物体运动,从启动到停止通常有一个加速-减速的过程。我用的是一段最常用的缓动曲线,easeOutCubic,它的函数形式是:

function easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3); }

其中 t 是时间进度,取值范围 0 到 1。这个函数的特性是前期增长快、后期逐渐减速,正好符合数据大屏“数据冲上去,稳稳停住”的观感。如果你试过linear匀速,会发现数字像机器一样一格一格跳,一点都不高级。

有了缓动函数,动画循环就可以写成这样:

let rafId: number | null = null; let startTime = 0; let fromValue = 0; let toValue = 0; function animate(timestamp: number) { if (startTime === 0) { startTime = timestamp; } const elapsed = timestamp - startTime; const progress = Math.min(elapsed / duration, 1); const eased = easeOutCubic(progress); // 当前帧显示的数值 = 起始值 + (目标值 - 起始值) * 缓动比例 const currentValue = fromValue + (toValue - fromValue) * eased; render(currentValue); if (progress < 1) { rafId = requestAnimationFrame(animate); } else { // 动画结束,把目标值精确渲染一次,避免浮点误差 render(toValue); } }

这个代码里面有两个容易被忽略的细节。第一,startTime必须在第一次回调时赋值为当前的timestamp,而不是用Date.now(),因为 rAF 的回调参数是一个由浏览器统一对齐的时间戳,如果拿系统时间和它混用,第一次回调时可能会出现一个负的elapsed。第二,动画结束时要手动把目标值精确渲染一次,因为浮点累加会把最终数值算成类似 999.999999 的结果,直接渲染会导致显示值比目标值少一点。

2.3 组件封装:入参设计、重触发和数据格式化

把上面这些逻辑包装成一个 Vue 3 组件,我用的是组合式 API 的写法,整体结构可以看下面这段简化代码:

<template> <span ref="numberRef" :data-target="value">{{ displayText }}</span> </template> <script setup lang="ts"> import { ref, watch, onMounted, onBeforeUnmount } from 'vue'; interface NumberRollerProps { value: number; duration?: number; decimals?: number; prefix?: string; suffix?: string; useGrouping?: boolean; } const props = withDefaults(defineProps<NumberRollerProps>(), { duration: 2000, decimals: 0, prefix: '', suffix: '', useGrouping: true, }); const numberRef = ref<HTMLElement | null>(null); const displayText = ref<string>('0'); let rafId: number | null = null; let startTime = 0; let fromValue = 0; let toValue = 0; function formatNumber(value: number): string { // 先按小数位固定,再决定是否加千分位分隔 let fixed = value.toFixed(props.decimals); if (props.useGrouping) { const parts = fixed.split('.'); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); fixed = parts.join('.'); } return `${props.prefix}${fixed}${props.suffix}`; } function render(value: number) { displayText.value = formatNumber(value); } function playAnimation() { if (rafId !== null) { cancelAnimationFrame(rafId); } fromValue = parseFloat(displayText.value.replace(/,/g, '').replace(props.prefix, '').replace(props.suffix, '')) || 0; toValue = props.value; startTime = 0; rafId = requestAnimationFrame(animate); } function animate(timestamp: number) { if (startTime === 0) { startTime = timestamp; } const elapsed = timestamp - startTime; const progress = Math.min(elapsed / props.duration, 1); const eased = 1 - Math.pow(1 - progress, 3); const currentValue = fromValue + (toValue - fromValue) * eased; render(currentValue); if (progress < 1) { rafId = requestAnimationFrame(animate); } else { render(toValue); } } watch(() => props.value, playAnimation); onMounted(() => { render(props.value); }); onBeforeUnmount(() => { if (rafId !== null) { cancelAnimationFrame(rafId); } }); </script>

你可能会问,为什么playAnimation要从显示文本反解析出fromValue,而不是直接用一个变量在组件内部记录上一次的目标值?我这么做的理由是:如果外部通过v-model直接传入一个已经格式化好的字符串,或者组件内嵌了单位等信息,反解析能确保起点值永远来自用户真正看到的内容,不会出现“上一次显示 1,234,这次却以为从上一次目标值 1234 开始滚”的偏差。

2.4 边界条件:目标值比当前值小怎么办

数字滚动组件最容易被忽略的场景是目标值变小。比如刚才在线人数还是 10000,下一秒推过来的数据是 8600,很多实现会直接把数字从 0 重新滚到 8600,或者“咯噔”一下跳过去,两种都不理想。

正确的做法是让数字从当前显示值平滑地往下滚到目标值,制造一种“人数回落”的观感。因为我的fromValue取自当前显示文本,天然就支持这个行为。你只需要在动画循环里正常计算currentValue = fromValue + (toValue - fromValue) * eased,当toValue < fromValue时,差值本身就是负数,数字自然会往下走。

不过这里要注意一个体验细节:下降动画的时长和上升应该不同。数据大屏里“下降”通常意味着指标变差,视觉上应当更干脆一点,不要让观众盯着数字慢慢滑落,那会放大焦虑感。我会在组件里提供一个durationDown属性,取值通常只有上升时长的一半,比如上升 2000ms,下降 800ms。这个细节做完之后,客户的原话是:“跌的时候干脆,很果断,看着舒服。”

3. 翻牌器实现——CSS 3D 翻转和半区时序控制

翻牌器比数字滚动复杂,因为它的视觉单元是“数字位卡片”,每一位都是一个独立的翻转体。如果直接把数字字符串塞进一个元素里做翻转,翻出来只会是一团乱麻。我采用的方案是上下两半拆分,这也是机械翻页屏的标准模拟方式。

3.1 单个数字位的 DOM 结构与翻转时序

每一个数字位卡片,在任一时刻都同时存在四层:

  • 旧数字的上半层
  • 旧数字的下半层
  • 新数字的上半层
  • 新数字的下半层

以数值从 3 变成 4 为例,一次完整的翻转分两步。第一步,旧数字的上半层从 0 度向下翻转 90 度,同时新数字的上半层从 90 度翻到 0 度,完成上半区的切换。第二步,旧数字的下半层从 0 度向下翻 90 度,新数字的下半层从 90 度翻到 0 度,完成下半区的切换。

为什么要拆上下两半?因为真实的机械翻牌器就是上下两片独立转动的,这样翻转过程里观众看到的是“上半个新数字先落下,下半个新数字再跟上”,节奏感非常强。如果整卡直接旋转 180 度,视觉上更像一个硬币翻面,少了那种“唰啦”一下翻页的机械爽感。

以下是单个数字位的模板结构:

<template> <div class="flip-unit"> <!-- 旧数字上半层 --> <div class="flip-half flip-half--top flip-half--static"> <span class="flip-number">{{ currentDigit }}</span> </div> <!-- 旧数字下半层 --> <div class="flip-half flip-half--bottom flip-half--static"> <span class="flip-number">{{ currentDigit }}</span> </div> <!-- 新数字上半层 --> <div class="flip-half flip-half--top flip-half--animate-in"> <span class="flip-number">{{ nextDigit }}</span> </div> <!-- 新数字下半层 --> <div class="flip-half flip-half--bottom flip-half--animate-in"> <span class="flip-number">{{ nextDigit }}</span> </div> </div> </template>

3.2 关键 CSS:transform-origin 和 overflow 是灵魂

翻牌器的 CSS 有两个地方非常容易踩坑,一个是transform-origin,另一个是overflow: hidden。

先说 transform-origin。上半层的翻转轴在底部边缘,下半层的翻转轴在顶部边缘。如果不设置transform-origin,默认旋转中心是元素正中,翻出来会变成整张卡片绕中心旋转,完全不是翻页效果。所以上半层必须设transform-origin: center bottom,下半层设transform-origin: center top。

再说 overflow。上下半层都要设overflow: hidden,否则数字文字会溢出到另一半。为了把数字居中放置,上下半层里的数字 span 需要用不同的行高和垂直对齐方式:上半层的行高设成完整卡片高度,让文字垂直居中;下半层则要把行高设成 0,结合 transform 位移把文字顶到下半层的视觉中心。这段代码最关键:

.flip-half { position: absolute; left: 0; width: 100%; height: 50%; overflow: hidden; background: #1a1d24; backface-visibility: hidden; transition: transform 0.3s ease-in-out; } .flip-half--top { top: 0; line-height: 72px; transform-origin: center bottom; } .flip-half--bottom { bottom: 0; line-height: 0; transform-origin: center top; } /* 上半层翻转到位 */ .flip-half--top.flip-half--animate-in { transform: rotateX(90deg); } .flip-half--bottom.flip-half--animate-in { transform: rotateX(-90deg); }

当新数字的上半层进入时,它的初始状态是rotateX(90deg),然后过渡到 0 度;旧数字上半层则从 0 度过渡到rotateX(90deg)。下半层的方向相反。这些类名的切换由 JS 控制,通常一个翻转周期的总时长是 600ms,上半层占前 300ms,下半层占后 300ms,时间点用setTimeout或者transitionend事件触发。

3.3 新数字进入与旧数字退场的时序控制

控制时序的脚本是整个翻牌器最容易写乱的地方。我建议在单个数字位组件内部维护一个状态机,核心状态只有三个:idle(空闲)、top-flipping(上半层正在翻)、bottom-flipping(下半层正在翻)。当外部传入的 digit 变化时,状态机从idle进入top-flipping,给上半层相关元素加上翻转类名;300ms 后进入bottom-flipping,给下半层加类名;再 300ms 后,把旧数字层的内容替换成新数字,清除所有翻转类名,回到idle。

这里有一个必须处理的坑:如果在翻转过程中又来了一个新数字,比如 3 翻到 4 翻到一半,外部直接推到 5,这时候不能简单重启动画。我的做法是:把一次翻转周期看成一个原子操作,模块内部只响应上一次翻转结束后的变化。如果在翻转期间收到新 digit,就把目标值暂存起来,等当前翻转结束后立即再触发一次翻转。这样可以避免元素在半空中被改内容导致的视觉错乱。

3.4 多位数联动与固定位数处理

翻牌器通常是一个多位数序列,比如 6 位数字、8 位数字。父组件需要先把目标数值拆成单个数字位,再分别传入每个 FlipUnit。

拆位逻辑:

const targetText = String(targetValue).padStart(6, '0'); const digits = targetText.split(''); // ['0','0','1','2','4','8']

这里有个实际经验要分享:翻牌器必须固定位数。如果今天展示 9999,明天变成 10000,位数从 4 位变 5 位,整个卡片布局会跳一下,视觉上非常突兀。所以不管实际数值是多少,我都会固定成一个足够大的位宽,比如 8 位,不足的前面补零。电商大屏上的销量、库存,8 位基本够用;如果是抽奖号码,一般固定 6 位。

多位数联动还有一个细节:当低位从 9 进位到 0 时,高位也应该同步翻转。比如 199 变成 200,百位从 1 翻成 2,十位从 9 翻成 0,个位从 9 翻成 0。如果父组件只是简单地把199和200分别拆成['1','9','9']和['2','0','0'],然后逐位比较传入,就能自然联动,不需要额外的进位逻辑。

3.5 视觉增强:数字表面的质感细节

一个翻牌器做出来能不能打动人,取决于几个 CSS 细节。第一,数字要有一定厚度感,我通常会给数字加一层轻微的text-shadow,颜色和背景同色系,看起来像刻在卡片上。第二,上下半层的边界处要有一条细线,模拟机械翻页的缝隙,用border-bottom: 1px solid rgba(0,0,0,0.4)就能实现。第三,翻牌器整体要有轻微的内阴影,而不是纯平板的色块,box-shadow: inset 0 2px 6px rgba(0,0,0,0.5)的效果就很明显。

这些细节在后面的大屏实测里非常关键,同样的核心逻辑,加上这层视觉处理,组件的高级感完全不一样。

4. 大屏场景下的性能优化——不能每个组件各跑各的定时器

数据大屏通常会在一个页面上放几十个数字组件,如果每个数字滚动组件都自己启动一个 rAF 循环,几十个循环同时跑,性能会明显吃紧。我在一个页面上放过 32 个数字滚动组件,动画最卡的时候帧率掉到十几帧,数字还偶尔会跳帧。问题不在单个组件,而在没有统一调度。

4.1 共享时钟:多个动画组件共用一个 rAF 循环

解决方案是做一个极简的动画时钟调度器,全局只保留一个 rAF 循环,所有数字滚动组件把它们的动画帧回调注册到一个集合里,统一在同一个循环里执行。

调度器核心逻辑:

type FrameCallback = (elapsedMs: number) => void; const callbacks = new Set<FrameCallback>(); let rafId: number | null = null; let lastTime = 0; function loop(timestamp: number) { const elapsed = Math.min(50, timestamp - lastTime); lastTime = timestamp; callbacks.forEach((callback) => callback(elapsed)); if (callbacks.size > 0) { rafId = requestAnimationFrame(loop); } else { rafId = null; } } export function addFrameCallback(callback: FrameCallback) { callbacks.add(callback); if (rafId === null) { lastTime = performance.now(); rafId = requestAnimationFrame(loop); } } export function removeFrameCallback(callback: FrameCallback) { callbacks.delete(callback); }

为什么elapsed要取Math.min(50, timestamp - lastTime)?因为在某些浏览器行为下(比如系统唤醒、切回页面、开发者工具打断),两次 rAF 之间可能隔了上百毫秒,如果不做钳制,动画会突然跳一大截,视觉上像“瞬移”。钳制到 50ms 以内,最多就是动画放慢一点,但不会出现不可接受的大跳跃。

每个数字滚动组件在使用时,不再自己调requestAnimationFrame,而是通过addFrameCallback注册一帧回调。组件卸载时调用removeFrameCallback移除,同时把内部动画状态清空。

4.2 页面隐藏时暂停动画

大屏页面通常挂在展厅的大电视上,但偶尔也会有人用浏览器单独开一个标签页预览。如果标签页切到后台,rAF 会被浏览器暂停,这一点是好的;但问题在切回标签页的瞬间,如果调度器计算lastTime的差值不做钳制,数字会瞬间从旧值“扑”到新值,看起来非常怪。

我的处理方式是在调度器里加一个document.hidden的监听。当检测到页面隐藏时,把lastTime重置为performance.now(),同时把每个组件内部记录的时间起点也重置。这样即使 rAF 被暂停了一分钟,恢复动画时也会从当前状态继续,而不是一次性跳过中间过程。

4.3 WebSocket 实时推送下的动画节流

数据大屏接 WebSocket 实时数据很常见,服务端可能每 500ms 推一次数据。如果每次推送都触发一次数字滚动动画,你会发现前一个动画还没播完,下一个又开始了,视觉上就是数字一直抖来抖去,根本停不下来。

我的经验是动画组件只响应“最终状态变化”,而不是每次推送都响应。具体来说,在组件里维护一个latestValue,每次外部数据进来只更新这个字段;而动画循环每一帧只做一件事:判断当前渲染值是否等于latestValue,不等才有必要启动滚动。因为latestValue覆盖速度可能比动画进度快,动画会自然地把推进终点设为最新值,而不是一次次重启。换句话讲,rAF 循环本身就有天然节流能力,只是你需要把“刷新逻辑”收敛到循环里,而不是放在数据推送的回调里。

这里还有一个经验:如果推过来的数值变化极频繁,比如每秒 10 次以上,建议在大屏后台对数据源做一层合并,比如每 500ms 只保留最后一个值。前端动画做再多优化,也不如数据源先做一次降频划算。

5. 我在真实项目里踩过的坑——从首屏闪烁到翻牌卡死

这部分是本文最有价值的沉淀,我把做这两个组件过程中实际碰到的坑按场景整理出来,每一个都是血与泪换来的。

5.1 首屏闪烁:数字从 0 滚上去,还是先看到 0 再滚上去?

最开始我的实现很简单,onMounted之后直接把value传进动画,数字从 0 滚到目标值。结果首屏渲染时,用户会清晰地看到页面上先出现一个“0”,然后才开始滚动。这个 0 闪一下,非常掉档次。

我自己最终采用的方案是:组件挂载时不直接开始动画,先用nextTick把目标值渲染成静态文本,等页面渲染完成后再延迟一个极短的时间启动滚动。延迟时间 50ms 左右就够,肉眼几乎感觉不到,但能避免首帧的“0 闪现”。

5.2 翻牌器动画卡死在半空中

有一次我调试翻牌器,发现连续快速改数字时,卡片会一直保持在一个 90 度的中间状态,像被冻住一样。排查了很久,最终确认是transitionend事件没有触发。原因是:在动画进行到一半时,我给同一元素添加了新的类名,或者改了display: none,导致浏览器中断了 CSS 过渡,transitionend事件没有正常派发。

解决这个问题的办法是双保险。第一,每个翻转阶段的结束点,既监听transitionend,又设置一个setTimeout作为兜底,谁先触发谁清除另一个。第二,不要在一个 CSS 过渡还没结束时就去修改同一个属性的过渡目标,宁可等一下,也不要强制中断。

我后来把这段“双保险”逻辑封装成了一个内部函数:

function waitForTransition(element: HTMLElement, timeout = 350): Promise<void> { return new Promise((resolve) => { let done = false; function finish() { if (done) return; done = true; element.removeEventListener('transitionend', finish); resolve(); } element.addEventListener('transitionend', finish); setTimeout(finish, timeout); }); }

5.3 v-for 里的 key 必须稳定,否则翻牌次数对不上

在批量渲染多个翻牌器时,我最初用数组下标作为 key,结果一旦某个数字位的内容变化,Vue 复用了组件实例,但组件内部不会重新触发翻转。典型的场景是:六位数字中只变了最后一位,前面五位没变,但因为数组有长度变化,下标对不上了,某些位会莫名翻转。

这个问题的根源是 Vue 的 diff 机制。我给每个数字位组件设置的 key 必须是稳定的、与业务相关的值,比如第几位数。用下标做 key,在数组长度不变时勉强能用,但一旦涉及插入删除,就会出各种各样的诡异问题。后来我统一改成一个带索引的 key,比如digit-0、digit-1,并且保证这个 key 在整个生命周期内不变化。

5.4 千分位和小数点:格式化逻辑里的边界问题

数字滚动组件里,千分位展示是个坑。因为动画过程是浮点数,不能每次都直接调用toLocaleString,否则动画中间会出现 1,234.567 这种带多位小数的奇怪展示。正确做法是先toFixed(decimals)固定小数位,再对整数部分做千分位分隔。而我封装的formatNumber函数,思路就是先toFixed,再按小数点前后分别处理,这样整个动画过程中每帧的位数都是稳定的。

还有一个更隐蔽的坑:负数。如果目标值是负数,千分位正则/\B(?=(\d{3})+(?!\d))/g会把负号前面的空位也匹配进去,结果输出-1,234可能变成-,1234之类的乱码。处理办法是先把符号位提取出来,单独处理数字部分,最后再拼回去。

5.5 多组件下的内存泄漏:路由切换后动画还在跑

大屏项目往往会有多个页面,用户从数据大屏切到报表页,翻牌器和数字滚动组件应该被彻底销毁。如果没有在onBeforeUnmount里清掉 rAF 和setTimeout,动画会继续在后台消耗性能,严重时还可能出现“组件已经销毁但定时器回调还在改 DOM”的报错。

我在组件销毁时统一做了三件事:取消 rAF 动画帧、清除所有setTimeout和transitionend相关监听、把对外暴露的状态归零。这套清理逻辑看着简单,但在多个组件同时销毁时能避免大量潜在报错。

5.6 Vue 2 项目里怎么迁移

如果你的项目还在 Vue 2,核心逻辑可以平移,但有三个差异要处理。第一,Vue 2 的$nextTick和 Vue 3 的nextTick作用一样,但实现细节不同,迁移时注意引入方式。第二,Vue 2 没有组合式 API 的onBeforeUnmount,需要写在beforeDestroy生命周期里。第三,Vue 2 的响应式系统在频繁修改文本节点时会触发更严重的更新开销,建议大量数字组件场景下直接操作 DOM 文本节点,避免走响应式渲染。这些差异不影响整体设计,改起来不算麻烦。

6. 组件沉淀与未来扩展——把滚动和翻牌做成可复用资产

做完这两个组件之后,我没有把它们留在单个项目里,而是抽成了一个内部 Vue 组件库的一部分,后续再遇到类似需求,直接import就能用。如果你也想这么做,有几个方向可以继续扩展。

第一,把数字滚动组件和翻牌器统一成一套“数值动画配置”。我后来做的一个版本里,外部只需要传入mode: 'scroll' | 'flip',组件内部自动切换实现方式,其余 props 基本一致。这样需求方想换效果时,改一个字段就行。

第二,支持更复杂的格式化需求,比如单位、正负号颜色、小数位、前缀后缀。大屏项目里经常有“百分比”“万元”“人数”这类带单位的数据,如果格式化能力做全,就能覆盖绝大多数业务场景。

第三,接入 ECharts / Canvas 大屏时的联动。数字滚动组件可以监听外部事件,比如用户点击某个图表区域时,数字滚动到对应数值。这个联动本身不难,难的是动画效果不能和数据刷新互相干扰。我当时加了一个简单的 GoToValue 方法,外部可以随时让组件跳到指定数值。

还有一个小技巧:在数字滚动组件的动画过程中,尽量用requestAnimationFrame统一调度,并且对外暴露暂停和恢复两个方法。大屏项目里有时需要“暂停动画让领导仔细看数据”,这个功能虽然不常提,但一旦提出来,没有预留设计就很麻烦。

在做翻牌器的过程中,我还尝试过用 Canvas 来实现翻牌效果,性能确实更好,但开发成本也更高。如果你需要同时渲染几十个翻牌器,Canvas 方案值得考虑;如果只是几个核心指标,DOM 方案完全够用,而且样式调整更灵活。

最后再聊一个我个人的体会。数字滚动和翻牌器这两个组件,难度都不算高,但它们特别能检验一个前端工程师对动画时序和渲染性能的敬畏程度。数字滚动考验你对动画循环、缓动曲线、格式化边界的理解;翻牌器考验你对 CSS 3D 变换和事件时序的掌控。把这些细节打磨到位,做到在任何数据变化速度下都能稳定运行、不闪屏、不卡顿、不泄漏,比直接引入一个大而全的动画库更能积累真正属于项目团队的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询