news 2026/10/6 9:56:22

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 }" @click="triggerBounce" > 弹一下 </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 name="modal-fade"> <div v-if="visible" class="overlay"> <!-- 弹窗内容 --> </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 :class="statusAnimation" class="status-icon"></div> </template>

动画名本身对应的是不同幅度、不同时长、不同运动轨迹的关键帧。把动画名和业务状态直接绑定,代码读起来非常直观:看到connecting就自动联想到blink-fast,不需要在模板里堆一大堆v-if。

这个方案唯一的代价是,关键帧需要写得稍微细一点。比如breath-slow和blink-fast虽然都是透明度变化,但时长和透明度范围不同,建议把每个形态单独命名,而不是复用同一个fadeIn。

3.2 用 key 强制重播

在真实开发里,最高频的需求其实是“点击一下动一次”。我第一次做的时候用了一个套出错的写法:按钮点击后只改变isAnimating布尔值,第二次点击时虽然布尔值从false变回true,但动画类名并没有变化,浏览器发现计算后的样式没变,直接就不播了。

解决办法很简单:加一个每次递增的key,强制元素重挂载。重挂载之后浏览器会把它当成新元素,动画必然重新执行。

<template> <span :key="waveCount" class="wave-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 class="ripple-circle" @animationend="removeRipple(ripple.id)" ></span>

有一点需要特别提醒:动画事件是冒泡的,如果父容器也监听了animationend,子元素动画结束时事件会冒泡到父容器,可能触发父级的回调。在事件处理函数里要判断event.target !== event.currentTarget,否则会出现一个涟漪移除时把父级节点里的其他涟漪一并清理掉的情况。

4. 完整案例:做一个涟漪光圈扩散组件

4.1 需求拆解

涟漪光圈扩散这个效果,在很多 C 端按钮、地图打点、直播点赞气泡上都能看到。点击按钮时,一个圆环从点击位置向外扩散,同时透明度逐渐淡到 0。用户连续点击时,多个光圈可以叠加出现。

把需求拆开看,其实只有三步:记录每次点击的光圈数据、按数据渲染光圈、动画结束后移除光圈。

每条光圈需要记录四个信息:唯一 id、触发位置 x/y、动画时长。位置用来定位光圈的起点,时长用来控制速度,id 用来作为v-for的 key。

4.2 模板与样式实现

<template> <div class="ripple-btn" @click="onClickBtn"> 点击我 <span v-for="ripple in ripples" :key="ripple.id" class="ripple-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上绑定@animationend="removeRipple(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 面板看看自己写的动画每一帧到底发生了什么,这个工具比任何调试日志都直观。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 9:54:33

灰狼优化算法改进:多策略融合解决收敛慢与早熟问题

1. 灰狼算法没你想的那么简单&#xff0c;也没那么难 1.1 从狼群捕猎到数学寻优&#xff1a;灰狼优化算法的核心逻辑 灰狼优化算法&#xff08;Grey Wolf Optimizer&#xff0c;GWO&#xff09;是2014年由Mirjalili等人提出的一类群体智能优化算法。它模拟灰狼种群在捕猎过程中…

作者头像 李华
网站建设 2026/10/6 9:53:13

C++双指针实现字符串原地反转:原理、写法与踩坑指南

后台经常有人跑来问我&#xff1a;双指针反转字符串这题到底该怎么写&#xff1f;说实话&#xff0c;第一次看到这道题&#xff0c;我也觉得简单到有点“无聊”&#xff0c;一个 for 循环倒着拷贝不就行了。但你真去面一次试或者认真刷一遍题就知道&#xff0c;这题考的根本不…

作者头像 李华
网站建设 2026/10/6 9:52:29

10款免费降AI率工具横评:原理、实测与避坑指南

“你这稿子我用检测器看了&#xff0c;AI疑似率86%&#xff0c;改改吧。”做公众号的编辑朋友大半夜给我发来这句话时&#xff0c;我正打算关电脑睡觉。这两年做内容的人都懂“AI率”这个概念有多让人头疼&#xff0c;写东西用AI辅助吧&#xff0c;检测工具一查就是一片红&…

作者头像 李华
网站建设 2026/10/6 9:52:21

2026前端面试十万字笔记:JS核心、框架原理与工程化实战

去年开始&#xff0c;我陆陆续续把前端面试重点整理成一份笔记&#xff0c;最后成了十万字的合集。起初只是面试前救急&#xff0c;后来发现这东西越写越厚&#xff0c;因为前端面试的范围早就不是“会不会写页面”这么简单了。这份笔记不只是背题&#xff0c;它实际上是一张前…

作者头像 李华
网站建设 2026/10/6 9:51:30

Agent-Reach 实战:用 CLI 把 AI Agent 拉进终端干活

1. 从零认识 Agent-Reach&#xff1a;一个把 AI Agent 拉进终端的 CLI 工具第一次看到 Agent-Reach 这个名字&#xff0c;我下意识把它和市面上那些"套壳聊天框"归到了一类。直到我把它的定位、关键词和周边生态串起来看&#xff0c;才发现它真正想解决的是一个很具体…

作者头像 李华
网站建设 2026/10/6 9:51:27

Spring FactoryBean深度解析:反直觉原理、源码与实战避坑指南

1. 反直觉的第一课&#xff1a;为什么getBean拿到的不是FactoryBean先讲一个让我印象深刻的场景。刚接触Spring源码那阵子&#xff0c;我在项目里定义了一个类&#xff0c;实现了FactoryBean接口&#xff0c;想着"这就是一个工厂Bean嘛&#xff0c;注册进去之后&#xff0…

作者头像 李华