1. 这套路由动画到底在动什么:先看清 AppMain 里的三行代码
第一次接触 Vue Element Admin 的人,十个里有八个会问同一个问题:「我点菜单,页面怎么是硬切过去的,一点过渡都没有?」其实不是没有,而是你没找到它藏在哪。Vue Element Admin 默认自带了路由动画,代码就躺在src/layout/components/AppMain.vue里,只有<transition>加一个<style scoped>里几百字节的 CSS,但就是这三行结构加六条样式,撑起了整个后台的页面切换过渡效果。这篇东西就想把路由动画这层窗户纸捅破,从「它在动什么」讲到「怎么改成你要的样子」,覆盖 Vue 2 那一套老写法,也顺带把 Vue 3 加 vue-router 4 的差异一起说清楚。
适合谁看?如果你手上正在维护一个基于 Vue Element Admin 的后台系统,觉得默认动画别扭——比如切菜单太慢、切完了页面抖一下、子路由切换压根不动、弹窗被动画压住层级——那这篇基本能覆盖你的场景。如果你是从零搭后台,想主动设计一套「切页面有呼吸感」的过渡,前半部分讲时序和参数的章节也能当设计参考。我不打算只贴代码,因为这类「照抄不生效」的情况太多了,真正卡人的往往是 CSS 作用域、keep-alive 嵌套顺序、key取哪个值这些细节。
先给个最短的答案:Vue Element Admin 的页面切换过渡,本质上是一个包裹router-view的<transition>组件 + 一套以fade-transform命名的 CSS 类 + 一个会让路由视图重新渲染的key,三者缺一不可。少了 transition 没动画,少了 CSS 类动画跑不起来,少了 key 则 Vue 会认为「这还是同一个组件」直接复用,动画连触发机会都没有。后面所有的展开,都是围绕这三件事讲。
1.1 AppMain 里那段结构的真实含义
打开AppMain.vue,模板部分长这样:
<template> <section class="app-main"> <transition name="fade-transform" mode="out-in"> <router-view :key="key" /> </transition> </section> </template> <script> export default { name: 'AppMain', computed: { key() { return this.$route.path } } } </script>这段代码信息量不小,我们一行一行拆。
<transition>是 Vue 内置的抽象组件,它不渲染任何真实 DOM,只做一件事:监听包裹子元素的进入和离开,在恰当的时机挂上、摘掉特定类名。这里的name="fade-transform"决定了类名前缀是fade-transform-*而不是默认的v-*。名字一定要和 CSS 里的类名严格对齐,差一个字母动画就完全不生效,而且不会有任何报错提示——这是新手最常栽的第一个坑。
mode="out-in"控制的是新旧两个页面在时间上的排布关系。默认模式(不写 mode)是「同时进行」,旧页面还在离开、新页面已经开始进入,两个视图会短暂共存于同一个容器里。对一个后台布局来说,这个共存的瞬间非常致命:两页高度不一样,父容器的高度会在几十毫秒内跳一下,如果外层还有滚动条,用户会看到明显的横向或纵向抖动。out-in的含义是「旧的走完,新的再来」,同一时刻容器里只有一页,布局永远稳定。代价是总时长翻倍,比如单边 0.3 秒,用户实际感知的是 0.6 秒,这也是很多人抱怨「默认动画慢」的根源,后面讲参数时会展开。
:key="key"是让动画能触发的前提。Vue 做 DOM diff 时,如果新老 vnode 的 type 和 key 都一致,就认为这是同一个节点,直接复用、只更新 props 和数据,不会走卸载和挂载流程。路由切换时router-view渲染出的组件虽然变了,但router-view本身是个同一个组件,如果没有 key 差异,Vue 层面并不会把它当成「一次元素的进入离开」,<transition>自然监视不到任何变化。给了key之后,key 变 → 节点被判定为不同 → 旧节点离开、新节点进入 → transition 生效。
key为什么取this.$route.path而不是$route.fullPath,这是一个刻意的设计取舍。path不含查询参数,意味着从/user/list?page=1跳到/user/list?page=2时,key 不变,页面不重新渲染也不会触发动画,只走组件内部的 watch 更新。对后台表格页来说这是对的——翻个页还整屏淡出淡入一次,观感很糟糕。反过来说,如果你的业务里带 query 的跳转本身就意味着「换了一整个页面」,那就该换成fullPath,但要接受翻页、筛选、tab 切换都会触发动画的副作用。
1.2 为什么 mode 和 key 缺一不可
这两个点经常被分开讨论,实际它们是互相牵制的。我把几种组合的实际表现列一下,你自己对号入座。
| 配置组合 | 切换表现 | 适用判断 |
|---|---|---|
| 无 key + 无 mode | 完全无动画,组件原地复用 | 只适合路由间共享同一组件的场景 |
| 有 key + 无 mode | 有动画但两页叠在一起,高度抖动 | 单页高度一致时勉强可用,不推荐 |
| 无 key + out-in | 完全无动画(新旧是同一节点) | 无效配置 |
| 有 key + out-in | 顺序切换,布局稳定,感知时长变长 | Vue Element Admin 默认,最稳 |
还有第三种模式in-out,意思是新页面先进来,旧页面再走。听上去像「无缝衔接」,实际体验更糟:进入过程中两页依然共存,最后旧页再淡出,用户会看到一个「新旧叠加然后旧页消失」的怪异过程。做后台过渡,out-in基本是唯一正解,别在这上面折腾。
补充一个容易被忽略的细节:<transition>要求直接子节点是单个元素。<router-view>本身在运行时渲染成一个组件元素,没问题;但如果你的页面组件在 Vue 3 里写了多根节点(多个并列的顶层标签),transition 会直接警告并放弃动画。Vue 2 时代组件模板有且只有一个根节点,反而规避了这个问题,迁移到 Vue 3 时这个坑才会冒出来。
1.3 transition 和 keep-alive 的嵌套顺序
后台系统几乎一定会用keep-alive缓存页面状态——从列表进详情再返回,列表的筛选条件、滚动位置还得在。很多人的写法是这样的:
<!-- 错误示范:顺序反了 --> <keep-alive> <transition name="fade-transform" mode="out-in"> <router-view :key="key" /> </transition> </keep-alive>这么写在 Vue 2 里看起来也能跑,但缓存的对象变成了 transition 包裹的那一层,容易出现「页面缓存了但动画丢了」或者「动画还在但状态丢了」的错乱现象。正确顺序是transition 在外,keep-alive 在内:
<template> <section class="app-main"> <transition name="fade-transform" mode="out-in"> <keep-alive :include="cachedViews"> <router-view :key="key" /> </keep-alive> </transition> </section> </template>道理不复杂:transition 要监视的是「这个位置上的元素有没有换人」,所以它必须是最外层、直接盯住渲染结果的那个;keep-alive 是缓存的实现细节,套在里面,被 transition 当成一个普通的、会变化的子节点即可。顺序换过来,transition 监听的就不再是「页面变了没」,而是「缓存组件实例变了没」,语义已经偏了。
注意:
keep-alive的include匹配的是组件的 name 选项,不是路由的 name。写include之前先确认目标组件的name字段,两个名字对不上,缓存静默失效,这个坑我踩过不止一次。
2. 过渡样式的底层时序:四个类名分别在什么时候生效
动画不生效的第二大类原因,全在 CSS 这一侧。Vue 的过渡类名一共有六个,但常用的只有四个,且每一个出现的帧都不一样。搞不清时序,就会写出「只有进场没退场」或者「闪一下才动」的样式。
默认那段 CSS 在 Vue Element Admin 里是这样的:
.fade-transform-leave-active, .fade-transform-enter-active { transition: all 0.5s; } .fade-transform-enter { opacity: 0; transform: translateX(-30px); } .fade-transform-leave-to { opacity: 0; transform: translateX(30px); }短短几行,包含了两条互补的位移线:新页面从左侧 -30px 处淡入,旧页面向右 +30px 处淡出,一进一出形成「内容向右流动」的方向感,配合左侧固定菜单,视觉上像是新内容从菜单那一侧推进来。这个方向不是随便定的,改成反方向(进从右来、出去往左)就会有「往回退」的错觉,和后台逐层深入的浏览逻辑相悖。
2.1 enter 和 leave 的帧级执行顺序
Vue 2 的过渡时序可以这样理解,以进场为例:
- 元素插入 DOM 前,挂上
fade-transform-enter和fade-transform-enter-active。此时元素已经在「起点状态」:透明、左移 30px,且已具备 transition 属性。 - 插入 DOM 后的下一帧(
requestAnimationFrame触发),移除fade-transform-enter,加上fade-transform-enter-to。起点类被摘掉,元素回到默认样式(不透明、原位),CSS 引擎检测到属性变化,开始过渡。 - 过渡时长结束,移除
enter-active和enter-to,收尾。
退场类似:先挂leave和leave-active(此时还是正常状态),下一帧摘leave挂leave-to(变成透明、右移),过渡结束再清理。
看懂这个顺序,你就能明白两件事。第一,起点状态必须写在enter里,终点状态必须写在leave-to里,反过来写无效——enter-to一般不写样式,因为它代表的就是元素「本来该有的样子」。第二,transition属性必须挂在-active类上,因为只有它在整个过渡期间始终存在。写在enter里会在下一帧被摘掉,过渡属性随之消失,元素会「瞬移」到终点。
Vue Element Admin 那段 CSS 用了.fade-transform-enter和.fade-transform-leave-to简写,把进场起点和退场终点合并成对称的两段,读起来清爽。如果你想更显式,也可以补全:
.fade-transform-enter-from, .fade-transform-leave-to { opacity: 0; transform: translateX(-30px); }不过在 Vue 2 里没有enter-from这个类,写上去是无效的,别顺手把 Vue 3 的写法带进来。
2.2 时长、位移、缓动三个参数的取值逻辑
默认0.5s配上out-in,用户实际等待是 1 秒。这个数字在 2018 年的机器上还行,现在明显拖沓。我的经验值:单边 0.22s 到 0.3s,总感知 0.45s 到 0.6s,在「能感觉到过渡」和「不觉得慢」之间刚好卡住。
为什么是这个区间?人眼对界面变化的「瞬时感」阈值大约在 100ms 以内,超过 100ms 就会感知为「有过程」;而 400ms 以上的等待开始引发注意力转移,用户会下意识觉得卡。把单边压到 0.25s,两边合计 0.5s,正好落在「有仪式感但不拖」的窗口里。如果你的页面本身首屏渲染就慢(比如要拉三个接口),还可以再压到 0.18s,让动画不要叠加在加载等待上。
位移距离同理。30px 是个中庸值,改成 100px 会变成「飞入」效果,只适合大屏仪表盘这种需要夸张动效的场景;改成 8px 又几乎看不出来,还不如不做。我一般按容器宽度微调:宽屏布局用 20px,窄屏或分栏布局用 12px,视觉位移感相差不大,但小屏上过大的位移会让内容「甩出去」的感觉很明显。
缓动曲线是最容易被忽略但最能提升质感的一项。默认的ease是cubic-bezier(0.25, 0.1, 0.25, 1),起步慢、收尾也慢,中间一段快,用在位移上会显得「先犹豫再冲」。更适合界面过渡的做法是:进场用减速曲线,退场用加速曲线,组合起来行云流水。
.fade-transform-enter-active { transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); } .fade-transform-leave-active { transition: opacity 0.18s cubic-bezier(0.4, 0, 1, 1), transform 0.18s cubic-bezier(0.4, 0, 1, 1); }注意这里把opacity和transform分开写,而不是用all。all是个省事的写法,但它会把所有可过渡属性都纳入监听,包括你不希望动的那些(比如某些组件内部动态改的border-color、background),一旦页面里有其他属性变化,会跟着一起过渡。用all最容易出的事故是:某些第三方组件在挂载时改height,结果被过渡拦下来,出现「页面高度慢慢撑开」的诡异画面。老老实实列属性,是省心不省钱的做法。
2.3 为什么必须用 transform 而不是 left
有人习惯把位移动画写成left: -30px到left: 0,跑起来也像那么回事,但性能账完全不一样。
left是布局属性,浏览器改它必须重新计算元素的几何尺寸、重新布局(reflow),再重新绘制(repaint),最后合成。这三个阶段任意一个都可能在低端设备上吃掉 10ms 以上,页面里再有几个图表组件,动画直接掉帧。而transform走的是合成阶段,现代浏览器会为它单独提升为一个合成层,动画期间只改变图层的位置矩阵,主线程几乎不参与。同样是 0.25 秒的位移,transform能稳定 60 帧,left在复杂页面上可能掉到 30 帧以下,肉眼能明显看出卡顿。
同一逻辑也适用于用margin、top、width做进场动效,后台项目里这些写法都应该改掉。想验证自己的动画是不是走了合成层,Chrome DevTools 的 Rendering 面板里勾上Paint flashing和Layer borders,动画期间如果整块区域闪绿,说明在重绘;如果只是图层边框高亮,说明走的是合成,稳。
但transform有个非常要命的副作用,必须提前打招呼。
注意:任何元素只要带上了非
none的transform,就会成为其内部position: fixed子元素的包含块。也就是说,动画期间.app-main上的translateX会让里面所有fixed定位的弹窗、抽屉、回到顶部按钮,从「相对视口固定」变成「相对 .app-main 固定」。如果你在这些页面里用了全屏遮罩,动画那 0.5 秒里遮罩会跟着跑偏,甚至动画结束后因为残留的transform一直偏。这是路由动画最常见的连带事故。
3. 完整实操:把路由过渡接进现有项目
理论说够了,动手。这一节从「项目是空的」开始,一步步把过渡搭起来,同时给出每一步该怎么验证,避免你改完之后只能靠猜。
3.1 先定位文件,别改错地方
Vue Element Admin 的布局结构是这样的:src/layout/index.vue里放了侧边栏、导航栏、内容区三块,内容区引用了AppMain.vue。路由视图在AppMain.vue内部渲染,所以过渡动画的容器就是这里。很多人跑到layout/index.vue或者router/index.js里加 transition,改完没反应,是因为位置就不对。
一个快速判断方法:在浏览器里打开页面,DevTools 选中渲染出的内容区,看它的父级 DOM 外面有没有多出注释节点——Vue 的<transition>组件在 DOM 里不留节点,但mode="out-in"生效时你会在切换瞬间看到旧页面从 DOM 里被移除的过程。更直接的办法是给<section class="app-main">加个临时背景色,确认自己改的文件确实被渲染了。
如果你用的是自己搭的布局,没有这个文件,那对应位置就是「包裹<router-view>的那一层容器」,找它就行。
3.2 改造 AppMain:把时长和曲线参数化
我不建议每次都去改 CSS 里的硬编码数值,更省事的做法是用 CSS 变量把三个关键参数抽出来,一处改、全局生效:
<template> <section class="app-main"> <transition name="fade-transform" mode="out-in"> <router-view :key="key" /> </transition> </section> </template> <script> export default { name: 'AppMain', computed: { key() { return this.$route.path } } } </script> <style scoped> .app-main { --page-enter-duration: 0.25s; --page-leave-duration: 0.18s; --page-shift: 20px; min-height: calc(100vh - 50px); width: 100%; position: relative; overflow: hidden; padding: 0; } .fade-transform-enter-active { transition: opacity var(--page-enter-duration) cubic-bezier(0.16, 1, 0.3, 1), transform var(--page-enter-duration) cubic-bezier(0.16, 1, 0.3, 1); } .fade-transform-leave-active { transition: opacity var(--page-leave-duration) cubic-bezier(0.4, 0, 1, 1), transform var(--page-leave-duration) cubic-bezier(0.4, 0, 1, 1); } .fade-transform-enter { opacity: 0; transform: translateX(calc(var(--page-shift) * -1)); } .fade-transform-leave-to { opacity: 0; transform: translateX(var(--page-shift)); } </style>这里有两个细节值得说。
一是min-height: calc(100vh - 50px)里的 50px 是导航栏高度。这个值不是装饰,它决定了「短页面切换时容器高度是否塌陷」。如果去掉min-height,从一屏高的列表跳到只有三行文字的详情页,父容器会瞬间缩到很矮,而out-in期间旧页面已移除、新页面刚进来,滚动条会有一个明显的「消失再出现」的跳变。保留min-height并配合overflow: hidden,可以让容器高度恒定,滚动条不动。
二是overflow: hidden不只是为了裁剪位移。translateX(-30px)期间,元素一侧会伸到容器外面一点,如果没有 hidden,容器上一级就可能出现横向滚动条闪一下。用overflow: hidden是代价最小的兜底,但它也会让内部的position: sticky失效——如果你的页面里用了吸顶表头,就需要把这层 hidden 换成只在动画期间生效的类(用@before-enter/@after-enter钩子动态加)。
CSS 变量写在.app-main上,过渡类名被加在它的子元素上,变量会继承下去,所以var()能取到值。这个链路没问题,但如果你把变量写到:root或者外层布局上,也照样生效,看团队习惯。
3.3 处理多个 router-view 与嵌套路由
后台里常见「主内容区 + 侧边详情栏」的双视图结构,或者父子路由(父级是列表,子级是详情弹层)。这种结构下,动画会变得微妙。
对于嵌套的<router-view>,子视图的切换发生在父组件内部,外层 AppMain 的 key 是$route.path,从/list跳到/list/1时 key 变了,整个父组件会被重建,动画会从最外层触发一次。这在语义上没问题——用户确实换了页面。但如果你希望「父级列表保持不动,只有子级详情滑入」,就不能靠外层 key,得在子级 router-view 上单独包 transition:
<template> <div class="list-layout"> <list-panel /> <transition name="detail-slide" mode="out-in"> <router-view :key="$route.fullPath" /> </transition> </div> </template>这种情况下外层 AppMain 的 key 建议改成$route.matched[0].path,让一级路由切换才触发整页动画,二级路由交给内层处理,两级节奏不打架。这个改法不算标准做法,是我在实际项目里试出来比较稳的方案,你可以按自己的路由层级结构微调。
3.4 改完必须跑一遍的验证清单
改代码容易,确认它真的按预期跑起来难。我通常按下面这个顺序自检,五分钟能定位大部分问题。
| 检查项 | 期望现象 | 不符合说明 |
|---|---|---|
| 点左侧菜单切换一级路由 | 旧页右移淡出,新页左移淡入 | 无动画 → 查 key 和类名 |
| 同一菜单下点二级路由 | 视你的设计而定,至少要有一致行为 | 忽动忽不动 → 查 key 取值 |
| 切换瞬间页面高度 | 无跳变,滚动条不闪 | 有跳动 → 查 min-height 与 mode |
| 快速连点三次菜单 | 动画不叠加、不卡死 | 叠影 → 缺 mode 或 key 重复 |
| 带弹窗的页面切换 | 弹窗在动画期间不跑偏 | 跑偏 → transform 包含块问题 |
| 返回带缓存的列表页 | 状态保留且动画正常 | 状态丢 → keep-alive 顺序错 |
第三项和第五项是最容易出问题但最难自查的,因为「灯光正常的时候没人看灯泡」。建议专门造两个测试页:一个超短(三行字),一个超长(一百行表格),在它们之间来回切,任何高度抖动都会立刻暴露。
4. 进阶:方向感动画与按路由定制过渡
默认的「永远从左边进」用久了会腻,而且语义上有个硬伤:从「用户管理」跳到「角色管理」是同层级横移,动画方向无所谓;但从「订单列表」走进「订单详情」,用户心理模型是「深入一层」,这时候如果详情页还是从左边推进来,会有种「又开了一个同级页面」的错位感。解决思路是让动画方向跟随导航方向。
4.1 用路由层级判断前进还是后退
最省事的判据是路由的matched数组长度,也就是匹配到的路由记录数,层级越深说明越「往里走」:
computed: { transitionName() { const toDepth = this.$route.matched.length const fromDepth = this.fromRoute ? this.fromRoute.matched.length : 0 if (toDepth === fromDepth) return 'fade-transform' return toDepth > fromDepth ? 'slide-left' : 'slide-right' } }配合beforeRouteUpdate或者全局的router.afterEach把上一跳的路由记下来。这套逻辑简单可靠,缺点是只能反映「层级深浅」,不能反映「水平位置」。从「角色管理」跳到「订单列表」,两者层级相同,方向就退化成无方向的淡入,还算能接受。
想更精确的话,可以维护一个简单的历史栈:router.afterEach里把路由 push 进数组,监听浏览器前进后退(popstate)时标记为「回退」,回退方向永远反向。这类方案代码量会上去一些,收益在大型后台里才明显。我的建议是:先用层级判据,跑一段时间发现不够用再上历史栈,别一上来就设计一套复杂的方向状态机,后期维护成本比动画收益高得多。
对应的 CSS 就是两套方向:
.slide-left-enter { opacity: 0; transform: translateX(30px); } .slide-left-leave-to { opacity: 0; transform: translateX(-30px); } .slide-right-enter { opacity: 0; transform: translateX(-30px); } .slide-right-leave-to { opacity: 0; transform: translateX(30px); } .slide-left-enter-active, .slide-left-leave-active, .slide-right-enter-active, .slide-right-leave-active { transition: opacity 0.25s ease, transform 0.25s ease; }注意方向是反的:前进时新页从右侧推入(translateX(30px)起点),旧页向左退出。这个方向直觉来自移动端的页面栈,用户已经被手机训练出「新页面从右边来」的肌肉记忆,后台沿用同一套心智模型,学习成本为零。
4.2 让单个页面自己决定过渡方式
有些页面天生不适合位移。比如全屏的图表大屏、带position: fixed覆盖层的页面、或者加载了 WebGL 的重型页面,位移会诱发明显的重绘卡顿。这种页面适合用纯淡入,甚至干脆不做动画。
实现方式是在路由meta上打标记,AppMain 读取:
// router/index.js { path: '/dashboard', component: () => import('@/views/dashboard/index'), meta: { title: '数据看板', transition: 'fade' } }computed: { transitionName() { return this.$route.meta.transition || 'fade-transform' } }然后在 CSS 里补一套.fade-enter/.fade-leave-to,只做透明度。这个扩展点很轻,但实际用起来非常顺手——重页面、全屏页、iframe 嵌套页都可以单独降级,不用为了一个页面把全局动画都关掉。
meta里还可以塞时长。比如大屏页想要更慢更庄重的 0.6 秒淡入,其他页面维持 0.25 秒。做法是把 CSS 变量挂到内容根元素上:
computed: { transitionStyle() { return { '--page-enter-duration': this.$route.meta.duration || '0.25s' } } }<section class="app-main" :style="transitionStyle">CSS 变量的好处是优先级高、改动局部,不用维护一堆类名分支。
4.3 侧边栏折叠与内容区宽度联动的细节
有个小问题很多人遇到过:侧边栏折叠时,内容区宽度变化有 0.28 秒的过渡(Vue Element Admin 默认给.main-container加了transition: margin-left),如果这时候正好切路由,两个动画叠在一起,内容区的位移会「双重计算」,看起来像在抽搐。
处理办法是给内容宽度过渡和路由动画错开触发时机,或者干脆把侧边栏折叠动画的时长调到比路由动画短(比如 0.2s),让它在路由切换开始前就完成。更彻底的做法是把侧边栏的宽度变化也交给transform而不是margin-left,但那样会改动布局结构,成本偏高,通常不值得。
还有一个视觉细节:内容区左右padding一般是 20px,路由动画的位移起点如果正好等于 padding,视觉上会像「新页面从内边距里滑出来」,很自然;如果位移距离远大于 padding,就会滑出可视区再进来,反而显得刻意。所以--page-shift取 16px 到 24px 之间,和 padding 数量级对齐,是最不容易出错的。
5. Vue 3 + vue-router 4 的写法差异与迁移要点
现在还在维护 Vue 2 后台的团队不少,但新项目基本都上 Vue 3 了。Vue Element Admin 那套写法在 Vue 3 里直接复制会失效,因为有三个地方变了:router-view的渲染方式、过渡类名的命名、以及组件多根节点的问题。
5.1 v-slot 解构是新的必经之路
Vue 3 的router-view不再直接渲染组件,而是通过作用域插槽暴露Component,你需要自己决定把它放到哪里。这带来的好处是 transition 和 keep-alive 可以更明确地组合:
<template> <section class="app-main"> <router-view v-slot="{ Component, route }"> <transition name="fade-transform" mode="out-in"> <keep-alive :include="cachedViews"> <component :is="Component" :key="route.path" /> </keep-alive> </transition> </router-view> </section> </template>顺序依然是 transition 在外、keep-alive 在内、component最里。key依然要加,但注意这里用的是插槽解构出来的route,不是this.$route——在<script setup>里拿不到this,用插槽参数最省事,也避免了响应式依赖追踪的坑。
cachedViews一般来自状态管理,存的是组件 name 数组。Vue 3 里如果用了<script setup>,组件默认没有 name,include会匹配不上。这时候要么显式写defineOptions({ name: 'UserList' }),要么用vue-plugin-name之类的编译插件自动生成,别指望它自己猜。
5.2 类名改了,别照抄旧的
Vue 3 把过渡类名重新命名了一遍,这是迁移时最隐蔽的坑——代码没报错、动画就是不生效:
| Vue 2 类名 | Vue 3 类名 | 含义 |
|---|---|---|
v-enter | v-enter-from | 进入起点 |
v-enter-active | v-enter-active | 进入过程(不变) |
v-enter-to | v-enter-to | 进入终点(不变) |
v-leave | v-leave-from | 离开起点 |
v-leave-active | v-leave-active | 离开过程(不变) |
v-leave-to | v-leave-to | 离开终点(不变) |
所以fade-transform-enter要改成fade-transform-enter-from,fade-transform-leave要改成fade-transform-leave-from。-active和-to保持不变。改完之后动画立刻回来,很多人以为是配置问题,其实就是一个类名没改。
另外 Vue 3 支持<Transition>首字母大写的组件式写法,配合<script setup>使用更自然:
<template> <Transition name="fade-transform" mode="out-in"> <component :is="Component" :key="route.path" /> </Transition> </template>功能完全等价,只是书写风格。团队内部统一就行,别一半大写一半小写。
5.3 多根节点组件的静默失败
Vue 3 允许组件模板有多个根节点,这是好事,但它和<transition>天然冲突——transition 需要「一个元素」才能挂类名,多根节点没有单一宿主,动画无从附着。
实际表现是:切换到这个页面时控制台会出现一条警告,大意是组件渲染了非元素根节点,无法执行动画,然后动画静默跳过。页面上什么都没发生,你会以为是路由配置问题,排查半天。
解决办法有几种,按推荐度排序:
- 给页面组件套一层
<div>作为唯一根节点,最直接,代价是多一个 DOM 层级。 - 用
<template>加v-if保证同一时刻只有一个根节点渲染,但这需要业务上确实互斥。 - 把多根改成一个语义化容器(
<main>、<section>),既解决动画又改善可访问性,我个人最推荐这个。
提示:这个警告在生产构建里可能被静默掉,只在开发环境可见。所以「本地好好的、线上没动画」这种问题,优先回本地看控制台,别急着怀疑构建配置。
6. 踩坑实录:九成问题都出在这几个地方
这一节是我这些年被问得最多、自己也踩得最多的几类问题,按出现频率排序,每条都给出定位方法。你可以把它当成一张故障速查表。
6.1 动画完全不触发
先排除最简单的三种:name拼错、CSS 没被加载、key没加。前两个好查,DevTools 里切一次路由,盯着元素面板看类名有没有闪一下fade-transform-enter-active,闪了说明 JS 侧正常,问题在 CSS;没闪说明 transition 根本没识别到元素变化,去查 key。
第三个原因比较隐蔽:key 用了固定值。有人写成:key="$route.name",结果两个不同路由复用了同一个组件名(比如都叫Index),key 不变,动画不触发。还有人把 key 写在<router-view>的父元素上,那也是无效的,key 必须落在 router-view 或它渲染出的组件上。
第四个原因:嵌套过深导致选择器失效。如果 CSS 用了scoped且写成.app-main .fade-transform-enter { ... }这种后代选择器,而过渡类名恰好挂在.app-main的直接子元素上,理论上能命中;但如果中间隔了keep-alive渲染出来的注释节点或者别的包装,某些写法会失配。稳妥做法是过渡类名不加任何前缀,平铺写。
6.2 切换时白屏或整页闪烁
白屏一般不是动画本身的问题,而是「旧页走了、新页还没准备好」。路由懒加载时,新页面的 chunk 需要时间下载,out-in模式会老老实实等旧页退场完毕再挂新页,如果这时 chunk 还在路上,中间就是纯空白。
处理办法是预加载。现代打包工具支持给路由加魔法注释/* webpackPrefetch: true */,让浏览器空闲时提前拉取;或者用router.beforeEach里配合 loading 状态,在 chunk 加载期间保留旧页面不销毁——但后者实现复杂,容易引出别的问题。
更常见的闪烁来源是mode没写,两页叠加时背景色不同的部分互相透出,看起来像闪。还有一种:页面根元素有background: #fff,而内容区是灰色,动画期间 opacity 变化会让白色背景半透明地压上来,视觉上就是「白闪一下」。修法是把背景色提到.app-main上,页面组件自身不再设背景。
6.3 子路由切换没有动画
这个前面提过,原因是父级 router-view 的 key 没变,Vue 复用了父组件,子 router-view 的变化发生在组件内部,外层的 transition 看不到。
两种解法:一是把 key 换成$route.fullPath,让任何路由变化都触发重建(副作用是 query 变化也动画);二是在内层子 router-view 上自己包 transition,外层保持$route.matched[0].path。我更倾向第二种,因为它把「一级切换」和「二级切换」的动效分开控制,节奏更合理,也避免了翻页触发整屏动画的尴尬。
6.4 keep-alive 缓存失效或动画丢失
症状是:从详情返回列表,列表的筛选条件没了,或者回来了但动画不播。
缓存失效的排查顺序:先看include里的名字和组件name是否一致(大小写敏感);再看 keep-alive 是否被放在了 transition 内部(放外面会导致缓存的是过渡层而不是页面);最后看key是否包含了会变化的部分——如果 key 里带了时间戳之类的动态值,每次都是新 key,缓存自然命中不了。
动画丢失通常是顺序问题。<transition>包<keep-alive>,<keep-alive>里放<component>或<router-view>,这个嵌套链不能乱。乱一层,你就要花半小时去猜到底哪一环失效了。
6.5 一张表定位大部分症状
| 症状 | 最可能原因 | 快速验证 |
|---|---|---|
| 完全无动画 | name 与 CSS 类名不匹配 | 看元素类名是否有短暂变化 |
| 只有进场没退场 | 缺leave-to或mode未设 | 检查 CSS 是否写了 leave 系列 |
| 两页叠在一起 | 未设mode="out-in" | 补上 mode 再试 |
| 动画结束位置偏移 | 残留 transform 或包含块问题 | 检查 fixed 子元素位置 |
| 高度抖动、滚动条闪 | 缺 min-height 或 overflow | 造长短两页对比测试 |
| 只在首次进入有动画 | key 固定,未跟随路由 | 打印 key 值确认变化 |
| 生产环境无动画 | 多根节点被静默跳过 | 回开发环境看控制台警告 |
| 动画卡顿掉帧 | 用了 left/margin 做位移 | Rendering 面板看重绘区域 |
7. 性能、体验与降级:让动画不拖后腿
动画这东西,做得好是加分项,做不好就是「每次点菜单都想砸键盘」。最后一节聊点工程层面的收尾工作,这些内容文档里基本不会写,但实际项目里一定会遇到。
7.1 别让动画吃掉主线程
路由动画期间,Vue 正在做的事其实不少:卸载旧组件(触发beforeDestroy、清理定时器、销毁图表实例)、挂载新组件(初始化数据、发请求、渲染列表)。如果新页面第一屏就要渲染几百行表格或者一个 ECharts 实例,动画的 0.25 秒里主线程是满的,CSS 过渡会被挤掉帧。
几个缓解手段:把动画时长压到 0.2 秒以内,缩短冲突窗口;把重页面的初始化数据放到nextTick或requestIdleCallback里延后;图表类组件在mounted里加一个几十毫秒的延迟再init,避开动画高峰。这些都是「土办法」,但实测有效,比上虚拟列表改造便宜得多。
另一个容易忽略的点是will-change。给.app-main加will-change: transform能提前提升合成层,动画更顺,但常驻着会白占显存,页面多了反而更卡。正确用法是只在动画期间加:
.fade-transform-enter-active, .fade-transform-leave-active { will-change: transform, opacity; }过渡结束时类名被移除,will-change自动失效,干净利落。
7.2 长列表页切换的滚动位置处理
out-in模式下,旧页面被移除时页面高度归零,浏览器会把滚动位置重置到顶部。新页面进来后如果它自己有滚动位置(比如从详情返回列表,想回到原来那行),你会发现滚动条「先跳顶、再跳回」,中间闪一下。
这个问题的标准解法是给 router-view 外层配置滚动行为:
// vue-router 2/3 scrollBehavior(to, from, savedPosition) { if (savedPosition) return savedPosition return { x: 0, y: 0 } }配合 keep-alive 缓存,返回时savedPosition会带上历史滚动位置。但要注意:滚动容器必须是window或者被 vue-router 识别的主滚动区,如果你的布局是内容区自己overflow: auto的独立滚动容器,scrollBehavior管不到,需要自己手动记录scrollTop并恢复。Vue Element Admin 默认用的是独立容器,所以很多人反馈「返回没回到原位」,根子在这。
7.3 低端设备和减少动效偏好
后台系统经常被要求跑在会议室的老电脑、甚至国产化终端上,这些设备掉帧会更明显。一个务实的降级方案是检测prefers-reduced-motion,用户在系统层面开了「减少动态效果」时直接关掉位移动画:
@media (prefers-reduced-motion: reduce) { .fade-transform-enter-active, .fade-transform-leave-active { transition-duration: 0.01ms !important; } .fade-transform-enter, .fade-transform-leave-to { transform: none; } }这个媒体查询不只是照顾晕动症用户,也是给老旧设备兜底。写起来只要几行,但体现了对使用者的尊重,很多团队会漏掉。
同类的还有forced-colors高对比度模式,动画期间的透明度变化在那种模式下可能让文字消失。这个场景比较极端,知道有这回事就行。
7.4 一套我用了很久的参数模板
最后把参数整理成一份可直接抄的模板,覆盖 90% 的后台场景。你可以按自己项目的实际观感微调,但结构不用动。
.app-main { --enter-duration: 0.24s; --leave-duration: 0.16s; --shift: 18px; --enter-curve: cubic-bezier(0.16, 1, 0.3, 1); --leave-curve: cubic-bezier(0.4, 0, 1, 1); } .fade-transform-enter-active { transition: opacity var(--enter-duration) var(--enter-curve), transform var(--enter-duration) var(--enter-curve); will-change: transform, opacity; } .fade-transform-leave-active { transition: opacity var(--leave-duration) var(--leave-curve), transform var(--leave-duration) var(--leave-curve); will-change: transform, opacity; } .fade-transform-enter { opacity: 0; transform: translateX(calc(var(--shift) * -1)); } .fade-transform-leave-to { opacity: 0; transform: translateX(var(--shift)); }进场 0.24s、退场 0.16s 的不对称设计,是我试下来最舒服的一档:离场快一点,让用户觉得「响应及时」;进场稍慢一点,让内容有落定的过程。如果两边都设成一样长,要么显得拖,要么显得急,中间那个甜点区很难靠单边时长拿捏。不对称是个取巧但有效的路子,你可以先在两个页面之间来回切二十次,感受一下节奏,再决定往上调还是往下调。
另外提醒一句,别在动画里加rotate、scale这种复合变换。初看很炫,用一天就会烦,后台系统的动效原则是「感知得到但记不住」,任何让人注意到「这里有个动画」的设计,都是过度设计。
我个人在好几个项目里用的都是这套参数,中途试过换成纯淡入、换成位移加缩放、换成 Material 那套 shared axis,最后都改回来了。不是因为别的方案不好,而是后台这种高频切换的场景,动效越克制越耐用。真正需要花心思的地方不在动画本身,而在那些「切完页面状态丢了」「返回没回到原来的位置」「弹窗被动画压住」的细节上,把那些理顺了,动画自然就顺了。