做移动端H5商城的时候,用户反馈最多的一句是:"切换页面感觉像闪过去的,没什么过渡,体验太硬了。"那时候我路由动画的代码还是直接在<transition>外面包一层<router-view>,结果动画完全不生效,页面依然是瞬切,查了半天才发现是 Vue Router 4 的渲染机制和我理解的不一样。后来把v-slot插槽路由的用法彻底捋了一遍,vue3 路由动画才算真正入了门。这篇文章我就把从实现原理到完整代码、再到动态方向和踩坑排查的整个过程写出来,希望能帮到正在跟路由动画较劲的朋友。
1. 先想清楚需求:路由动画到底图什么
1.1 路由动画解决的不是"好看",而是"感知连续"
SPA 的页面切换,本质上是 Vue 把旧组件销毁、把新组件挂载。这个过程不存在浏览器整页跳转的画面,所以页面看起来是"瞬间变了"。如果两页视觉风格差异大,人的视觉系统会因为缺少运动轨迹而觉得突兀,甚至产生"页面是不是崩了"的错觉。路由动画的核心作用,其实是给大脑补一条"视觉运动轨迹",用 200~300ms 的过渡时间让切换变得连续自然。
这一点在做移动端 H5 时体现得特别明显。移动端用户习惯的是原生 App 那种"点进去推入新页面、返回时滑出"的交互,如果 H5 没有对应的方向感,就算功能完全正常,用户也会觉得"不跟手"。而反过来,在后台管理系统里加一个大 slide 动画反而很怪,因为用户高频操作表格和树,动画会拖慢操作节奏。所以,动手之前先要回答一个问题:这个切换有没有"位置关系"或者"层级关系"需要表达?
1.2 哪些场景真的需要路由动画
根据我这个阶段的经验,比较适合加路由动画的场景有这么几类:
- 移动端 H5 页面栈切换:列表页到详情页、详情页到支付页,这类有明显的"前进/后退"方向,滑动转场最贴近原生体验。
- B 端引导感强的页面:比如表单分步骤、审批流流转、向导式配置,用淡入淡出或者轻微位移,可以让用户理解"进入下一个阶段"。
- 需要刻意制造仪式感:某些推广落地页、活动入口页,需要一个短促的缩放或者位移动画来吸引注意力。
相反的,下面这些场景我建议忍住别加:
- 高频数据刷新页:比如监控大屏、行情列表,动画会让数据密集页面的渲染压力变大,而且每次切换都延迟用户看到数据的时间。
- 低端设备上的复杂页面:动画期间新旧组件同时存在,内存和 GPU 开销会上升,低端机上反而容易卡成"幻灯片"。
- 无层级关系的平级 tab 切换:如果几个页面地位相同,用左右滑动会让用户搞不清谁进谁退,用淡入淡出已经足够了。
判断标准可以很简单:动画应该指向"位置关系"或"层级关系",而不是为了动而动。
2. 动画方案选型:内置 transition 还是上动效库
2.1 Vue 内置 transition 的边界在哪里
Vue 3 的<transition>组件是官方默认方案,它本身不负责"怎么动"——具体运动效果由 CSS 过渡类或 JS 钩子函数实现。它的真正价值在于把组件挂载/卸载的时机封装好了:进入时插入节点、离开时延迟移除节点,并且在不同阶段自动添加对应的类名。
这意味着绝大多数路由转场,根本不需要引入第三方动画库。淡入淡出、左右滑动、上下滑动、缩放、模糊、旋转,都可以用纯 CSS 解决。CSS 过渡的性能也足够好,尤其是只动transform和opacity时,GPU 合成器可以直接接管,帧率非常稳。
内置 transition 的另一个好处是生态兼容性。它和 Vue Router 的配合是官方在维护的,遇到问题搜一下基本都有答案。第三方库虽然效果更炫,但引入后你要处理它们的生命周期和 Vue 组件生命周期的同步问题,出 bug 时排查链路会长很多。
2.2 什么时候必须引入第三方库
我见过不少团队一上来就上 GSAP,结果只是做了一个淡入淡出,这就属于杀鸡用牛刀。真正需要第三方库的场景通常是:
- 需要弹簧物理效果,也就是带阻力和弹性的运动,纯 CSS 三次贝塞尔曲线模拟不了。
- 需要多个子元素联动,例如页面标题、内容卡片、底部按钮分别以不同延迟进入,内置 transition 只能在单个根节点上做整体过渡。
- 需要手势驱动,比如拖动页面边缘返回、滑动取消,这时动画的进度和手指位移绑定,CSS transition 完全控制不了进度。
如果需要这些能力,推荐 GSAP 或者 Motion One,它们都提供了比较完善的 JS 钩子写法。为了让你在选型时心里有数,我列一个简单对比:
| 维度 | Vue 内置 transition | GSAP | animate.css |
|---|---|---|---|
| 学习成本 | 低,懂 CSS 就会用 | 中高,需要理解时间轴和缓动 | 最低,加类名即可 |
| 能力边界 | 单节点进入/离开过渡 | 复杂时间线、物理弹簧、手势驱动 | 预置动画类,无复杂编排 |
| 体积 | 0 | 约 60KB+ | 约 20KB+ |
| 适合场景 | 大部分页面切换 | 高定制转场、交互动画 | 快速实现标准动效 |
| Vue 生态配合 | 官方原生支持 | 需要手动对接钩子 | 需要监听类名切换 |
我的建议很简单:默认用内置 transition,等需求明确超出能力边界再引库。这样项目不会背负不必要的依赖,动画代码也更容易被别人接手。
3. 核心原理拆解:router-view 为什么必须配合插槽使用
3.1 不要把 transition 直接包在 router-view 外层
很多 Vue 2 转 Vue 3 的同学第一次写路由动画都是这样的:
<transition name="fade"> <router-view /> </transition>这个写法在 Vue 3 + Vue Router 4 里很不稳定,大多数情况下不生效。原因是:<router-view>本身是一个动态组件,它确实会渲染匹配到的路由组件,但Vue Router 在更新时不会切换<router-view>这个组件实例本身。它内部会根据路由记录去更新子组件,而<transition>只能观察自己直接子组件的变化。子组件变了,但<transition>看到的"直接子节点"还是同一个<router-view>,所以它不知道这次更新是一次"切换",自然不会触发进入/离开过渡。
理解这个机制后,就知道正确做法必须是让<transition>的直接子节点变成"真正的路由页面组件"。Vue Router 4 提供了带插槽的<router-view>写法:
<router-view v-slot="{ Component }"> <transition name="fade" mode="out-in"> <component :is="Component" /> </transition> </router-view>这里v-slot解构出来的Component是当前路由匹配到的组件对象或异步组件包装器,<component :is="Component" />才是真正被渲染的页面组件。这样<transition>才能感知到"旧页面组件被卸载、新页面组件被挂载"这个变化。
3.2 key 到底放哪儿、要不要放
<transition>判断是否需要切换,关键是节点的 identity。如果两个不同的路由都渲染同一个组件,但组件本身没有变化,Vue 会认为节点可以复用,动画也不会触发。反过来,如果两个路由渲染完全不同的组件,Vue 也能区分,因为组件类型不同。
但实践中有个很常见的场景:同一个组件承载多个路由,比如一个详情组件服务/detail/1和/detail/2。这时组件类型完全相同,刷新数据靠的是route.params变化,如果不加 key,切换动画不会触发。这种情况下需要给<component :is="Component" :key="$route.path" />,让 Vue 把两个不同路径下的同一组件视为两个不同节点。
这里有一个很多新手踩过的坑:用$route.fullPath作为 key,虽然能解决组件复用问题,但fullPath包含 query 参数。比如详情页有一个?from=share的推广来源参数,用户从列表进入时from=share,返回再进入另一个详情时from=search,整页就因为 query 变化重新挂载并触发一次动画,非常突兀。所以我的习惯是:key 用$route.path,参数变化引发的组件刷新单独用watch处理。
3.3 transition 只认根节点,多个根组件怎么办
Vue 3 支持组件模板有多个根节点,但如果一个页面组件内部有多个根节点,<transition>会告警并直接跳过动画。原因是 transition 机制需要把过渡类挂到单个根节点上才能统一控制。遇到这种情况,最简单的办法是在页面组件最外层包一层带单一根节点的容器,或者用Fragment显式包裹。移动端页面我本来就建议每个路由页面保持单根容器,因为它同时承担布局、滚动容器、动画定位容器等职责。
4. 完整代码实现:从淡入淡出到滑动转场
4.1 基础淡入淡出:后台系的稳妥之选
先上一个最常用的淡入淡出。模板部分:
<template> <router-view v-slot="{ Component }"> <transition name="fade" mode="out-in"> <component :is="Component" :key="$route.path" /> </transition> </router-view> </template>样式部分,建议放在全局样式文件里,而不是组件<style scoped>中:
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; } .fade-enter-from { opacity: 0; } .fade-leave-to { opacity: 0; }这里mode="out-in"的语义是"旧组件先完成离开动画,新组件再进入"。为什么不省略?因为如果不设置 mode,新旧组件会同时出现在页面上。对于纯透明度动画,两者同时存在问题不大,但如果你在动画期间读取 DOM 高度、或者新旧页面都有固定定位元素,就可能出现闪烁和高度错乱。out-in虽然会让整个切换时长变长一倍,但换来的是稳定可控。
淡入淡出最适合后台管理页面,因为后台用户没有"前进后退"的心智模型,一个柔和的过程就足够了。有一点要注意:过渡类样式的生效依赖动画期间 Vue 动态添加的类名,动画结束后类会被移除。如果你把这些类写在带 scoped 的组件样式里,某些构建场景下选择器优先级可能和全局类冲突,排查起来很费劲,所以我统一放全局样式。
4.2 左右滑动转场:移动端页面栈的基础款
移动端 H5 的页面切换,最核心的就是"从右侧推进来,返回时从右侧滑出去"。这需要两个方向的过渡类同时存在,并且要为路由页面容器做定位处理。
模板部分:
<template> <router-view v-slot="{ Component }"> <transition :name="transitionName"> <component :is="Component" :key="$route.path" /> </transition> </router-view> </template>transitionName可以是一个动态值,后面章节会专门讲如何根据前进/后退切换方向。这里先提供slide-left和slide-right两套 CSS。以"前进时新页面从右侧推入、旧页面向左滑出"为例:
.slide-left-enter-active, .slide-left-leave-active, .slide-right-enter-active, .slide-right-leave-active { transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease; } /* 前进:新页面从右侧进入 */ .slide-left-enter-from { transform: translateX(100%); opacity: 0; } /* 前进:旧页面向左滑出,稍微带一点位移 */ .slide-left-leave-to { transform: translateX(-30%); opacity: 0; } /* 后退:新页面从左侧进入(实际上是返回时的目标页) */ .slide-right-enter-from { transform: translateX(-30%); opacity: 0; } /* 后退:旧页面从右侧滑出 */ .slide-right-leave-to { transform: translateX(100%); opacity: 0; }这里为什么前进时旧页面只滑到-30%而不是-100%?因为移动端页面栈切换的视觉逻辑是"新页面盖在旧页面上面",旧页面只要稍微偏移并降一点透明度,就能让用户感知到底下有内容。如果旧页面完全滑出屏幕,动画会显得太"空",而且用户很难理解"谁覆盖了谁"。
视觉上要达成"新旧两个页面同时在屏幕上"的效果,页面容器必须有定位基础。推荐给每个路由页面最外层容器加:
.page-container { position: fixed; inset: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; background: #fff; }不加定位会是怎样?两个页面组件在动画期间同时存在,文档流高度会变成两个页面高度之和,滚动条瞬间变长,页面内容上下跳动,动画结束后又突然缩回去。这种问题特别隐蔽,我一开始没加定位,动画看着确实是动了,但页面一直在"跳",调了半天才发现是文档流的问题。
4.3 缩放 + 透明度:适合浮层式页面
有些业务里会有"全屏弹窗式页面",比如协议确认、会员开通引导,它们不像是正常页面跳转,更像是一个浮层盖上来。这时的转场就可以用缩放:
.scale-fade-enter-active, .scale-fade-leave-active { transition: opacity 0.25s ease, transform 0.25s ease; } .scale-fade-enter-from { opacity: 0; transform: scale(0.92); } .scale-fade-leave-to { opacity: 0; transform: scale(0.96); }注意缩放动画里,enter-from的起始缩放值可以比leave-to更小,这样进入时更有"从中心展开"的感觉,离开时幅度小一点,视觉上更收敛。整页缩放的动画不适合正常的内容页,因为缩放过程里文字和图片都在变形,用户阅读体验很差。它更适合那些"不是真正导航,而是临时浮层"的页面。
5. 动态转场方向:让前进/后退拥有方向感
5.1 通过路由 meta 标记层级
移动端页面栈最常见的组合是"列表页→详情页→下单页"。这里的"前进/后退"不是 CSS 能自己判断的,必须由业务路由关系来决定。最简单可靠的做法是给路由配置加meta.depth:
{ path: '/list', name: 'List', component: () => import('@/views/List.vue'), meta: { depth: 1 } }, { path: '/detail/:id', name: 'Detail', component: () => import('@/views/Detail.vue'), meta: { depth: 2 } }然后在 App.vue 或一个专门的路由动画 composable 里监听路由变化:
import { ref, watch } from 'vue'; import { useRoute } from 'vue-router'; const route = useRoute(); const transitionName = ref(''); watch( () => route.path, (toPath, fromPath) => { const toDepth = route.meta.depth ?? 0; // 这里不能用 route.meta 取 from 的信息,需要缓存上一次的 depth } );更严谨的做法是使用 Vue Router 的afterEach全局钩子,它能同时拿到to和from:
router.afterEach((to, from) => { const toDepth = to.meta.depth ?? 0; const fromDepth = from.meta.depth ?? 0; if (toDepth > fromDepth) { transitionName.value = 'slide-left'; } else if (toDepth < fromDepth) { transitionName.value = 'slide-right'; } else { transitionName.value = 'fade'; } });transitionName需要是一个在模板中绑定的响应式变量。如果不想把状态散落在组件里,可以抽一个useRouteTransitioncomposable:
export function useRouteTransition(router) { const transitionName = ref(''); router.afterEach((to, from) => { const toDepth = to.meta.depth ?? 0; const fromDepth = from.meta.depth ?? 0; transitionName.value = toDepth > fromDepth ? 'slide-left' : toDepth < fromDepth ? 'slide-right' : 'fade'; }); return { transitionName }; }这个方案的核心是"人工维护层级关系"。它的优点是简单直观,但缺点也明显:如果项目里路由多、层级深,depth数字很容易维护错。详情页可能有三个入口,每个入口本身的层级就不一样,这时候统一设depth: 2会出问题。
5.2 基于 history.state 判断前进/后退
Vue Router 4 内部基于 History API 维护路由栈,每跳转一次,history.state都会变化。利用这一点,可以不依赖人工维护的 depth,而是判断当前路由在历史栈中是前进还是后退。
在实际项目中,我见过一个很实用的写法:在afterEach里对比to和from在路由记录中的索引,如果当前跳转后to的记录号比from大,就是前进;否则就是后退。但 Vue Router 4 没有直接暴露"记录号"给业务层,所以更常见的兼容方案是结合"页面名单"判断。
对于中小型项目,我仍推荐 meta depth 方案,因为它可读性最好,团队成员一看就懂。如果项目有严格的页面栈管理需求,再引入统一的路由栈封装,在push和back时显式传入方向,而不是事后猜测:
// 在某个基础跳转方法里 function navigateTo(route, { direction = 'forward' } = {}) { if (direction === 'back') { router.back(); } else { router.push(route); } }这种显式方向管理在实际业务中反而最不容易出错,因为前端页面栈和浏览器历史栈并不总是一一对应的,浏览器后退键也可能从详情页直接回到入口页,这时用"谁 deep 谁浅"来判断方向只是近似拟合,没有真正表达用户意图。
5.3 边界条件:直接访问 URL、刷新页面、tab 切换
方向判断有几个边界条件需要提前处理:
- 直接访问深链接:比如用户从外部链接进入
/detail/123,此时from是个空记录,from.depth为 0,动画方向会显示成"前进",这其实没问题。但如果项目要求首屏不播放动画,就需要加一个"首次渲染跳过动画"的标记,见后面踩坑章节。 - 刷新页面:刷新时没有路由变化,
afterEach不会执行,这个方向逻辑不会触发,页面只是原地重新加载,这是符合预期的。 - 同级 tab 切换:如果
to.depth === from.depth,我建议用fade或者干脆不设置动画,避免横向滑动造成"页面左右横跳"的错觉。 - 浏览器前进按钮:浏览器自带的前进/后退也会触发路由变化,使用 meta depth 对比依然有效。它不管用户是点了页面里的返回还是浏览器按钮,只看层级关系,这对大多数业务来说已经足够。
6. 实测总结:那些最容易踩的坑
6.1 首次进入页面动画"闪一下"
应用第一次加载时,根组件挂载会触发一次路由渲染,如果<transition>没有跳过首帧,用户会在打开页面的瞬间看到一个"从透明到实体"的闪烁过程。很多团队把这个问题误判为"动画卡顿",其实是首帧动画没有排除。
解决方式比较简单:在路由实例上标记一个"是否已经完成首次进入"的 Flag,首屏不设置动画名称:
const isFirstRender = ref(true); router.afterEach(() => { if (isFirstRender.value) { isFirstRender.value = false; } });模板中绑定:name时做判断:
<transition :name="isFirstRender ? '' : transitionName">注意:transition的 name 为空时,Vue 仍然会尝试处理,但没有对应的过渡类,效果等同于无动画。这个方案实测在首屏刷新和点击跳转两种场景下都正常。
6.2 异步路由组件导致的空白闪烁
路由组件使用() => import()懒加载时,组件资源还没有下载完成之前,router-view里的Component是 undefined,渲染结果就是空白。动画开始在组件到达之前,于是用户看到一个"先白屏再突然出现内容"的体验,这比没有动画还糟糕。
针对这个问题,我有几个经验:
- 进入页面前预加载目标组件:在用户点击"查看详情"之前,可以提前
import()目标路由组件,让浏览器预下载资源。 - 给页面容器加最小占位高度:如果无法预加载,确保页面在组件加载完成前有一个背景色和合理的最小高度,不要在动画启动阶段是空白。
- 使用 Suspense 要非常小心:
<transition>的直接子节点是<Suspense>时,过渡类会作用到 Suspense 的容器节点上,而不是真正的页面组件上。实测中,Suspense 内部多个异步组件切换时,有的状态会导致动画提前结束。所以我更倾向把异步加载交给路由层,配合预加载和 loading 路由组件处理,而不是在 transition 内部强行套 Suspense。
如果你项目里已经有自定义加载组件,可以给<component :is="Component">加一个v-if="Component",组件未就绪时不渲染,也避免空节点干扰动画。
6.3 滚动位置恢复和动画打架
SPA 的默认行为是切换路由后滚动条停在原来的位置。从长列表进入详情页,再返回列表页时,浏览器会把滚动位置恢复到之前的坐标,如果这个恢复发生在动画期间,页面会有明显"跳一下"。
Vue Router 4 的scrollBehavior可以恢复位置:
scrollBehavior(to, from, savedPosition) { return savedPosition || { top: 0 }; }问题是这个行为和动画是并行的,无法保证顺序。我的处理方式是:页面容器使用固定定位,滚动恢复交给容器内部逻辑管理,而不是依赖浏览器滚动条。移动端页面栈场景下,每个页面容器自己维护滚动,返回时可以在afterEnter里用scrollTo手动恢复,动画结束后的滚动就不会产生跳动感。
// 在 transition 钩子中处理 const onAfterEnter = () => { const container = document.querySelector('.page-container'); if (container) { container.scrollTo({ top: savedTopMap.value[route.path] || 0 }); } };如果不想做这么复杂,简单方案是切换动画期间不恢复滚动,动画结束后再通过nextTick设置滚动位置。至少能避免用户看着页面在运动中乱跳。
6.4 query 变化触发了整页动画
前面在 key 的部分提过,使用$route.fullPath作 key 会让 query 变化也触发动画。这个坑在移动端尤为常见,因为很多业务靠 query 传来源参数:/detail/1?from=share、/detail/1?from=banner。两个来源之间切换时,页面被强制重新挂载,用户会觉得"我没跳转,页面怎么闪了一下"。
我的建议是:key 固定用$route.path。如果业务上确实需要监听 query 变化并刷新数据,就在组件内部用watch(() => route.query, initData)控制,不要让路由动画承担数据刷新职责。这样动画只关心"页面路径是否变化",数据刷新由业务代码精确控制。
6.5 过渡类作用域问题
一个小而隐性的问题:过渡 CSS 写在使用了scoped的组件里时,Vue 会为过渡类加上><transition :css="false" @enter="handleEnter" @leave="handleLeave" > <component :is="Component" :key="$route.path" /> </transition>
handleEnter接收el和done两个参数,done是必须手动调用的,否则 Vue 会认为进入动画没有结束:
import gsap from 'gsap'; const handleEnter = (el, done) => { const title = el.querySelector('.page-title'); const content = el.querySelector('.page-content'); const tl = gsap.timeline({ onComplete: done }); tl.fromTo(el, { opacity: 0 }, { opacity: 1, duration: 0.2 }); tl.fromTo(title, { y: 20, opacity: 0 }, { y: 0, opacity: 1, duration: 0.2 }, '-=0.1'); tl.fromTo(content, { y: 30, opacity: 0 }, { y: 0, opacity: 1, duration: 0.25 }, '-=0.1'); tl.fromTo(el, { x: 0 }, { x: 0, duration: 0.1 }, '-=0.1'); };这里有一个对新手很关键的提醒:如果设置:css="false",Vue 不会添加 CSS 过渡类,所有动画效果都由 JS 控制。想保留 CSS 过渡又增加 JS 钩子也可以,但要注意别让两者操作同一个属性,否则会出现"两个动画打架"的问题。比如 CSS 里已经过渡了 opacity,JS 钩子里又用 gsap 改 opacity,最后效果会非常奇怪。
7.2 性能注意:首选 transform 和 opacity
路由动画最影响性能的不是动画本身,而是动画期间触发的浏览器 layout 和 paint。改变width、height、top、left、margin这些属性,每帧都要重新计算布局,移动端很容易掉帧。而transform和opacity可以交给 GPU 合成器处理,动画期间不触发 layout。
实际开发中我会有几条硬性要求:
- 位移动画一律用
transform: translateX(),不要用left定位。 - 缩放用
transform: scale(),不要用zoom或动态改width。 - 透明度用
opacity,不要用visibility来做渐入渐出。 will-change不要滥用,只对动画中的容器加,动画结束后移除,否则大量页面同时保留will-change: transform会占用 GPU 内存,反而更卡。
还有一个容易忽略的点:动画期间新旧两个页面组件都会渲染,如果这两个页面都比较重,比如有大量长列表或者图片,需要控制好同时存在的时长。mode="out-in"能保证只有一个页面在大部分时间存在,in-out则相反。移动端页面栈场景下,我们想让"新页面盖上去"的感觉更明显,一般用默认的同时过渡模式,但要确保旧页面容器使用了will-change: transform,这样叠加渲染时才不会卡。
7.3 动画持续时间的取舍
动画不是越长越好。移动端页面切换的黄金时间大约在 250~350ms,低于 200ms 会感觉"还没看到就结束了",没有留下运动轨迹;高于 400ms 用户会有"卡"或"拖沓"的感觉。后台页面可以稍微宽松一点,但一般也不要超过 300ms。
调试时我习惯先把持续时间设成 1000~1500ms,把方向逻辑、透明度变化、位移距离都看清楚,确认没问题后再改回 300ms。这样比直接看 300ms 的动画更容易判断"方向写反了"还是"位移距离不够"。
如果你现在准备在公司项目里加路由动画,我的建议是先不要碰 GSAP,也不要一开始就做花哨的编排。从一个"列表页→详情页"的滑动转场开始,把方向判断、key 管理、滚动恢复这几个基础问题解决,再考虑特效。动画这个事,最怕的就是"动的很炫但业务逻辑全被干扰"。等你跑通一套稳定的页面栈切换,后续加什么效果都只是 CSS 或钩子函数里补充几行的问题。我在实际项目里最满意的不是动画多炫,而是列表往返回的体验终于跟手了,用户不再感觉页面是"刷"出来的,而是一层一层推过去的。