ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue 实战:用 @keyframes 关键帧动画搞定复杂动效

Vue 实战:用 @keyframes 关键帧动画搞定复杂动效 学习笔记整理到 Vue 动画系列的第二篇时我想先把上一篇的结论再拎一遍动画在 Vue 里实际上只有两条路线可走一条是 CSS Transition 过渡另一条就是这篇的主角——CSS 关键帧动画也就是keyframes加animation属性。很多刚接触 Vue 的同事会把这两者当成同一种东西结果在实现旋转、循环、多阶段动效时怎么调都别扭最后绕回 JS 定时器去模拟反而把性能做差了。这篇笔记的定位很明确把keyframes在 Vue 组件里如何注册、如何触发、如何跟业务数据联动讲透再给一个可以直接抄走的涟漪光圈扩散案例。无论你是刚开始写 Vue 组件还是正在做后台系统想给交互按钮加点质感这份笔记应该都能给你一套清晰的参考路径。1. 为什么一个动效要做成关键帧过渡动画搞不定的四种需求1.1 过渡动画的模型是“从 A 到 B”transition的工作方式其实很简单当元素的某个 CSS 属性值发生变化时浏览器自动计算起点和终点之间的中间帧完成补间插值。比如一个按钮 hover 时背景色从白色渐变到蓝色或者弹窗显示时opacity从 0 过渡到 1这些场景天生就适合过渡动画。但问题也恰恰出在这里你只能定义一段变化的左右两端中间过程怎么走、是否分段、是否循环、播完之后是否保持某帧状态transition一概不管。换句话说凡是需要“多段剧本”的动效过渡动画基本无能为力。举个我实际遇到过的场景。排行榜某个名次图标在做“微弱上升又回落”的强调动画时如果用 transition我只能写一个transform: translateY(-4px)的最终状态然后通过 JS 在几十毫秒后再改回去才能制造出“先上去又下来”的完整轨迹。且不说这段逻辑散落在事件回调里动画中间任何一次状态被重复触发视觉上就是一顿一卡的抖动。1.2 关键帧才是“多段剧本”keyframes可以从根本上解决这个问题。它允许你在一个动画里定义多个时间点比如0%、30%、80%、100%每个点上设置一组样式浏览器会把这些关键点之间的变化按时间进度自动插值。这是一整套完整的动画描述能力。更重要的是animation属性本身自带一套播放控制参数循环次数、播放方向、填充模式、延迟时间、暂停与继续。这些能力与 Vue 的生命周期没有任何依赖组件里随时可以组合使用。我在真实项目中遇到过的几种典型需求几乎都只能靠关键帧来完成涟漪光圈扩散一个圆环从点击位置向四周放大同时透明度渐隐到 0。数字跳动强调数字先上移 4px 再回落制造“弹一下”的视觉反馈。弹窗入场带轻微 overshoot先放大到 1.05再回落到 1.0形成弹性落点。无限旋转的 loading 图标iteration-count: infinite配合linear缓动。这类效果如果用 transition 去拼代码量会成倍增长而且每一处都要处理循环和重置的问题非常脆弱。把轨迹直接写进关键帧才是符合直觉的做法。1.3 什么时候用哪个先看这张对照表判断维度transitionanimation keyframes状态数量起点和终点两个两个或多个时间点循环播放不支持支持infinite反向与交替无原生支持reverse、alternate暂停与继续无直接控制animation-play-state批量延迟每个元素只能写自己的 transition-delayanimation-delay 可按层级错开与 Vue 组件联动通过 v-enter-active 等钩子类名同样的钩子类名也能直接绑定 class我的选型原则很简单两态之间的简单过渡用transition凡是涉及多阶段、循环、自动播放、需要暂停控制的直接用关键帧。想都不用想省得后面改需求时加大成本。2. 在 Vue 里注册关键帧三条路和两个重要注意点2.1 三条注册路径实际项目中一般有三种写法我分别说下它们的适用场景。第一种是写在全局样式文件里。比如项目根目录下的src/assets/style/common.css里面集中定义keyframes任何组件通过类名都能直接引用。我比较推荐把通用动画都集中到这里方便跨组件复用也能统一管理命名。第二种是写在组件的style scoped里。这种写法对单组件内使用最方便但有一个很容易踩的坑我在第 5 节会专门讲keyframes的名称在 scoped 编译时不会被自动更名。也就是说Vue 只是给选择器加了>template button :class{ bounce-once: isAnimating } clicktriggerBounce 弹一下 /button /template script setup import { ref } from vue const isAnimating ref(false) function triggerBounce() { if (isAnimating.value) return isAnimating.value true } /script style scoped .bounce-once { animation: bounceUpDown 0.6s ease-in-out; } keyframes bounceUpDown { 0% { transform: translateY(0); } 40% { transform: translateY(-16px); } 100% { transform: translateY(0); } } /style这个例子本身不难但有几个点很容易被忽略。动画播放结束后要把isAnimating置回false否则第二次点击时类名没有变化动画不会再触发。按钮上应该监听animationend事件来清理这个布尔状态。另外style scoped里的keyframes在编译后名字不会带哈希后缀。建议不要用fadeIn、scale这种过于通用的名字而是用bounceUpDown这种具体描述型命名。2.3 配合 transition 钩子类名的关键帧用法关键帧动画不仅能通过手动切换 class 来触发也能直接挂到 Vue 的transition组件钩子类名上。比如模态框出现时做缩放弹入、消失时做缩放淡出。transition namemodal-fade div v-ifvisible classoverlay !-- 弹窗内容 -- /div /transition.modal-fade-enter-active { animation: modalPopIn 0.3s ease-out; } .modal-fade-leave-active { animation: modalPopOut 0.25s ease-in forwards; } keyframes modalPopIn { 0% { opacity: 0; transform: scale(0.85); } 60% { opacity: 1; transform: scale(1.02); } 100% { opacity: 1; transform: scale(1); } } keyframes modalPopOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.9); } }需要特别注意离开动画的类名上要加animation-fill-mode: forwards也就是modalPopOut后面的forwards。原因在于 Vue 的退场机制是先挂载 leave 类等动画结束后才把元素从 DOM 移除。如果不让动画保持在最后一帧leave 动画还没结束元素状态就已经回到原始样式视觉上会突然闪一下。3. 让动画跟数据跑关键帧与 Vue 状态联动的三种姿势3.1 动态切换动画名业务里经常遇到这种需求不同状态下同一个元素要播放不同的动画。比如网络状态图标已连接时缓慢呼吸连接中时快速闪烁断开时左右晃动。最直接的做法是用计算属性返回对应的动画名。script setup import { computed } from vue const statusAnimation computed(() { if (props.networkStatus connected) return breath-slow if (props.networkStatus connecting) return blink-fast return shake-x }) /script template div :classstatusAnimation classstatus-icon/div /template动画名本身对应的是不同幅度、不同时长、不同运动轨迹的关键帧。把动画名和业务状态直接绑定代码读起来非常直观看到connecting就自动联想到blink-fast不需要在模板里堆一大堆v-if。这个方案唯一的代价是关键帧需要写得稍微细一点。比如breath-slow和blink-fast虽然都是透明度变化但时长和透明度范围不同建议把每个形态单独命名而不是复用同一个fadeIn。3.2 用 key 强制重播在真实开发里最高频的需求其实是“点击一下动一次”。我第一次做的时候用了一个套出错的写法按钮点击后只改变isAnimating布尔值第二次点击时虽然布尔值从false变回true但动画类名并没有变化浏览器发现计算后的样式没变直接就不播了。解决办法很简单加一个每次递增的key强制元素重挂载。重挂载之后浏览器会把它当成新元素动画必然重新执行。template span :keywaveCount classwave-item :style{ animation: wavePulse ${duration}s ease-out } /span /template script setup import { ref } from vue const waveCount ref(0) function emitWave() { waveCount.value 1 } /script这种方式在涟漪、点击反馈、水波纹这类“每次点击都走一遍”的场景里特别顺手。v-for列表渲染也一样把动画下标放进key里即可新 push 进去的元素会被创建旧元素会在清理函数中移除动画自然是新的。3.3 内联样式绑定动画参数关键帧定义的是运动轨迹相当于动画的“剧本”但播放速度、延迟、次数往往是动态的。这时可以把动画参数用内联style直接传给元素。button :style{ animationName: rippleName, animationDuration: ${duration}s, animationDelay: ${delay}s, animationIterationCount: iteration, animationTimingFunction: easing, } 点击 /button当这些参数变化时浏览器会重新计算动画属性不需要额外操作 DOM。动画名依旧可以从计算属性里拿参数来自 props 或者业务数据。这里能看出一个关键结论关键帧定义可以保持相对稳定真正变化的是播放参数。所以我在项目里倾向于把“运动轨迹”和“播放参数”拆开前者写死在keyframes里后者全部走 Vue 的动态绑定调试效率会高很多。3.4 用动画事件收尾关键帧动画有三个非常实用的事件animationstart、animationend、animationiteration。在 Vue 里可以直接绑定在元素上。典型用途是动画结束后的回调比如入场动画播完把焦点自动给到输入框或者清理涟漪动画产生的临时节点再或者做交互埋点。span classripple-circle animationendremoveRipple(ripple.id) /span有一点需要特别提醒动画事件是冒泡的如果父容器也监听了animationend子元素动画结束时事件会冒泡到父容器可能触发父级的回调。在事件处理函数里要判断event.target ! event.currentTarget否则会出现一个涟漪移除时把父级节点里的其他涟漪一并清理掉的情况。4. 完整案例做一个涟漪光圈扩散组件4.1 需求拆解涟漪光圈扩散这个效果在很多 C 端按钮、地图打点、直播点赞气泡上都能看到。点击按钮时一个圆环从点击位置向外扩散同时透明度逐渐淡到 0。用户连续点击时多个光圈可以叠加出现。把需求拆开看其实只有三步记录每次点击的光圈数据、按数据渲染光圈、动画结束后移除光圈。每条光圈需要记录四个信息唯一 id、触发位置 x/y、动画时长。位置用来定位光圈的起点时长用来控制速度id 用来作为v-for的 key。4.2 模板与样式实现template div classripple-btn clickonClickBtn 点击我 span v-forripple in ripples :keyripple.id classripple-circle :style{ left: ripple.x px, top: ripple.y px, animationDuration: ripple.duration s, } /span /div /template样式是整个组件里最需要细看的.ripple-btn { position: relative; overflow: hidden; } .ripple-circle { position: absolute; width: 80px; height: 80px; margin: -40px 0 0 -40px; border-radius: 50%; border: 2px solid #1677ff; transform: scale(0); opacity: 0.6; pointer-events: none; animation: rippleWave 0.6s ease-out infinite; } keyframes rippleWave { 0% { transform: scale(0); opacity: 0.6; } 100% { transform: scale(2.8); opacity: 0; } }这里有几个关键细节逐个说明。overflow: hidden把扩出按钮边界的光圈裁掉形成“按钮内部扩散”的效果。如果想让光圈穿透按钮边界出现就不能用 overflow要换成独立的 overlay 容器。pointer-events: none必须加在光圈上否则光圈会拦截按钮的点击事件第二次点击就点不中了。扩散动画只用transform: scale()和opacity完全不碰width、height之类触发布局的属性。这一点对性能影响很大尤其连点的时候。4.3 script 部分script setup import { ref } from vue const ripples ref([]) let rippleId 0 function onClickBtn(e) { const rect e.currentTarget.getBoundingClientRect() const x e.clientX - rect.left const y e.clientY - rect.top const duration 0.6 Math.random() * 0.3 const id rippleId ripples.value.push({ id, x, y, duration }) setTimeout(() { ripples.value ripples.value.filter((r) r.id ! id) }, duration * 1000) } /script这里用setTimeout清理光圈数据简单直观。如果希望动画一结束就立刻从 DOM 移除可以在span上绑定animationendremoveRipple(ripple.id)。稳妥起见可以两种机制都用animationend优先清理setTimeout做兜底防止某些异常场景下事件没有触发导致节点堆积。之所以强调清理是因为如果用户连续点击一百次数组里会积压一百条不可见的光圈数据最终影响渲染性能。及时清理永远不会错。4.4 用 DevTools 验证动画参数Chrome 开发者工具里有一个 Animations 面板可以录制动画后在时间轴上查看每一帧的状态还能拖拽时间轴检查特定时间点的视觉效果。我調参时的顺序一般是先把scale的起点和终点固定下来也就是自定义动画的轨迹然后调整时长先粗调再细调最后调缓动曲线。如果是多个光圈叠加出现优先做防抖和清理避免节点数量无限膨胀。5. 实际项目中踩过的坑关键帧在 Vue 里容易犯的错5.1 组件 scoped 里同名的关键帧会互相覆盖我在第 2 节快速带过这个名字冲突的问题这里值得展开讲一下。Vue 的 scoped 样式是通过给选择器加>keyframes fadeDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }在编译之后它们仍然是同一个全局名字fadeDown。后加载的样式表会把先加载的覆盖掉结果就是两个组件动效实际表现不一致但代码看起来完全正常。我遇到过弹窗 A 的fadeDown时长 0.3s弹窗 B 也写了同名关键帧时长 0.6s最后两个弹窗全部变成 0.6s排查了很久才定位到是样式表加载顺序的问题。解决套路有两个。一是动画统一在全局文件里管理文件加前缀比如app-fade-down二是只在单组件内使用时把组件名或者前缀拼进关键帧名字里例如vui-modal-fade-down。5.2 animation 结束后元素状态没持久化动画默认播放完后会回到元素本身的样式。如果元素本身没有写结束态视觉上就是瞬移回原点。解决办法是给动画类名加上animation-fill-mode: forwards让动画保持在最后一帧。但要注意forwards会让最后一帧的样式持续“覆盖”元素后续的样式如果动画结束后还想通过普通 CSS 再改它的状态优先级会有些麻烦这时可以配合animation-fill-mode: both或者再手动切换一个清理类名。5.3 用 width、height、left、top 做位移动画掉帧明显关键帧里做位移动画要尽量只用transform和opacity。这两个属性不触发 layout可以直接走合成器线程由 GPU 加速处理。而left、top、width、height的变化会触发页面布局重排在大列表或低端机上掉帧会比较明显。拿涟漪组件来说光圈放大的动画如果用width: 80px改成width: 220px每一帧都要重新计算布局连点十几次就会出现肉眼可见的卡顿。改成transform: scale(2.8)之后同样效果下几乎不消耗布局时间。5.4 无限循环的光圈导致 CPU 占用过高infinite循环动画如果挂在大量元素上对 CPU 的消耗是持续性的不会因为页面没在播而自动省下来。所以需要控制几件事非必要场景减少iteration-count改成有限次数对常驻循环动画加上will-change: transform让浏览器提前为其分配合成层大批量列表里避免同时给几十个元素挂循环动画。还有一个实用技巧监听document.visibilitychange当页面切换到后台时把所有循环动画的animation-play-state设为paused切回前台再恢复。这个做法对后台管理系统这种长期驻留页面非常友好可以明显降低静态资源占用。5.5 动画类名与 Vue 的 transition 类名冲突同一个元素如果既挂了transition钩子类名又挂了手动动画类名两套类名同时生效时优先级很容易打架。我的习惯是进入与离开类名只负责“挂载时机”具体动画全写在关键帧里进入与离开如果要用不同动画就明确区分成两个关键帧名称分别放在.enter-active和.leave-active类上。不要图省事把同一个类名同时用在进入和离开上否则离开动画会继承进入态的fill-mode视觉上容易出现“闪回去”的怪象。写在最后给同样在学 Vue 动画的同学这篇笔记整理到这里我在重写涟漪组件时最大的体会是Vue 本身只是一个载体关键帧动画的能力边界其实非常宽。那些看似复杂的动效拆开来看基本都是“轨迹 时长 缓动曲线”的组合只要把这套组合关系想清楚实现起来并不难。如果让我给一条建议的话就是别急着碰复杂的第三方动画库先花一个晚上把animation的 8 个子属性过一遍再回到 Vue 里用类名和状态去操纵它们会比靠 JS 定时器模拟动画顺手得多。别忘了用 Chrome 的 Animations 面板看看自己写的动画每一帧到底发生了什么这个工具比任何调试日志都直观。
返回列表