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 设计如下:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | Number/String | 0 | 要展示的目标值 |
| duration | Number | 2000 | 动画时长,单位毫秒 |
| delay | Number | 0 | 延迟启动动画的时间 |
| format | Function | null | 数值格式化函数 |
| separator | String | ',' | 千分位分隔符 |
| decimal | Number | 0 | 保留小数位数 |
| easing | Function | easeOutQuad | 缓动函数 |
| autoStart | Boolean | true | 是否在值变化时自动播放 |
事件方面只保留一个transition-end,动画播放完成后触发,方便父组件做后续操作(比如联动其他图表刷新)。
FlipCard组件稍微复杂一点,因为涉及位数管理和前后值对比:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | Number/String | 0 | 目标数字 |
| duration | Number | 600 | 每次翻牌动画时长 |
| digits | Number | 0 | 显示的位数,0表示自动按位数 |
| height | Number | 40 | 每张牌的高度,单位px |
| width | Number | 28 | 每张牌的宽度,单位px |
| autoStart | Boolean | true | 是否自动开始 |
这些接口不是凭空想出来的,而是从几个实际项目中抽出来的公共需求。比如千分位分隔符,不同国家地区习惯不一样,有人用逗号,有人用空格,所以做成可配置项而不是写死。
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 class="scroll-number"> <div class="scroll-number__digit" v-for="(digit, index) in displayDigits" :key="index" > <div class="scroll-number__digit-inner" :style="{ transform: `translateY(-${digit * 10}%)` }" > <span v-for="num in 10" :key="num">{{ 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-99) | 800-1200ms | 短促利落,不拖沓 |
| 中等(100-9999) | 1500-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 class="flap-card" :style="{ width: width + 'px', height: height + 'px' }"> <!-- 静态背景:始终显示旧数字的下半截 --> <div class="flap-card__half flap-card__half--bottom"> <span class="flap-card__num">{{ previousValue }}</span> </div> <!-- 静态前景:始终显示旧数字的上半截 --> <div class="flap-card__half flap-card__half--top"> <span class="flap-card__num">{{ previousValue }}</span> </div> <!-- 翻转的上半部分 --> <div class="flap-card__fold flap-card__fold--top" :class="{ 'is-flipping': isFlipping }"> <div class="flap-card__fold-inner"> <div class="flap-card__half flap-card__half--top"> <span class="flap-card__num">{{ currentValue }}</span> </div> </div> </div> <!-- 翻转的下半部分 --> <div class="flap-card__fold flap-card__fold--bottom" :class="{ 'is-flipping': isFlipping }"> <div class="flap-card__fold-inner"> <div class="flap-card__half flap-card__half--bottom"> <span class="flap-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 ref="scrollNumRef" :value="currentCount" />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 时序和动画帧里找到答案。