
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 namefade p v-ifshowhello/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加了namefade之后前缀就会变成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-toCSS 过渡开始执行从初始状态过渡到结束状态过渡结束后移除v-enter-active和v-enter-to离场过程条件变 false元素即将被移除Vue 添加v-leave-from和v-leave-active强制 reflow移除v-leave-from添加v-leave-toCSS 过渡开始执行过渡结束后移除元素及所有相关类名这个时间线是一切的根基。任何过渡动画不生效八成是在这个流程的某个环节出了问题。比如你给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 动画已经结束。如果你不调用doneVue 会认为动画还在进行中元素永远不会被移除。这里我要特别提醒一个坑如果你在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 clickshow !show切换/el-button transition namefade div v-ifshow classboxhello 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 namefade appear div v-ifshow页面加载时也会有渐入效果/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属性。modeout-in表示先执行完离场动画再把新元素插进来执行进场动画。modein-out则相反先插新元素、执行完进场再移旧元素。页面切换场景基本都用out-in它能保证同一时间只有一个页面在动视觉上更干净。transition namepage modeout-in component :iscurrentComponent :keycurrentPath / /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 namefade-slide modeout-in component :isComponent :key$route.path / /transition /router-view /templaterouter-view的v-slot是 Vue Router 4 提供的能力从插槽里解构出Component再用动态组件的方式渲染。这么写有几个好处能拿路由组件实例给动态组件传 propskey绑$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 :nametransitionName modeout-in component :isComponent :keyroute.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 :namename :modemode before-enteronBeforeEnter enteronEnter after-enteronAfterEnter before-leaveonBeforeLeave leaveonLeave after-leaveonAfterLeave 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 nameslide-left modeout-in router-view v-slot{ Component, route } component :isComponent :keyroute.path / /router-view /PageTransition这样全局只维护一套过渡组件页面要换动画风格时只改这个文件后端管理系统非常受益。不过要注意slot里的内容必须只有一个根元素或者说 Transition 只对直接子节点生效。如果插槽传了多个元素进来Vue 会告警。实际写的时候通常把 router-view 或者单个组件放在里面就行。5.2 JS 钩子实现复杂动画控制有些动画 CSS 不好写比如分步骤动画或者需要根据滚动位置动态计算位移动画。这时候就得用 JS 钩子。一个常见的场景是进入页面时先淡入再上浮而且要控制每一步的时间和延迟transition before-enteronBeforeEnter enteronEnter after-enteronAfterEnter leaveonLeave component :isComponent :keyroute.path / /transitionconst 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 namelist tagul li v-foritem in items :keyitem.id {{ item.name }} button clickremoveItem(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-classanimate__animated animate__fadeIn leave-active-classanimate__animated animate__fadeOut component :isComponent :keyroute.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 :isComponent :keyroute.path /这种写法下如果子组件内部有自己的根元素动画比如组件内部有v-if切换加transition那么内部动画和外部过渡可能相互干扰。Vue 的过渡检测是递归的吗不是。内部组件的 transition 是独立处理的但外部 transition 包裹的是整个组件实例两个动画的时序叠加后内部的动画可能被外部的离场/进场打断。我的经验是内外动画不要同时做大幅度的位移和透明度变化。外部负责整体方向感位移内部负责局部元素的显现透明度避免“套娃式”的多层位移叠加。动画是感受型的东西叠加太多反而廉价。6.5 性能问题动画期间页面掉帧页面本身数据多、图表重切页动画期间掉帧特别明显。一是因为离场动画和进场动画同时跑CPU/GPU 压力大二是因为页面里有大量重排重绘操作比如大表格、图表 canvas。我整理过几个比较有效的优化手段动画期间给动画元素加will-change: transform, opacity让浏览器提前做合成层优化只用transform和opacity做动画避免动画width、height、top、left这些触发重排的属性大数据表格页尽量用组件库自带的虚拟滚动不要让大量 DOM 参与动画如果页面确实重考虑用modeout-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 能做就用 CSSCSS 做不了就上 JS 钩子。类名机制清晰生命周期完整配合 Router 之后能快速搭出一套有质感的页面切换方案。如果你现在要在新项目里落地这套方案我给的直接建议是先定义一个PageTransition公共组件统一维护动画类名和路由深度判断逻辑动画类只用transform和opacity不要碰display、width、heightmode无脑用out-in视觉干净排查问题也少一半路由的meta里预留depth字段给层级动画用将来加页面不用改动画逻辑把prefers-reduced-motion的降级样式写进全局这样一套下来后台管理系统还是移动端 H5 都能直接套用。真正踩坑的时候按上面那张排查表过一遍基本能很快定位到问题。动画这块的坑都是比较显性的——要么不触发要么触发错乱对照时间线去查思路清楚了解决只是时间问题。