CSS animation 与 @keyframes:多阶段动画、可暂停循环和它跟 transition 的本质差异
transition 和 animation 经常被放在一起讲成"CSS 动画的两种写法",但这个说法容易让人忽略一个更本质的区别:transition 描述的是"从状态 A 到状态 B 的一次插值",它天然依赖外部触发(hover、加类名、改样式),本身不构成一段独立的时间线;animation 配合 @keyframes 描述的是一段自带起止、可以有任意多个中间关键帧、可以循环、可以暂停的时间轴,它不需要任何外部触发就能自己跑起来。这不是两种写法的风格差异,是两种模型:一个是"状态插值器",一个是"时间线播放器"。
团队里做加载指示器、进度动画、多阶段的强调提示(比如新消息小红点的呼吸效果)时,写 transition 会立刻卡壳,因为这些效果压根没有一个明确的"目标状态"可以过渡到——它们是持续进行的、有节奏的、甚至需要中途暂停恢复的。这篇就把这一类问题过一遍。
@keyframes 描述的是序列,不是两个端点
transition 只能定义起点和终点两个样式快照,中间怎么变全靠浏览器按缓动曲线插值。@keyframes 可以在 0% 到 100% 之间打任意多个关键帧,每一帧都可以指定一组独立的样式:
1@keyframes pulse { 2 0% { 3 transform: scale(1); 4 opacity: 1; 5 } 6 50% { 7 transform: scale(1.15); 8 opacity: 0.7; 9 } 10 100% { 11 transform: scale(1); 12 opacity: 1; 13 } 14} 15 16.badge { 17 animation: pulse 1.6s ease-in-out infinite; 18}
这段呼吸效果如果用 transition 实现,得靠 JS 不断切换类名在两个状态之间来回摆动,还得自己维护一个定时器。@keyframes 把这段节奏写成声明式的关键帧序列,浏览器自己按时间轴推进,不需要额外的 JS 心跳。
多阶段动画的价值在这类场景更明显:一个提示气泡先放大出现、停留、再缩小消失,中间还要带一次轻微的旋转抖动,这是三个以上的状态节点,transition 完全没法描述这种序列,只能靠 @keyframes 把每一帧的姿态都定下来:
1@keyframes attention { 2 0% { 3 transform: scale(0) rotate(0deg); 4 opacity: 0; 5 } 6 60% { 7 transform: scale(1.1) rotate(-4deg); 8 opacity: 1; 9 } 10 80% { 11 transform: scale(0.95) rotate(2deg); 12 } 13 100% { 14 transform: scale(1) rotate(0deg); 15 opacity: 1; 16 } 17}
关键帧的百分比不需要均匀分布,60%、80% 这种不对称的节点恰恰是做出"弹一下"手感的关键——如果全部按 0%、33%、66%、100% 均匀切,动画会显得机械。这一点和 transition 的缓动曲线思路完全不同:transition 靠一条曲线函数控制整个过程的节奏,animation 靠关键帧的位置分布控制节奏,两者不是同一套心智模型,不能把"transition 那套缓动经验"直接套到 @keyframes 上。
animation-fill-mode:动画放完之后,元素停在哪
transition 没有这个问题——过渡完成后,元素自然停在你写的终态样式上,因为终态本来就是一个真实的 CSS 规则(比如 .card:hover)。但 animation 不一样:@keyframes 里的 100% 只是动画期间的一个临时状态,动画结束后,浏览器默认会把元素的样式打回它在动画开始之前的原始值,而不是停在关键帧的终点。
这个坑很容易在做"进场动画"时踩到:
1@keyframes slide-in { 2 from { 3 transform: translateY(20px); 4 opacity: 0; 5 } 6 to { 7 transform: translateY(0); 8 opacity: 1; 9 } 10} 11 12.toast { 13 animation: slide-in 0.3s ease-out; 14}
不加任何 fill-mode 的话,这个 Toast 动画播完的瞬间会有一次肉眼可见的闪烁——因为动画一结束,opacity 和 transform 立刻被重置回元素本来的样式(很可能是 opacity: 1; transform: none,看起来没问题,但如果原始样式里 opacity 不是 1,或者动画中间有更复杂的关键帧,这个回弹就会很明显)。解决办法是显式声明 animation-fill-mode: forwards,让元素在动画结束后保持在最后一帧的状态:
1.toast { 2 animation: slide-in 0.3s ease-out forwards; 3}
fill-mode 一共四个取值,团队里容易混淆的是 backwards 和 both。backwards 是让元素在动画开始之前(也就是 animation-delay 期间)就提前应用第一帧的样式,避免有延迟的动画在延迟期间露出原始样式的"闪一下";both 是把 forwards 和 backwards 合起来用,动画前应用首帧、动画后停在末帧。带 animation-delay 的进场动画,我现在基本都写 both,不然延迟期间元素会先以原始姿态露一下脸,再跳到关键帧起点,等于多了一次不必要的跳变:
1.item { 2 animation: slide-in 0.3s ease-out 0.2s both; 3}
animation-play-state 配合 infinite:可暂停的循环动画
transition 没有"暂停"这个概念——它只是一次插值,插值完就结束了,没有可以挂起再恢复的中间状态。animation 因为本质是一条时间线,天然可以暂停在任意进度上再续播,这靠的是 animation-play-state 这个属性,取值只有 running 和 paused。
最典型的场景是加载指示器:页面在拉数据的时候转圈,数据到了就不需要再耗费性能持续渲染这个动画,但又不想让转圈"跳一下"停住,而是希望它在当前角度自然定住:
1.spinner { 2 width: 24px; 3 height: 24px; 4 border: 2px solid #e5e7eb; 5 border-top-color: #2563eb; 6 border-radius: 50%; 7 animation: spin 0.8s linear infinite; 8} 9 10.spinner.is-idle { 11 animation-play-state: paused; 12}
1function setLoading(el, loading) { 2 el.classList.toggle('is-idle', !loading) 3}
这里的关键点是:暂停不等于移除动画。如果直接把 animation 属性清空或者把元素隐藏掉,下次再触发加载时动画会从头播放,视觉上会有一次"归零再启动"的跳变;用 animation-play-state: paused 挂起,恢复 running 时它会从暂停的那个角度继续转,视觉上更连贯,尤其适合那种加载状态频繁切换、用户能明显感知到的场景(比如分页表格来回翻页时的局部 loading)。
另一个真实会用到的地方是"用户悬停时暂停自动播放的内容"。比如一个横向自动滚动的通知条,正常状态下用 @keyframes 做匀速平移循环,鼠标移上去就该停下来给用户时间看清楚:
1@keyframes marquee { 2 from { transform: translateX(0); } 3 to { transform: translateX(-50%); } 4} 5 6.ticker { 7 animation: marquee 12s linear infinite; 8} 9 10.ticker:hover { 11 animation-play-state: paused; 12}
这行代码不需要一行 JS,纯 CSS 就能做到"悬停暂停自动播放",这是 animation 相对 transition 明显更适合的场景——transition 从设计上就没有循环和暂停的概念,勉强用 JS 定时器模拟一个循环再暂停,代码量和可靠性都不如 animation-play-state 这一行。
GPU 加速属性在多阶段动画里更容易被破坏
transform/opacity 才能吃到合成层加速,这条经验在 transition 场景已经很熟悉了;但在 @keyframes 的多关键帧场景下,这条经验反而更容易被无意间破坏,因为关键帧多了之后,很容易在某一帧里顺手加了一个会触发布局的属性,而这一帧不像 transition 的两个端点那么显眼,容易被忽略掉。
比如一个卡片入场动画,原本只有 transform 和 opacity:
1@keyframes card-in { 2 from { 3 transform: translateY(12px) scale(0.96); 4 opacity: 0; 5 } 6 to { 7 transform: translateY(0) scale(1); 8 opacity: 1; 9 } 10}
后来产品要求卡片进场时阴影从无到有,很自然地会往关键帧里加 box-shadow:
1@keyframes card-in { 2 from { 3 transform: translateY(12px) scale(0.96); 4 opacity: 0; 5 box-shadow: 0 0 0 rgba(0, 0, 0, 0); 6 } 7 to { 8 transform: translateY(0) scale(1); 9 opacity: 1; 10 box-shadow: 0 12px 24px rgba(0, 0, 0, 0.12); 11 } 12}
box-shadow 本身不触发布局,但它没法走合成层加速,每一帧都要重新绘制(paint),列表批量出现几十张卡片同时播放这个动画时,box-shadow 的插值会让整体帧率明显往下掉,比单纯 transform + opacity 组合掉得多。这类问题在 DevTools 的 Performance 面板里看 Paint 那一层的火焰图很容易发现——多出来的紫色/绿色区块基本都能对应到某一帧新加的样式。团队里现在的约定是:关键帧动画如果要加阴影、渐变色这类视觉效果,尽量放在一个不参与动画的父级容器上,或者用 opacity 叠一层预先画好阴影的伪元素来模拟渐显,而不是直接把 box-shadow 塞进关键帧插值。
多阶段动画还有一个容易被忽略的性能点:关键帧数量本身不是问题,但如果同一个动画里有的关键帧只改 transform、有的关键帧还带了别的属性,浏览器没法在整个动画期间稳定地把这个元素固定在合成层上,可能出现"部分时间在合成层、部分时间要提升/降级"的抖动。排查这个问题比排查 transition 的性能问题麻烦一些,因为它不是简单的"这个属性行不行"的判断,而是要看整段关键帧序列里有没有属性不一致的情况。
Web Animations API:用 JS 直接操作动画时间线
CSS 的 animation 声明式地描述好节奏之后,剩下能用 JS 做的事情很有限——加类名切 play-state、监听 animationend/animationstart/animationiteration 这几个事件,基本就是全部家当。如果需要更细粒度地控制动画(比如让用户拖动进度条来 seek 动画到任意进度、或者根据滚动位置动态调整动画的播放速率),CSS animation 这套事件驱动的模型就不太够用了。
Web Animations API(简称 WAAPI)这几年在主流浏览器里已经比较成熟,Chrome、Firefox 早就支持,Safari 也在近几个版本补齐了大部分能力。它把 @keyframes 描述的那套关键帧序列直接搬到了 JS 里,用 Element.animate() 就能创建一个可编程控制的动画对象:
1const badge = document.querySelector('.badge') 2 3const anim = badge.animate( 4 [ 5 { transform: 'scale(1)', opacity: 1 }, 6 { transform: 'scale(1.15)', opacity: 0.7, offset: 0.5 }, 7 { transform: 'scale(1)', opacity: 1 } 8 ], 9 { 10 duration: 1600, 11 iterations: Infinity, 12 easing: 'ease-in-out' 13 } 14)
animate() 返回的这个 Animation 对象带着一整套可编程接口:anim.pause()、anim.play()、anim.reverse(),还有最关键的 anim.currentTime,可以直接读写,相当于给动画装了一个可以拖动的进度条。这一点是纯 CSS animation 做不到的——CSS 只能靠 animation-play-state 控制播放/暂停这两个离散状态,没法把动画精确 seek 到第 800ms 这种任意进度。
一个真实会用到这个能力的场景是"滚动关联动画":页面滚动条拖到什么位置,某个元素的动画就播放到对应的进度,这在纯 CSS 里几乎无解(scroll-timeline 这类 CSS 原生方案眼下还只是草案阶段,远没有到能用的程度),但用 WAAPI 配合 scroll 事件手动映射进度就是几行代码的事:
1window.addEventListener('scroll', () => { 2 const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight) 3 anim.currentTime = progress * anim.effect.getTiming().duration 4})
不过团队目前的判断是:WAAPI 更适合"需要 JS 强控制"的复杂交互动画,常规的加载指示器、进场退场这类跟着固定节奏走的效果,还是写在 CSS 里更省心——一是浏览器对纯 CSS animation 的优化路径更成熟,二是把动画逻辑放进样式表,designer 和其他前端同事改起来更直接,不用去读一段 JS 才知道动画长什么样。WAAPI 目前在我们项目里只用在那几个纯 CSS 确实做不到的地方,没有拿它去替换现有的 @keyframes。
animationiteration 事件:给循环动画做计数或变奏
animation-iteration-count: infinite 配合 animationiteration 事件可以做一类 transition 完全没有对应能力的效果——循环到第几次之后变个样子。比如一个提示动画默认是持续闪烁提醒用户,但闪够三次之后应该自动收敛成静止状态,避免长期打扰:
1@keyframes blink { 2 0%, 100% { opacity: 1; } 3 50% { opacity: 0.4; } 4} 5 6.notice { 7 animation: blink 0.8s ease-in-out infinite; 8}
1let count = 0 2notice.addEventListener('animationiteration', () => { 3 count += 1 4 if (count >= 3) { 5 notice.style.animation = 'none' 6 notice.style.opacity = '1' 7 } 8})
这里有个容易漏掉的细节:把 animation 设为 none 之后,元素会立刻脱离动画控制、回到普通样式层级,如果这时候恰好停在关键帧的中间态(比如 opacity: 0.4),画面会卡在一个不完整的状态上,所以停止动画的同时一定要手动把关键的样式补回正常值,不能指望动画"自己收尾在一个好看的地方"。这也是 animation 和 transition 另一个不对称的地方——transition 结束后终态是明确写在 CSS 规则里的,animation 被中途打断(无论是 animation: none 还是 display: none)都不保证停在一个"体面"的中间帧上。
animation-direction:alternate 让循环动画省一半关键帧
写呼吸灯、加载条这类来回摆动的效果时,一个常见的浪费是在 @keyframes 里把"去程"和"回程"都写一遍:
1@keyframes breathe-verbose { 2 0% { opacity: 0.4; } 3 50% { opacity: 1; } 4 100% { opacity: 0.4; } 5}
这段写法能跑,但它其实是把一次「暗到亮」又手工拼了一次「亮到暗」,只是塞进了同一个 @keyframes 里。animation-direction: alternate 可以把这两段拆开:@keyframes 里只写单程,方向的往返交给 alternate 自动补:
1@keyframes breathe { 2 from { opacity: 0.4; } 3 to { opacity: 1; } 4} 5 6.dot { 7 animation: breathe 1s ease-in-out infinite alternate; 8}
alternate 的效果是奇数次循环正向播(from 到 to),偶数次循环反向播(to 到 from),衔接处不会有跳变,因为反向播放用的就是同一条曲线的镜像。这里有个容易踩的细节:如果缓动曲线不是对称的(比如 ease-in),反向播放时浏览器会把整条曲线反过来用,而不是简单把 ease-in 换成 ease-out,这会导致往返两个方向的"手感"不一致——去程是"越来越快",回程视觉上会变成"越来越慢再突然到位"。团队里如果要做严格对称的往返动画,ease-in-out 这种本身对称的曲线更保险,能省掉这种方向不对称带来的违和感。
animation-direction 还有一个 alternate-reverse,配合 animation-iteration-count 是偶数还是奇数,起始方向也会不同,这个组合用得少,团队里基本只固定用 alternate,没必要为了省一点点关键帧代码去记全部四种取值的排列组合。
一个元素挂多段 animation:简写解析的坑
一个元素经常需要同时具备两种独立的动画节奏,比如一个悬浮卡片本身在做持续的轻微漂浮,同时鼠标移入时又要叠加一次强调性的抖动。animation 简写属性是支持逗号分隔多组值的,写法上和 transition 挂多个属性很像:
1.floating-card { 2 animation: 3 float 3s ease-in-out infinite, 4 shake 0.4s ease-in-out; 5}
这里真正容易出错的地方在简写里字段的个数不一致时的解析规则。animation 简写的字段顺序是 duration | timing-function | delay | iteration-count | direction | fill-mode | play-state | name,如果两段动画里一段写了完整的七个字段、另一段图省事只写了 name 和 duration,浏览器不会用"缺的字段用第一段的值补全"这种直觉去解析,而是每一段都独立地按默认值补全缺的字段。也就是说下面这种写法里,shake 并不会继承 float 的 infinite:
1.floating-card { 2 animation: 3 float 3s ease-in-out infinite, 4 shake 0.4s; 5}
shake 这里的 iteration-count 默认是 1,只播一次,这是符合预期的;但团队里真正出过的问题是反过来——两段动画都想要 infinite,图省事只在第一段写了,结果第二段悄悄变成只播一次,排查了很久才发现是简写省字段导致的默认值没继承。逗号分隔多段动画时,每段该写的字段都要写全,不要依赖"抄前面一段"的直觉,这是简写属性的通用坑,transition 挂多属性时其实也有同样的规则,只是因为 transition 通常只用 property/duration/easing 三段,字段少,问题不容易暴露。
steps():不是所有动画都需要平滑过渡
ease、linear、cubic-bezier() 这些缓动函数的共同点是连续插值,每一帧的位置都是平滑计算出来的。但有一类效果反而需要"跳跃式"的过渡,比如逐帧的雪碧图(sprite)动画、或者数字滚动到某个值时故意做出机械的"哒哒哒"节奏而不是丝滑滚动。这时候需要的是 steps() 这个阶梯缓动函数,它把一段过渡切成固定数量的离散步骤,每一步之间直接跳变,没有中间插值:
1@keyframes sprite-walk { 2 from { background-position: 0 0; } 3 to { background-position: -800px 0; } 4} 5 6.character { 7 width: 100px; 8 height: 100px; 9 background-image: url('/walk-sprite.png'); 10 animation: sprite-walk 0.8s steps(8) infinite; 11}
雪碧图总共 8 帧、宽度 800px,steps(8) 让动画在 0.8 秒内正好切换 8 次背景位置,每一步都是精确的一帧画面,不会出现两帧之间的中间态背景位置(如果用 linear,background-position 会被连续插值,画面会在两帧图案之间做诡异的滑动叠加,而不是清晰的逐帧切换)。
steps() 还有一个容易忽略的参数决定跳变发生在每一步的开头还是结尾——steps(8, end)(默认)是每一步维持当前帧直到步末才跳到下一帧,steps(8, start) 是一开始就跳到下一帧再维持。这个差异在雪碧图这种场景不算特别关键,但在做打字机效果(逐字符出现文字)时选错会导致首字符延迟一拍才出现或者动画一开始就先跳了一格,团队里这类效果基本固定用 steps(n, end) 配合等宽字体和 overflow: hidden 来实现。
用 DevTools 的 Animations 面板而不是肉眼纠错
transition 出问题基本靠肉眼对比两个端点就能定位,但多阶段的 @keyframes 动画一旦效果不对,很难靠"感觉"判断是哪一帧、哪个属性出的问题。Chrome DevTools 里有个专门的 Animations 面板(命令面板搜 "Show Animations" 打开),录制到一个正在播放的动画后,会把整条时间线按关键帧展开,每一帧对应的时间点、每个属性的插值曲线都画出来,还可以把整体播放速度调慢到 10% 甚至更低,逐帧核对某个属性是不是在该出现变化的地方变化了。
这个面板比单纯读代码排查更直接的地方在于:animation-fill-mode、多段 animation-delay 叠加之后,实际的时间轴经常和写代码时脑子里想的对不上(比如以为几个卡片是错峰进场,实际因为 delay 计算错了全部堆在了同一时刻),面板里的时间线是按真实计算结果画出来的,一眼就能看出对不对,不需要靠加 console.log 或者肉眼盯着页面数时间。排查循环动画卡顿的时候,也可以在这个面板里把动画拖到某一帧暂停,配合 Performance 面板的录制看这一帧单独的开销,比通篇录制再回放找问题快很多。
Vue 2.6 里的 <transition> 组件对付不了循环动画
Vue 2.6 的 <transition> 组件本质上是在 DOM 插入/移除的两个时机自动加减 v-enter/v-enter-active/v-leave/v-leave-active 这几个类名,再监听 transitionend(或者 animationend,Vue 会自动探测用的是哪一种)来判断动画什么时候结束、决定何时真正把元素移出 DOM。它的设计前提是"动画有一个明确的结束时间点",这个前提对 transition 天然成立,对一次性播放的 animation(比如 <transition> 触发一个 @keyframes 定义的进场效果)也成立,但对 infinite 循环动画完全不成立——如果进场动画本身是 infinite,animationend 事件永远不会触发,Vue 会一直等不到这个事件,走进内置的超时保护逻辑(默认给不出结束时间就报一个警告,或者干脆卡住转场状态)。
这也是团队里踩过的一个真实问题:给 <transition> 包裹的元素在 enter-active-class 上直接挂了一个 infinite 的呼吸动画,指望它"进来之后一直呼吸",结果元素一直停留在 fade-in-enter-active 这个类名上,没有被移除——Vue 一直在等这个类名对应的动画触发 animationend,但 infinite 动画从设计上就不会结束,这个事件永远等不到,过场状态自然也就卡住不清理。修法是把"进场动效"和"进场后持续存在的循环动效"拆成两层——<transition> 只负责一次性的、有限时长的进场/退场动画,负责触发 enter-active/leave-active 里那段一次性的 @keyframes;循环动画则挂在元素自身固定的类名上,跟 <transition> 的生命周期完全脱钩:
1<template> 2 <transition name="fade-in"> 3 <div v-if="visible" class="badge"> 4 <span class="badge__pulse"></span> 5 新消息 6 </div> 7 </transition> 8</template> 9 10<style scoped> 11.fade-in-enter-active { 12 animation: fade-in 0.25s ease-out; 13} 14 15@keyframes fade-in { 16 from { opacity: 0; transform: translateY(-4px); } 17 to { opacity: 1; transform: translateY(0); } 18} 19 20/* 循环动画不挂在 transition 的类名上,独立跑自己的时间线 */ 21.badge__pulse { 22 animation: pulse 1.6s ease-in-out infinite; 23} 24</style>
这样 <transition> 只需要等 fade-in 这个一次性动画的 animationend 就能正确收尾,badge__pulse 的持续呼吸完全不受 Vue 过渡状态机的管理,各自的生命周期不互相纠缠。这个拆分思路在 <transition-group> 列表动画里更重要——列表项进场时如果同时还带着自己的循环强调动画,一旦让 <transition-group> 去等一个不会结束的 animationend,整个列表的过渡状态都会卡住,后续的插入/删除动画可能完全不触发。
prefers-reduced-motion 对 animation 的处理和 transition 不一样
给"减少动态效果"的用户做兜底时,transition 类效果只要把 transition-duration 压到接近 0 基本就妥了,因为它只是一次插值,压缩时长不影响它的"完成态"是什么。但 infinite 的 animation 不能简单套用同一个思路——如果只是把 animation-duration 压到 0.01ms,循环动画依然是循环的,只是循环得飞快,视觉上会变成一团高频抖动的噪点,比原来的动效更容易引起不适,是彻头彻尾的反效果。
对循环动画正确的兜底方式是同时把 animation-iteration-count 也压到 1,让它退化成只播一次就停:
1@media (prefers-reduced-motion: reduce) { 2 .spinner, 3 .ticker, 4 .badge__pulse { 5 animation-duration: 0.01ms !important; 6 animation-iteration-count: 1 !important; 7 } 8}
这里还有个隐蔽的连带问题:像加载指示器这类本身承担"正在加载"这个信息传达功能的循环动画,一旦被压成播一次就停,用户会失去"是否还在加载"这个视觉信号。团队里的处理方式是把这类有信息价值的循环动画排除在通用兜底规则之外,改成用一个静态的替代提示(比如换成一个不循环的文案"加载中…"),而不是简单粗暴地让所有 infinite 动画都套同一条降级规则——纯装饰性的循环动画(呼吸灯、跑马灯)可以直接停,承担状态提示功能的循环动画需要有对应的静态替代方案,两者不能一刀切处理。
该用 animation 还是攒成一串 transition
团队里 code review 时最容易吵起来的判断标准,其实可以简化成一句话:这个效果有没有一个自己的时间线,需不需要循环、暂停、或者中途变速。如果答案是否,两个明确的状态端点、由用户操作触发,那就是 transition 的地盘,没必要多绕一层 @keyframes 的命名和 fill-mode 心智负担;如果这个效果自己会动、需要循环、暂停后要接着上次的进度播、或者需要三个以上关键帧才能描述清楚,那就该上 animation,硬用一堆 transition 加 JS 定时器去模拟,代码量和可维护性都会输给声明式的关键帧序列。
至于要不要上 Web Animations API,我们现在的判断更保守一些:只有当交互本身需要动画进度可读可写、可以被外部输入(滚动、拖拽)实时驱动时才用,其余的循环动画、进出场动画,@keyframes 加 animation-fill-mode、animation-play-state 这一套已经能覆盖团队里绝大多数真实需求,没必要为了赶新技术把简单的效果也搬进 JS 里维护。
多个循环动画叠加时的相位对齐问题
一个组件同时挂了两个独立的 infinite 动画,比如一个通知徽标既有缩放的 pulse、又有边框颜色渐变的 glow,两段动画各自的 duration 不一样,会出现一个不容易预料的现象——它们的相位会随时间不断错开,重合的视觉效果每次都不一样。比如 pulse 周期 1.6s、glow 周期 2s,这两个数字的最小公倍数是 8s,也就是说这个组合动画要跑满 8 秒才会回到最开始那个相位组合,8 秒之内的每一次循环,两段动画叠加出来的视觉效果都是新的。
这在做一次性强调效果时没问题,但如果需要"每次循环看起来完全一样"(比如做成品牌 loading 动画,要求视觉上是稳定重复的),就必须让多段动画的周期成简单的整数倍关系,比如都定为 1.6s 和 3.2s,而不是随手拍脑袋的两个互质数字。这个问题在只有一个 animation 的场景完全不存在,只有多段独立循环动画叠加时才会暴露,团队里现在设计带循环动画的组件时,会先把涉及到的所有周期列出来算一下最小公倍数,公倍数太大(意味着要跑很久才重复一次相位)就说明这几个周期选得不合适,需要重新调整成能对齐的数值。
关键帧命名冲突:全局作用域的隐藏坑
@keyframes 定义的动画名是全局的,不像 CSS Modules 或者 Vue 的 scoped 样式那样天然带作用域隔离。团队里用 Vue 单文件组件时,<style scoped> 只会给普通的选择器加上 data-v-xxx 属性选择器做隔离,但 @keyframes 的名字本身不会被这套机制处理——如果两个不相关的组件都定义了一个叫 fade 的 @keyframes,后加载的那个会直接覆盖前一个,两个组件的动画效果会互相串味,而且这种冲突在开发环境经常因为组件加载顺序凑巧一致而不会暴露,一到生产环境按不同的 chunk 拆分顺序加载就出现问题,排查起来很容易被误认为是别的原因(样式没生效、缓存没刷新)。
解决办法很直接,但容易被忽略:给项目里的关键帧命名统一加上组件级别的前缀,而不是图省事用 fade、slide、spin 这类过于通用的名字:
1@keyframes badge-pulse { 2 /* ... */ 3} 4@keyframes toast-slide-in { 5 /* ... */ 6}
这条约定和选择器命名要不要用 BEM 是同一层道理——只是很多人只对普通选择器有这根弦,对 @keyframes 的名字反而放松了警惕,因为它长得不像"选择器",容易被当成一个无关全局样式表的局部标识符来对待。