1. 项目概述:让页面切换告别生硬跳变
做管理后台也好,做移动端H5也好,做产品展示站也好,页面之间的切换总是一个绕不开的点。默认的路由切换就是一个div瞬间替换成另一个div,没有过渡,没有层次,视觉上就是“啪”地一下变过去了。用户感知不到页面的来龙去脉,操作起来总觉得生硬。尤其在做后台管理系统或者官网展示页的时候,同样的功能,加了动画和没加动画,体验差距非常明显。
其实 Vue3 从 3.0 开始内置的 Transition 组件就能解决这个问题,而且它并不是一个多复杂的API。它负责的是一件事:当元素或组件插入、更新、移除时,给它加上进入和离开的动画。放到页面切换这个场景里,就是让旧页面先离场、新页面再进场,两个过程叠加起来形成平滑的过渡效果。
这个项目里我要做的就是基于 Vue3 的 Transition 组件,结合 Vue Router 完成一套路由页面切换的动画方案,同时把 Transition 本身的核心机制、类名规则、JS钩子、批量复用方式都拆开讲透。适合正在做 Vue3 后台管理系统、需要优化页面切换体验的前端开发者,也适合准备 Vue3 面试时想把这个点讲透彻的读者。跟着这一篇走完,你会理解 Transition 的完整运行机制,而不是停留在“会用<transition>包一下”的层面上。
2. 核心机制拆解:Transition 组件到底在干什么
2.1 六个过渡类名才是真正的核心
网上随便搜 Vue3 Transition,出来的例子都是套模板式的:
<template> <transition name="fade"> <p v-if="show">hello</p> </transition> </template>然后 CSS 里写.fade-enter-active、.fade-leave-active。能跑,但很多人没搞清楚这些类名是什么时候加上的、什么时候移除的、六个类名分别控制什么。
Vue3 的过渡体系实际上有六个类名,分为两组,一组管进场,一组管离场。
进场组:
v-enter-from:元素插入前的初始状态,比如opacity: 0、transform: translateX(20px)v-enter-active:进场过程中一直生效的类,一般放transition过渡属性v-enter-to:进场的结束状态,比如opacity: 1、transform: none
离场组:
v-leave-from:离场起始状态,通常是元素当前的样子v-leave-active:离场过程中一直生效的类,放transition过渡属性v-leave-to:离场结束状态,比如opacity: 0、transform: translateX(-20px)
这里面的v是默认前缀,当你给<transition>加了name="fade"之后,前缀就会变成fade-enter-from、fade-enter-active这样。
关键点在于:enter-active和enter-to这两个类是在同一帧被加进去的。Vue 会在元素插入后强制触发一次浏览器重绘,再添加enter-to,这样才能让 CSS 过渡从enter-from状态变化到enter-to状态时被浏览器捕获。如果你自己手写逻辑把这两个类同时加上,过渡不会触发,因为浏览器没检测到样式变化。
同理,leave-from和leave-active也是同时存在的。这六个类名背后的运行顺序是面试里高频考的点,也是自己排查动画不生效问题时的关键线索。
2.2 过渡时机的完整时间线
我画了一条时间线在自己脑子里,每次排查动画问题都靠它:
进场过程:
- 条件变 true,元素即将被插入
- Vue 给元素添加
v-enter-from和v-enter-active - 元素被插入 DOM
- 强制 reflow(浏览器重绘)
- 移除
v-enter-from,添加v-enter-to - CSS 过渡开始执行,从初始状态过渡到结束状态
- 过渡结束后,移除
v-enter-active和v-enter-to
离场过程:
- 条件变 false,元素即将被移除
- Vue 添加
v-leave-from和v-leave-active - 强制 reflow
- 移除
v-leave-from,添加v-leave-to - CSS 过渡开始执行
- 过渡结束后移除元素及所有相关类名
这个时间线是一切的根基。任何过渡动画不生效,八成是在这个流程的某个环节出了问题。比如你给enter-active里写了transition: all 0.3s,但enter-to里没有写任何具体样式,那就没变化可过渡。又比如你忘记在enter-active写transition属性,那enter-from到enter-to的变化就是瞬时跳变,毫无动画可言。
2.3 生命周期钩子的对应关系
Transition 组件还暴露了 JavaScript 钩子,在动画的各个阶段被调用:
before-enter:元素插入前调用,对应enter-from类添加前enter:元素插入后调用,对应enter-active阶段after-enter:进场动画结束后调用enter-cancelled:进场动画被中断时调用before-leave:离场前调用leave:离场开始调用after-leave:离场动画结束后调用leave-cancelled:离场被中断时调用
用 JS 钩子时,enter和leave钩子函数里有两个参数:el和done。done是必须调用的回调,告诉 Vue 动画已经结束。如果你不调用done,Vue 会认为动画还在进行中,元素永远不会被移除。
这里我要特别提醒一个坑:如果你在enter钩子里用了第三方动画库,比如 GSAP,动画完成回调里要调用done。如果你忘了,下一次切换时页面可能会卡住,因为过渡永远不会结束。
const enter = (el, done) => { gsap.from(el, { opacity: 0, duration: 0.5, onComplete: done }) }如果enter和leave钩子里不需要动画,直接用done()就行了。
3. 基础实践:从一个按钮切换开始
3.1 完整的过渡类名代码示例
在很多业务项目里,我们不会只用一个过渡效果,所以给<transition>取个语义化的名字很重要。常见的是fade、slide-fade、zoom这种按效果命名的方式,每套效果对应一组 CSS 类。
先来一个最基础的淡入淡出:
<template> <div> <el-button @click="show = !show">切换</el-button> <transition name="fade"> <div v-if="show" class="box">hello transition</div> </transition> </div> </template> <script setup> import { ref } from 'vue' const show = ref(true) </script> <style scoped> .fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; } </style>这段代码里我没有写enter-to和leave-from,因为默认状态就是opacity: 1,不写也能自动归位。这就是 Transition 的一个设计友好点——结束状态如果等于元素的默认状态,可以不写。
但如果你想做位移类的动画,结束状态就和默认状态不一样了,必须显式写出来:
<style scoped> .slide-fade-enter-active, .slide-fade-leave-active { transition: all 0.3s ease; } .slide-fade-enter-from { opacity: 0; transform: translateX(20px); } .slide-fade-leave-to { opacity: 0; transform: translateX(-20px); } </style>这套动画的效果是:新元素从右侧 20px 的位置边移入边显现,旧元素向左移出并变透明。两个方向的移动形成一种“推入推出”的感觉,在列表页跳详情页的场景里特别合适。
3.2 初始渲染动画 apper
默认情况下,Transition 只在元素插入或移除时触发动画,页面首次加载时如果元素本来就该显示,它是没有动画的。要让首次加载也有动画,需要加appear属性:
<transition name="fade" appear> <div v-if="show">页面加载时也会有渐入效果</div> </transition>加了appear后,Vue 在初始渲染时会额外走一遍进场动画的流程。这在你做首屏引导、欢迎弹层时很常用。注意appear也可以配合appear-active-class这类属性单独指定样式,不过实际项目中很少用到,知道即可。
3.3 配合 v-show 的取值注意
前面用的是v-if,条件为 false 时元素直接从 DOM 里移除。如果用v-show,元素一直在 DOM 里,只是display: none来回切换。Transition 对这两种模式都兼容,动画效果也都能正常触发。
区别在于:v-if模式下,元素从 DOM 中彻底移除,离场结束后 DOM 里找不到它;v-show模式下,元素还在,只是被隐藏。如果整个页面切换都是固定结构,用v-show性能更好;如果涉及动态组件、路由组件这种需要重新渲染的场景,用v-if才合理。
页面切换的核心场景下,我们用的自然是v-if或路由的组件渲染机制,因为每次切过去都是新页面实例。
4. 实战主角:路由页面切换动画
4.1 用 mode 控制进出场顺序
如果直接把所有页面切换都包一层 Transition,你马上会遇到一个问题:两个页面的进场动画和离场动画同时发生,视觉上会乱,新页面直接叠在旧页面上面,透明度叠加、位移叠加,画面非常脏。
这时就需要mode属性。mode="out-in"表示先执行完离场动画,再把新元素插进来执行进场动画。mode="in-out"则相反,先插新元素、执行完进场,再移旧元素。页面切换场景基本都用out-in,它能保证同一时间只有一个页面在动,视觉上更干净。
<transition name="page" mode="out-in"> <component :is="currentComponent" :key="currentPath" /> </transition>给组件绑定动态key是必须的。同一个<component>标签,如果 key 不变化,Vue 会认为组件没有变更,Transition 不会触发。
这里有个容易踩的坑,我之前在一个项目里折腾了很久:用了 mode 之后动画却失效了。排查后发现是给<transition>和内部的组件都加了v-if条件,条件在切换时给transition本身也挂载卸了,Vue 失去了追踪目标,动画自然不触发。Transition 这个标签必须始终存在,只能改变它内部内容的 key。
4.2 结合 Vue Router 的完整方案
在 Vue3 项目里结合 Router 的写法是这样的:
<template> <router-view v-slot="{ Component }"> <transition name="fade-slide" mode="out-in"> <component :is="Component" :key="$route.path" /> </transition> </router-view> </template>router-view的v-slot是 Vue Router 4 提供的能力,从插槽里解构出Component,再用动态组件的方式渲染。这么写有几个好处:
- 能拿路由组件实例,给动态组件传 props
key绑$route.path,每次跳转换 key,强制 Vue 把旧组件实例换掉- Transition 能正确感知组件的切换,执行离场和进场
只写<router-view />再在外面套<transition>是不行的。因为router-view内部渲染的组件变化时,Vue 无法确定是“同一个位置组件更新”还是“完全不同的组件替换”,动画自然无法稳定触发。
4.3 路由层级不同用不同动画
在真实项目里,层级不同的页面切换应该用不同方向的动画。比如从列表页进入详情页,详情页应该从右侧滑入,列表页向左滑出;返回时反过来。视觉效果要给用户一个空间方向的感知,这是移动端交互设计里很基础的原则,Web 端同样适用。
要实现这个效果,通常的做法在路由meta里标记层级,然后在动画入口处根据层级判断切换方向。
路由配置:
{ path: '/list', name: 'List', component: () => import('@/views/List.vue'), meta: { depth: 1 } }, { path: '/list/detail', name: 'Detail', component: () => import('@/views/Detail.vue'), meta: { depth: 2 } }模板里:
<template> <router-view v-slot="{ Component, route }"> <transition :name="transitionName" mode="out-in"> <component :is="Component" :key="route.path" /> </transition> </router-view> </template> <script setup> import { ref, watch } from 'vue' import { useRouter } from 'vue-router' const router = useRouter() const transitionName = ref('slide-left') watch( () => router.currentRoute.value, (to, from) => { const toDepth = to.meta.depth ?? 0 const fromDepth = from.meta.depth ?? 0 transitionName.value = toDepth > fromDepth ? 'slide-left' : 'slide-right' } ) </script>这两个方向对应的动画类:
.slide-left-enter-active, .slide-left-leave-active, .slide-right-enter-active, .slide-right-leave-active { transition: all 0.3s ease; } .slide-left-enter-from { opacity: 0; transform: translateX(40px); } .slide-left-leave-to { opacity: 0; transform: translateX(-40px); } .slide-right-enter-from { opacity: 0; transform: translateX(-40px); } .slide-right-leave-to { opacity: 0; transform: translateX(40px); }推进时新页面从右边来,旧页面往左走;返回时新页面从左边进,旧页面往右退。方向符合直觉,用户能感知到自己是在“前进”还是“后退”。
4.4 mode 失效的一个隐藏原因
用out-in模式时还会遇到另一个隐藏问题。当多个路由组件在切换,如果组件之间传递了异步数据,比如onMounted里发请求,请求返回前组件渲染的是骨架屏或者空白,此时进场动画已经执行完了,用户会看到先是空白页面恍了一下,然后数据突然出现。
这个问题动画本身解决不了,得反过来在设计上考虑。我常用的方案是给路由组件加一个最小渲染延迟,或者让数据加载状态也参与动画表现,比如整个页面做一个渐变透明容器,数据到位后再渲染内部内容。不过这会增加复杂度,如果项目不追求极致体验,保持动画简单即可。
提示:路由页面切换动画的 key 必须跟路由路径绑定,不要拿组件名当 key。同一个组件被多个不同路由复用时,组件名一样但路径不同,因为 key 不变,Vue 会复用实例,动画就不会触发。
5. 进阶玩法:复用、JS钩子与列表过渡
5.1 把过渡封装成可复用组件
项目里多个页面都要用同一套过渡效果时,每个页面里复制 CSS 会很痛苦。更好的做法是封装一个通用组件。
我自己的封装思路是这样的:
<!-- PageTransition.vue --> <template> <transition :name="name" :mode="mode" @before-enter="onBeforeEnter" @enter="onEnter" @after-enter="onAfterEnter" @before-leave="onBeforeLeave" @leave="onLeave" @after-leave="onAfterLeave" > <slot /> </transition> </template> <script setup> const props = defineProps({ name: { type: String, default: 'fade' }, mode: { type: String, default: 'out-in' } }) // 需要 JS 控制时再加逻辑,不需要就不暴露 const emit = defineEmits([ 'before-enter', 'enter', 'after-enter', 'before-leave', 'leave', 'after-leave' ]) // 传递事件给父组件,或者内部统一处理 </script>父组件里这样用:
<PageTransition name="slide-left" mode="out-in"> <router-view v-slot="{ Component, route }"> <component :is="Component" :key="route.path" /> </router-view> </PageTransition>这样全局只维护一套过渡组件,页面要换动画风格时只改这个文件,后端管理系统非常受益。
不过要注意,slot里的内容必须只有一个根元素,或者说 Transition 只对直接子节点生效。如果插槽传了多个元素进来,Vue 会告警。实际写的时候,通常把 router-view 或者单个组件放在里面就行。
5.2 JS 钩子实现复杂动画控制
有些动画 CSS 不好写,比如分步骤动画,或者需要根据滚动位置动态计算位移动画。这时候就得用 JS 钩子。
一个常见的场景是进入页面时,先淡入再上浮,而且要控制每一步的时间和延迟:
<transition @before-enter="onBeforeEnter" @enter="onEnter" @after-enter="onAfterEnter" @leave="onLeave" > <component :is="Component" :key="route.path" /> </transition>const onBeforeEnter = (el) => { el.style.opacity = '0' el.style.transform = 'translateY(40px)' } const onEnter = (el, done) => { // 分两步动画 el.style.transition = 'opacity 0.3s ease' el.style.opacity = '1' setTimeout(() => { el.style.transition = 'transform 0.4s ease' el.style.transform = 'translateY(0)' done() }, 300) // 注意:如果在 setTimeout 里调用 done, // 需要保证完成回调不会被重复调用 } const onAfterEnter = (el) => { el.style.transition = '' el.style.opacity = '' el.style.transform = '' } const onLeave = (el, done) => { el.style.transition = 'all 0.3s ease' el.style.opacity = '0' el.style.transform = 'translateY(20px)' setTimeout(done, 300) }需要注意,JS 驱动的动画里,done必须在合适时机调用,而且只能调用一次。如果动画里有异步步骤,记得用一个标志位防止重复回调。我在项目里踩过这个坑:因为某个页面的切换动画里既有过渡还有请求,请求失败时停了动画,done没被调用,整个路由直接卡成白屏。
避免方案:给done加个超时保护。
const enter = (el, done) => { let called = false const finish = () => { if (!called) { called = true done() } } // 动画逻辑,完成后调用 finish // 兜底 1s 后强制结束 setTimeout(finish, 1000) }这样就算动画逻辑里有异常,也不会卡死路由跳转。
5.3 列表页多个元素间的过渡
页面切换动画不只针对整个页面,列表内部元素的增删也可以加过渡,这个叫TransitionGroup。虽然标题讲的是页面切换,但在列表页里,操作列、筛选项、分组行这些局部元素的增删过渡效果,对整体体验影响同样不小。
TransitionGroup和Transition的区别在于,它渲染一个真实容器(默认是div,可通过tag改),并且内部多个子元素可以同时被追踪。Vue3 里TransitionGroup还提供了move类用于列表内元素位置变化时的位移动画。配合 FLIP 技术,可以实现“其他元素平滑让位”的效果。
一个典型场景:删除列表中的某一项,剩下的项自动补位。
<template> <transition-group name="list" tag="ul"> <li v-for="item in items" :key="item.id"> {{ item.name }} <button @click="removeItem(item.id)">删除</button> </li> </transition-group> </template>.list-enter-active, .list-leave-active { transition: all 0.3s ease; } .list-enter-from, .list-leave-to { opacity: 0; transform: translateX(30px); } /* 位置移动的过渡 */ .list-move { transition: transform 0.3s ease; } /* 让被删除元素不占位,避免周围元素跳动 */ .list-leave-active { position: absolute; }这段代码里有个很关键的细节:list-leave-active里加position: absolute,这样删除的元素会脱离文档流,剩下的元素能立即补位,产生的move动画会更自然。如果不加,删除瞬间剩余的项会先跳一下,然后再做位移动画,观感很差。
排查这个现象时我花了不少时间,后来在浏览器里逐步调试才发现是占位问题。
5.4 自定义过渡类名与第三方库协作
Vue 允许给transition传自定义 class,覆盖默认生成的类名:
<transition enter-active-class="animate__animated animate__fadeIn" leave-active-class="animate__animated animate__fadeOut" > <component :is="Component" :key="route.path" /> </transition>这在配合 Animate.css 这类第三方动画库时非常方便。不用自己写 CSS,只需要从文档里拷贝类名。需要注意 Animate.css 这些库自带animation属性,你得保证库的主题样式全局引入了,否则类名不生效。
还有一点,如果用了自定义类名,默认的六个类名就不会被添加,而是用你提供的这些类名替代。这就意味着你要全权负责过渡过程中的所有样式,比如动画时长、缓动函数、方向等。
6. 常见问题与排错心得
6.1 动画不触发的排查清单
我把自己平时排查过渡动画问题的顺序整理成了一张表,每次遇到问题都按这个顺序过一遍,90% 的问题能在 5 分钟内定位:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 动画完全不执行 | 组件 key 没变 | 确认:key绑定的值是否每次切换都不同 |
| 动画执行一半突然跳变 | CSS 里缺少transition属性 | 检查enter-active和leave-active是否写了transition |
| 页面切换时闪一下 | 遮挡层级问题 | 给离场元素加position: absolute或调 z-index |
| 新页面瞬间出现 | mode没生效 | 确认 transition 本身没有被 v-if 控制 |
| 离场动画结束时页面卡住 | JS 钩子没调用 done | 搜索:enter和:leave钩子,确认 done 被调用 |
| 动画执行后元素错位 | 没有清理内联样式 | 在after-enter和after-leave里清掉 style |
| 列表删除后剩余项跳动 | 没有设置position: absolute | 在leave-active里加绝对定位 |
6.2 内联样式残留问题
在 JS 钩子里改元素 style,比如设置opacity、transform,如果没有在动画结束后清掉,这些内联样式会一直留在 DOM 上。最典型的场景是:进场动画里设置了transform: translateY(40px),动画结束后忘了在after-enter里清空,下次页面尺寸变化或内部元素定位时,这个残留的 transform 会引发各种奇怪问题——比如 fixed 定位元素位置错乱、子元素position: sticky失效。
所以凡是 JS 钩子里操作过的样式属性,after-enter和after-leave里一定要老老实实清一遍。CSS 类名方式没有这个问题,因为类名会在过渡结束后被 Vue 自动移除。
6.3 与组件库的样式冲突
很多后台管理系统基于 Element Plus 或 Ant Design Vue 这类组件库。这些组件库有全局样式重置,甚至有的会针对transition属性做特殊处理。用 Transition 后组件的首帧样式可能被插件或全局样式覆盖,导致动画不生效。
我在一个 Vue3 + Element Plus 项目里就遇到过:Table 组件放在路由页面里,页面切换动画执行时 Table 内部的样式闪烁。原因定位到的根因是 Element Plus 的 tooltip 在路由切换的瞬间被触发,它的定位是 fixed,加上我们的transition设置了 transform,导致它相对新定位上下文错乱。
解决方案是给页面包裹层设置overflow: hidden或will-change: transform,让动画期间内部浮动元素不参与定位计算。另外,留意组件库的 Popup 类组件(弹窗、气泡、下拉框)不要在切页动画期间呼出,否则层级和位置都容易出问题。
6.4 动态组件模式下动画被吞掉
<component :is="Component" :key="route.path" />这种写法下,如果子组件内部有自己的根元素动画,比如组件内部有v-if切换加transition,那么内部动画和外部过渡可能相互干扰。Vue 的过渡检测是递归的吗?不是。内部组件的 transition 是独立处理的,但外部 transition 包裹的是整个组件实例,两个动画的时序叠加后,内部的动画可能被外部的离场/进场打断。
我的经验是,内外动画不要同时做大幅度的位移和透明度变化。外部负责整体方向感(位移),内部负责局部元素的显现(透明度),避免“套娃式”的多层位移叠加。动画是感受型的东西,叠加太多反而廉价。
6.5 性能问题:动画期间页面掉帧
页面本身数据多、图表重,切页动画期间掉帧特别明显。一是因为离场动画和进场动画同时跑,CPU/GPU 压力大;二是因为页面里有大量重排重绘操作,比如大表格、图表 canvas。
我整理过几个比较有效的优化手段:
- 动画期间给动画元素加
will-change: transform, opacity,让浏览器提前做合成层优化 - 只用
transform和opacity做动画,避免动画width、height、top、left这些触发重排的属性 - 大数据表格页尽量用组件库自带的虚拟滚动,不要让大量 DOM 参与动画
- 如果页面确实重,考虑用
mode="out-in"而不是in-out,至少同一时间只做一套动画 - 极端情况下,可以直接让动画时长缩短到 150ms 甚至 0,用微交互代替大幅动画
有个指标我感觉很实用:如果页面切换动画时长超过 300ms,用户会感觉拖沓;低于 150ms 又几乎感知不到。200ms 到 250ms 之间是我认为最均衡的区间,既明显又干脆。
6.6 移动端适配与减少动态效果需求
移动端页面切换对动画最敏感。iOS 和 Android 的浏览器对 transform 的合成层处理不同,有些安卓机在动画结束时会出现残影,原因是没有强制开启 GPU 加速或合成层未释放。
解决方法是在动画结束后清理掉内联样式,手动把transform赋值为none,并通过after-leave钩子里调用浏览器垃圾回收机制促使渲染层释放。
如果线上项目里有很多用户反馈“动画导致头晕”或者“动画太晃”,要在系统设置层面做降级。我见过不少项目加了prefers-reduced-motion媒体查询:
@media (prefers-reduced-motion: reduce) { .fade-enter-active, .fade-leave-active, .slide-left-enter-active, .slide-left-leave-active, .slide-right-enter-active, .slide-right-leave-active { transition: none !important; } }有这个媒体查询在,系统开了“减少动态效果”的用户会直接跳过动画。这不复杂,但体现的是对用户偏好的尊重,值得养成习惯。
7. 写在最后的实践经验分享
做了这么多年前端,我对动画的看法比较务实:动画是产品体验的一部分,不是装饰品。页面切换动画的核心价值是给用户一个连续的空间认知,告诉用户“你从哪来到了哪”。没有动画,用户只知道页面变了;有了合适的动画,用户能感知到前进、后退、层级递进的关系。
具体到 Vue3 里,Transition 组件是我见过设计得相当好用的内置 API 之一。它不强迫你用任何动画库,CSS 能做就用 CSS,CSS 做不了就上 JS 钩子。类名机制清晰,生命周期完整,配合 Router 之后能快速搭出一套有质感的页面切换方案。
如果你现在要在新项目里落地这套方案,我给的直接建议是:
- 先定义一个
PageTransition公共组件,统一维护动画类名和路由深度判断逻辑 - 动画类只用
transform和opacity,不要碰display、width、height mode无脑用out-in,视觉干净,排查问题也少一半- 路由的
meta里预留depth字段给层级动画用,将来加页面不用改动画逻辑 - 把
prefers-reduced-motion的降级样式写进全局
这样一套下来,后台管理系统还是移动端 H5 都能直接套用。真正踩坑的时候,按上面那张排查表过一遍,基本能很快定位到问题。动画这块的坑都是比较显性的——要么不触发,要么触发错乱,对照时间线去查,思路清楚了,解决只是时间问题。