做后台项目时给列表加过动画的人应该都有这种经历:明明加了<transition>,代码也没报错,删除一行时整个区域却是“啪”一下直接缩成空白;页面切换时更是直接闪一下,完全没有预期的丝滑。Vue3的动画体系表面上还是Transition和TransitionGroup这对老搭档,但使用细节跟Vue2时期已经有了不少偏移,网上很多教程还停留在贴代码的阶段,根本没有把过渡的class状态怎么流转讲清楚。这篇我把Vue3动画从基础过渡、列表动画到页面切换动画完整过一遍,重点放在为什么这样写、什么时候会翻车,以及真实项目里最容易出问题的细节。适合正在用Vue3做管理系统、移动端H5页面,想让交互更顺滑的开发者参考。
1. 先别急着写CSS:Transition的class状态与底层渲染时机
很多人的误区是:给元素套个<transition>,再写个.fade-enter-active搭配opacity,就以为动画自动会跑。实际上Vue的过渡系统有一套严格的“插帧”时序,理解了这个时序,你才能解释那些“为什么我写了动画却不生效”的疑难杂症。
1.1 六种class状态是如何被Vue“导演”出来的
以最常见的fade为例,一个简单的过渡组件长这样:
<transition name="fade"> <div v-if="show" class="box">内容</div> </transition>配套CSS:
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; }当show从false变成true,也就是元素需要进场时,Vue实际上做了这几件事:
- 把
<div class="box">插入到DOM中,同时给它加上fade-enter-from和fade-enter-active,此时元素是opacity: 0。 - 强制浏览器进行一次重绘,确保“透明”这一帧真的被渲染到了屏幕上。
- 在下一帧删除
fade-enter-from,加上fade-enter-to,于是opacity从0变成了1,CSS3的过渡被触发。 - 过渡结束后,清理掉
fade-enter-active和fade-enter-to,触发afterEnter回调。
离场时对称,只是改成加fade-leave-from、fade-leave-active,下一帧换fade-leave-to,动画结束再把整个DOM节点移除。
这里最关键的其实是第2步和第3步之间的“一帧间隔”。你可以把它类比成拍定格动画:想让一个小球从透明变成可见,你不能直接告诉浏览器“最终状态是可见”,浏览器会把中间过程全省略,直接画出终点。你必须先在屏幕上定格“透明”这一帧,再在下一帧告诉它“现在开始变”,动画才有播放的余地。很多人过渡不生效,就是因为在各种回调里同步地添加和删除class,两个class在同一个渲染帧内被合并了,浏览器直接跳到最终状态,自然一点动画都没有。
1.2 为什么我建议把位移和透明度放在优先级最高的位置
抛开那些花哨的三维特效,普通列表和页面切换的动画90%用transform和opacity就够了,这不是保守,而是性能考量。
transform里的位移、缩放和opacity,浏览器能交给GPU合成器直接处理,动画过程中不需要反复计算布局、重绘整个页面。但如果用top、left、width、height这类属性,浏览器每一帧都要重新走一遍layout + paint流程。举个例子,100个元素的列表同时改变left值,你会看到明显的卡顿;同样的动画换成transform: translateX(),基本能稳定60帧。
在Vue3里你只需要控制CSS属性,不需要额外命令,但选属性时要有意识地避开layout属性。我见过不少新手把拖拽排序写成了修改top/left的动画,一拖就卡,换成transform后立刻流畅。所以后面讲的列表动画、页面切换,全部默认基于transform和opacity,这应该是你写Vue动画的第一直觉。
2. 单个元素过渡实操:Transition组件的mode、时长与首次渲染问题
基础机制看明白后,我们进入真实的组件使用环节。单元素过渡是最常见的场景,但这里藏着两个高频翻车点:多元素切换时的重叠、以及CSS动画与过渡同时存在时的时长判断。
2.1 mode="out-in" 什么时候必须用,什么时候用错了反而更怪
假设你要做一个选项卡切换,两个面板用v-if控制,外面包了同一个<transition>:
<transition name="fade"> <component :is="currentTab" /> </transition>不写mode时,Vue让旧面板退出和新面板进入同时进行。如果两个面板高度差不多,可能感知不到问题;一旦高度不同,你会看到两个面板同时出现在页面上,容器高度瞬间被撑到最高,等旧面板消失后又突然缩回去,视觉上像“跳了一下”。
这时候用mode="out-in",意思是先让旧元素完整离开,再让新元素进场:
<transition name="fade" mode="out-in"> <component :is="currentTab" /> </transition>out-in是页面切换、面板切换的默认首选,因为它永远保证同一时刻只有一个主元素在屏幕上,不会出现布局跳动。反过来的in-out则相反,新元素先进场,旧元素再离开。这个模式适合什么场景呢?我举一个例子:消息列表顶部插入一条新提示,你希望新提示滑入时,旧内容被“挤下去”,这时候in-out的新元素先进场、占住位置,旧元素再退场,就会形成一种“新消息顶上来”的物理效果。理解了两种mode的差别,你就能按场景选择,而不是盲目套。
2.2 同时用CSS transition和animation时,时长以谁为准
这是比mode更隐蔽的一个坑。某次我接了个需求,元素进场时不仅要淡入,还要有一点弹跳。于是我在同一个class里写了:
.fade-enter-active { transition: opacity 0.3s ease; animation: bounce-in 0.8s; }结果动画到0.3秒时,元素突然“跳”到了最终位置,后面0.5秒的弹跳没了。原因很简单:Vue在监听过渡结束事件时,谁先结束就以谁为准。这里transition的opacity在0.3秒就触发了transitionend,Vue认为过渡已完成,立即清理了所有状态class,剩下的animation自然被中断。
解决办法有两种。如果我想让弹跳动画完整播放,就明确告诉Vue“你给我按animation的时长来判断”:
<transition name="fade" type="animation">反过来,如果动画只是辅助、想要按transition结束,就写type="transition"。还有一种更硬核的方式,直接指定总时长:
<transition name="fade" :duration="{ enter: 500, leave: 200 }">:duration可以显式声明进场的500毫秒和离场的200毫秒,只要写了这个,Vue就不再依赖事件检测。这在结合第三方动画库时特别常用,因为第三方库经常自己控制结束时机,事件检测容易错乱。
2.3 appear:让第一次渲染也有过渡
还有一个很常见但容易忽略的需求:页面加载时就存在的弹窗,或者首屏展示的提示条,刚打开页面时直接“啪”一下出现了,没有任何动画。原因很简单,v-if初始就是true,元素是伴随页面一起渲染出来的,没有经历“从false变true”的过程,过渡自然不触发。
这时给<transition>加上appear属性即可:
<transition name="fade" appear> <div v-if="visible" class="toast">提示内容</div> </transition>appear会启用专门的三阶段class:fade-appear-from、fade-appear-active、fade-appear-to。要注意的是,如果你没有单独定义appear的class,Vue会回退使用v-enter-from这一套,所以多数时候只写fade那套CSS也能生效。但如果你想让首屏出现的动画跟后续出现的动画不一样(比如首屏用下拉,后续用淡入),就需要单独定义appear状态。
3. 列表动画硬骨头:TransitionGroup的正确打开方式与常见翻车现场
单元素过渡学会后,真正的难点在列表。列表动画之所以麻烦,是因为它有两个需求叠加:一是增删元素时的进场/离场,二是其余元素为了让新元素腾出空间而产生的“移动”。只给<li>写一个淡入淡出,根本不够看。
3.1 TransitionGroup和Transition的本质区别
Transition一次只处理一个元素/组件,而TransitionGroup处理的是一组子节点。它不会渲染一个独立的DOM容器,而是通过tag属性决定“我外包一层什么标签”。比如我要让一组li有动画,可以这样:
<transition-group name="list" tag="ul"> <li v-for="item in items" :key="item.id"> {{ item.name }} </li> </transition-group>这里有几个容易踩的坑:
- 子节点必须用唯一的
key,而且不能用index。如果以index作为key,删除第一项后,原来第二项会被复用成第一项,Vue会认为“第一个元素被删除”而不是“第二个元素被删除”,动画会错位。我见过最极端的情况是:删除一行后,视觉上消失的是另一行。这就是key用index的下场。 - 不传
tag在Vue3里会渲染为一个“片段”,不产生额外DOM。想保持语义或样式,最好显式指定tag。如果要对<tr>做动画,可以tag="tbody",每组<tr>作为子节点。 v-show控制显隐不会触发列表动画。列表动画依赖的是节点的插入和移除,也就是v-if配合动态数组增删,不是单纯改display。
3.2 move过渡:列表排序时最顺滑的一招
我们处理列表时经常做筛选或排序:用户点了“按价格排序”,其余项的位置全部变化,这时如果只有enter/leave动画,位移是瞬间发生的,整个列表像“被重新渲染”了一样。move过渡就是为此设计的。
在<transition-group name="list">中,只要给.list-move写一个transform过渡,Vue会利用FLIP技术自动完成位移动画:
.list-move { transition: transform 0.5s ease; } .list-enter-from { opacity: 0; transform: translateY(-20px); } .list-leave-to { opacity: 0; transform: translateY(20px); }原理大致是:Vue记录每个子元素上一次的位置,下一次渲染后计算新位置,再把差值封装成transform加到元素上,最后通过CSS过渡把transform清掉。这样元素看起来不是瞬移,而是平滑地滑到新位置。整个过程你不需要写任何JS。
这里有两个实际项目中的注意事项:
- 列表项之间的间距,我建议用
padding或者flex布局的gap,尽量别用margin。FLIP计算的是元素自身的边界,margin产生的间隙在位移过程中容易出现“忽宽忽窄”的抖动,特别是在批量插入多条数据时。 - move过渡的时长最好和enter/leave时长保持一致,否则会出现“前一个刚淡入完,后面的人才开始挪窝”的延迟感,观感上像整个列表卡了一下。
3.3 一个完整的增删排序列表案例
把前面讲的串起来,下面是一个可直接跑的列表组件:
<template> <div class="list-wrap"> <button @click="addItem">添加</button> <button @click="shuffle">随机排序</button> <transition-group name="list" tag="ul" class="list"> <li v-for="item in items" :key="item.id" class="list-item"> <span>{{ item.name }}</span> <button @click="removeItem(item.id)">删除</button> </li> </transition-group> </div> </template> <script setup> import { ref } from 'vue' let id = 4 const items = ref([ { id: 1, name: '苹果' }, { id: 2, name: '香蕉' }, { id: 3, name: '橙子' } ]) function addItem() { items.value.push({ id: id++, name: '新成员' + id }) } function removeItem(id) { items.value = items.value.filter((item) => item.id !== id) } function shuffle() { items.value = items.value.sort(() => Math.random() - 0.5) } </script> <style scoped> .list { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 8px; } .list-item { display: flex; justify-content: space-between; padding: 10px 12px; background: #f5f6f8; border-radius: 6px; } .list-move { transition: transform 0.4s ease; } .list-enter-from { opacity: 0; transform: translateY(-16px); } .list-enter-active { transition: opacity 0.4s ease, transform 0.4s ease; } .list-leave-to { opacity: 0; transform: scale(0.9); } .list-leave-active { transition: opacity 0.3s ease, transform 0.3s ease; } </style>注意,删除时的list-leave-active里我没有写position: absolute。如果你希望删除一个元素时,其余元素同时补位,就要让离场元素脱离文档流:
.list-leave-active { position: absolute; width: 100%; transition: opacity 0.3s ease, transform 0.3s ease; }加了position: absolute后,被删除的元素一边消失,其余元素一边用list-move补上来,视觉上会平滑很多。不加的话,元素还没离开完,后面的元素就已经占位了,一眼看上去像“跳过去”。这个细节我觉得是做得精致还是粗糙的分水岭。
4. 页面切换动画:从router-view到路由meta与懒加载的组合问题
列表动画搞定后,页面切换动画是很多人急切想上手的。但基于Vue Router 4的过渡写法跟Vue Router 3时代已经不一样了,这也是我在技术社区里看到最多提问的地方。
4.1 vue-router4下的标准写法与key的关键作用
如果你还在用transition直接包<router-view>,在Vue Router 4里会收到警告,因为router-view直接作为子元素时,Vue无法获取到当前路由组件的动态VNode。标准写法是利用其作用域插槽:
<router-view v-slot="{ Component }"> <transition name="page" mode="out-in"> <component :is="Component" :key="$route.fullPath" /> </transition> </router-view>这个写法里最重要也最容易遗漏的是:key="$route.fullPath"。为什么一定加key?因为如果不加,当你在同一路由下切换query参数时,Vue会复用同一个组件实例,只更新数据,不触发销毁和重建,也就没有过渡。加上key后,不同路径强制对应不同组件实例,切换时才会走完整的leave/enter链路。
选择fullPath还是path要按场景来。如果你希望同一个页面的query变化也触发动画,就用fullPath;如果只希望路径变化时动,query变了不重新进场,就用path。
4.2 通过路由meta配置不同方向的过渡动画
一个后台系统通常有左侧菜单和详情页。我们希望:从列表进入详情的过渡方向是“左滑进来”,从详情返回列表时是“右滑回来”。如果全项目只用一个page动画,返回时也左滑,会很别扭。
具体做法分两步。第一步,在路由配置里给每个页面打上 meta 标记:
const routes = [ { path: '/list', component: () => import('@/views/List.vue'), meta: { depth: 1, transition: 'slide-right' } }, { path: '/detail/:id', component: () => import('@/views/Detail.vue'), meta: { depth: 2 } } ]第二步,在App.vue里计算当前应该用哪个transition名:
<script setup> import { computed } from 'vue' import { useRoute } from 'vue-router' const route = useRoute() const transitionName = computed(() => { return route.meta.transition || (route.meta.depth > 1 ? 'slide-left' : 'slide-right') }) </script> <template> <router-view v-slot="{ Component }"> <transition :name="transitionName" mode="out-in"> <component :is="Component" :key="$route.fullPath" /> </transition> </router-view> </template>我的习惯是给不同方向单独定义一套CSS:
.slide-left-enter-active, .slide-left-leave-active, .slide-right-enter-active, .slide-right-leave-active { transition: transform 0.3s ease, opacity 0.3s ease; } .slide-left-enter-from { transform: translateX(60px); opacity: 0; } .slide-right-enter-from { transform: translateX(-60px); opacity: 0; } .slide-left-leave-to, .slide-right-leave-to { opacity: 0; }这里我只给enter设置了位移,leave只做淡出,避免同时位移导致页面在过渡时出现视觉上的“来回瞎晃”。“进出位移方向不同”这种设计能强化导航层级感,用户一眼能看出自己是进入还是返回。
4.3 懒加载路由 + out-in + keep-alive 的三方纠纷
到了真实的系统里,页面组件大概率是懒加载的:component: () => import('@/views/Home.vue')。这时我在项目里遇到过很尴尬的现象:从A页切到B页,A页的leave动画很快结束,但B页的组件还没加载完,浏览器里出现了一段白屏,等加载好了,B页才带着enter动画进场。整个动画过程像“卡住”一样,动效断成两截。
有几个组合层面的思路可以处理:
- 如果目标组件体积不大,可以在点击跳转时提前触发动态 import,让它加载;但网速慢时依然会有延迟。
- 在切换期间呈现一个简单的全局loading,或让路由组件自身带骨架屏。
- 更稳妥的组合是“过渡、keep-alive、router-view”一起用。先进入过的页面会被缓存,再次返回时组件已经处于激活状态,不再有加载白屏问题。写法是把
keep-alive放在transition内部:
<router-view v-slot="{ Component }"> <transition name="page" mode="out-in"> <keep-alive :include="['List', 'Detail']"> <component :is="Component" :key="$route.path" /> </keep-alive> </transition> </router-view>这里有两个坑。第一个是include匹配的是组件内部的name,如果你用的是<script setup>语法,组件没有显式name,默认匹配不到。需要在<script setup>里额外用defineOptions({ name: 'List' })声明。第二个是缓存后,某些页面可能不想被缓存(比如表单页每次都要重新拉数据),所以include列表要按业务动态调整,不要图省事把全部页面塞进去。
5. 更进一步的工程化:JS钩子、状态动画、性能与可访问性
前面的内容足够覆盖常用场景了,但如果你想在真实项目里做得更专业,还得掌握JS钩子、状态动画,以及动画对弱光用户和低性能设备的影响。这些是很多教程不会讲的。
5.1 JS钩子函数与第三方动画库的配合
有时候CSS过渡搞不定复杂动效,比如要做一个“卡片从中心放大、带惯性回弹”的动画,我会直接用第三方动画库(比如GSAP)。Vue的<transition>提供了完整的JS钩子:
<transition @before-enter="beforeEnter" @enter="enter" @leave="leave" :css="false" > <div v-if="show" class="card"></div> </transition>function beforeEnter(el) { el.style.opacity = 0 el.style.transform = 'scale(0.8)' } function enter(el, done) { el.style.transition = 'transform 0.4s ease, opacity 0.4s ease' el.style.opacity = 1 el.style.transform = 'scale(1)' el.addEventListener('transitionend', done) } function leave(el, done) { el.style.transition = 'transform 0.3s ease, opacity 0.3s ease' el.style.opacity = 0 el.style.transform = 'scale(0.8)' el.addEventListener('transitionend', done) }这里有两个必然遇到的坑:
:css="false"必须声明。如果不声明,Vue会认为你还在用CSS过渡,它会自己去监听 transitionend。而你的JS钩子也在监听,两个监听一起抢,done会被重复调用,动效结束时机彻底乱掉。- enter和leave钩子的
done回调必须被调用。Vue把过渡完成判断完全交给你了,你不调用done,组件就永远认为动画没结束,后续的after-leave不触发,DOM节点可能一直留在页面上。我踩过一次:leave里写了个异步请求,请求没成功就忘了调done,结果整个页面切换卡死无响应。
如果用GSAP,代码会简洁很多:
import gsap from 'gsap' function enter(el, done) { gsap.fromTo(el, { opacity: 0, scale: 0.8 }, { opacity: 1, scale: 1, duration: 0.4, onComplete: done }) }回调里的onComplete直接指向done,GSAP播完即通知Vue,没有手动监听的烦恼。
5.2 数字滚动、数值变化这类“看不见”的动画
动画不只发生在DOM增删上。仪表盘里的数值从0滚到9,999,价格变动时的数字跳动,其实也属于动画范畴。Vue3没有内置这种“状态动画”,需要自己封装。我的项目里常备一个useAnimatedNumber:
import { ref, watch, onUnmounted } from 'vue' export function useAnimatedNumber(target) { const current = ref(0) let rafId = 0 const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3) function animate(from, to) { cancelAnimationFrame(rafId) const start = performance.now() const duration = 600 const step = (now) => { const progress = Math.min((now - start) / duration, 1) current.value = from + (to - from) * easeOutCubic(progress) if (progress < 1) { rafId = requestAnimationFrame(step) } } rafId = requestAnimationFrame(step) } watch(target, (val, old) => { animate(old || 0, val) }, { immediate: true }) onUnmounted(() => cancelAnimationFrame(rafId)) return current }使用的时候:
<script setup> import { ref } from 'vue' import { useAnimatedNumber } from '@/hooks/useAnimatedNumber' const target = ref(10000) const currentValue = useAnimatedNumber(target) </script> <template> <div>当前金额:{{ Math.round(currentValue).toLocaleString() }}</div> </template>为什么用performance.now()而不是Date.now()?因为requestAnimationFrame的回调参数自带一个高精度时间戳,用它计算帧之间差值更准确,也避免Date.now()受系统时钟调整影响。缓动函数选easeOutCubic是因为数值动画“先快后慢”最符合人的视觉习惯,看起来像是滚轮在惯性减速。
5.3 少折磨一部分用户:动画性能与系统级减少动画
最后聊一个很多人不屑但实际很重要的点:动画会让一部分用户生理性不适,也会拖垮老旧设备。前端的专业度不仅体现在“做得出来动画”,还体现在“知道什么时候该砍掉动画”。
性能层面,我在团队里定的规矩是:
- 动画只对
transform和opacity动刀,禁止用top/left做位移。 - 可以用
will-change: transform, opacity提前告诉浏览器要做一个合成层动画,但要小心:这个属性本身会占用GPU内存,元素一多就是“内存刺客”。我的习惯是只给正在动画的元素临时加,或者干脆不加。现在绝大多数现代浏览器对transform和opacity的优化已经足够好,不加will-change也没大碍。 - 移动端列表里,一次性让上百个DOM节点同时参与动画,再流畅的机器也会掉帧。这种时候要么做虚拟列表,要么用分页/懒渲染减少节点数量。
可访问性层面,系统偏好prefers-reduced-motion是必须处理的。很多用户在生产环境里开启了“减少动态效果”,我们不应该强行塞给他动画。常见做法是写一段全局CSS:
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } }这段样式会把所有过渡和动画压到近似瞬移,相当于一键关闭动效。权重用!important是为了覆盖组件库内部的动画,虽然粗暴,但在这个场景下是可控的。
就我个人经验来说,动画是为交互服务的,不是用来炫技的。团队项目里与其每做一个页面都去复制粘贴fade/slide的CSS,不如抽一个公共的过渡配置,把常见的name和对应class集中维护,页面里只负责绑定name和动态切换条件。这样“加动画”变成了一件低成本的配置工作,也更容易保证全站体验一致。如果你在项目里发现动画始终做不“丝滑”,先不用怀疑原生能力,多检查一下是不是在布局属性上硬刚——换到transform那边,很多问题会自己消失。