有关 Vue3 路由过渡动效的实践

前言

在用 Tauri 写 App 时发现与其开多窗口,不如单页面路由,同时为了体现各页面间的跳转,选择通过路由的过渡动效特性体现。在实践过程中查阅了不少资料,由于选择了 Vue3 + Typescript ,踩了不少的坑,在此稍作记录,希望能对你有所帮助。

设定布局为十字形,如下图所示。示例 Repo 地址 dynamic-router

布局示意图

官方文档搭建

根据官方文档 基于路由的动态过渡 进行设置,让我们先来实现两个界面间简单的左右平移过渡。具体代码可以查看 dynamic-router/two-page

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// ./assets/style/RouterTransition.less
.route-slide-in-left-enter-active,
.route-slide-in-left-leave-active {
transition: all 0.85s ease-in-out;
}

.route-slide-in-left-enter-to {
position: absolute;
left: 0%;
top: 0;
}

.route-slide-in-left-enter-from {
position: absolute;
left: -100%;
top: 0;
}

.route-slide-in-left-leave-to {
opacity: 0;
}

.route-slide-in-left-leave-from {
opacity: 1;
}

.route-slide-out-left-enter-active,
.route-slide-out-left-leave-active {
transition: all 0.85s ease-in-out;
}

.route-slide-out-left-enter-to {
opacity: 1;
}

.route-slide-out-left-enter-from {
opacity: 0;
}

.route-slide-out-left-leave-to {
position: absolute;
left: -100%;
top: 0;
}

.route-slide-out-left-leave-from {
position: absolute;
left: 0;
top: 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// App.vue
<!-- App.vue -->
<script setup lang="ts">
const build_router_transitionname = (name: any): string => {
return (name === undefined ? '' : name)
}
</script>

<template>
<router-view v-slot="{ Component, route }">
<transition :name="build_router_transitionname(route.meta.transitionName)">
<component :is="Component" />
</transition>
</router-view>
</template>

<style lang="less">
@import "./assets/style/reset.css";
@import "./assets/style/RouteTransition.less";

html, body {
overflow: hidden;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// router/index.ts
import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router";
import MainPage from '../views/MainPage.vue';
import LeftPage from '../views/LeftPage.vue';

const routes: RouteRecordRaw[] = [
{
path: '/',
component: MainPage
},
{
path: '/left',
component: LeftPage
}
]

const router = createRouter({
history: createWebHashHistory(),
routes,
})

router.afterEach((to, from) => {
const toDepth = to.path.split('/').length
const fromDepth = from.path.split('/').length

if (to.path == '/left') {
to.meta.transitionName = "route-slide-in-left";
} else if (to.path == '/' && from.path == '/left') {
to.meta.transitionName = "route-slide-out-left";
}
})

export default router;

但是运行 pnpm run dev 后发现并没有出现预期的过渡效果,这是为什么呢?

细心的同学应该已经发现了,router 实际用到的 transition 是以 router.transition 来决定的,但实际赋值的却是 router.transitionname,正是这两者的不一致导致了过渡效果的失效。英文文档并不受此影响,已提交 pr,所以平时还是应该多读读英文文档。

transition 统一为 router.transitonname, 现在我们就可以看到两个界面平滑的过渡了。

双页过渡.gif

双页过渡.gif

让我们再添加一点点细节,就可以实现十字形界面的平滑过渡了。具体代码可以查看 dynamic-router/all-page

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// router/index.ts
import { createRouter, createWebHashHistory, RouteRecordRaw } from "vue-router";
import MainPage from '../views/MainPage.vue';
import LeftPage from '../views/LeftPage.vue';
import RightPage from '../views/RightPage.vue';
import UpPage from '../views/UpPage.vue';
import DownPage from '../views/DownPage.vue';

const routes: RouteRecordRaw[] = [
{
path: '/',
component: MainPage
},
{
path: '/left',
component: LeftPage
},
{
path: '/right',
component: RightPage
},
{
path: '/up',
component: UpPage
},
{
path: '/down',
component: DownPage
},
]

const router = createRouter({
history: createWebHashHistory(),
routes,
})

router.afterEach((to, from) => {
if (to.path == '/right') {
to.meta.transitionName = "route-slide-in-right";
} else if (to.path == '/left') {
to.meta.transitionName = "route-slide-in-left";
} else if (to.path == '/up') {
to.meta.transitionName = "route-slide-in-up";
} else if (to.path == '/down') {
to.meta.transitionName = "route-slide-in-down";
} else if (to.path == '/' && from.path == '/right') {
to.meta.transitionName = "route-slide-out-right";
} else if (to.path == '/' && from.path == '/left') {
to.meta.transitionName = "route-slide-out-left";
} else if (to.path == '/' && from.path == '/up') {
to.meta.transitionName = "route-slide-out-up";
} else if (to.path == '/' && from.path == '/down') {
to.meta.transitionName = "route-slide-out-down";
}
})

export default router;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<!-- views/MainPage.vue -->
<script setup lang="ts">
import router from '../router'

const jumpTo = (to: any) => {
router.push({ path: to })
}
</script>

<template lang="">
<div class="container">
<div id="title" class="title">
This is the Main Page
</div>

<div class="action">
<el-button type="primary" round @click="jumpTo('/left')">
to Left
</el-button>
<el-button type="primary" round @click="jumpTo('/right')">
to Right
</el-button>
<el-button type="primary" round @click="jumpTo('/up')">
to Up
</el-button>
<el-button type="primary" round @click="jumpTo('/down')">
to Down
</el-button>
</div>
</div>
</template>

<style lang="less" scoped>

.container {
position: relative;
width: 100%;
height: 100vh;
background-color: #e6f7ff;
}

.title {
padding-top: 20px;
text-align: center;
}

.action {
margin-top: 20px;
text-align: center;
}
</style>

使用第三方库

动画效果全部手写相信大部分人是不愿意的,简单好用的第三方库当然备受青睐,我们的原则是 偷懒! 提升效率!

在这里我们选择 Animate.css 作为动画库,相信有了它,一定能大大提升开发效率吧!

现在让我们重新添加一点点细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// router/index.ts
...
router.afterEach((to, from) => {
if (to.path == '/right') {
to.meta.transitionName = "animate__animated animate__slideInRight";
} else if (to.path == '/left') {
to.meta.transitionName = "animate__animated animate__slideInLeft";
} else if (to.path == '/up') {
to.meta.transitionName = "animate__animated animate__slideInDown";
} else if (to.path == '/down') {
to.meta.transitionName = "animate__animated animate__slideInUp";
} else if (to.path == '/' && from.path == '/right') {
to.meta.transitionName = "animate__animated animate__slideOutRight";
} else if (to.path == '/' && from.path == '/left') {
to.meta.transitionName = "animate__animated animate__slideOutLeft";
} else if (to.path == '/' && from.path == '/up') {
to.meta.transitionName = "animate__animated animate__slideOutUp";
} else if (to.path == '/' && from.path == '/down') {
to.meta.transitionName = "animate__animated animate__slideOutDown";
}
})
...

然后意外的发现动画的运行时间是跑出来了,动画没出来,why?

可不可能是 animate.css 本身失效了?让我们添加给 title 添加个动效做下测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// views/MainPage.vue
<template lang="">
...
<div class="action">
...
<el-button type="primary" round @click="toggleAnime">
Anime!
</el-button>
</div>
</template>

<script setup lang="ts">
...
const toggleAnime = () => {
let ele_title = document.getElementById('title');
if (ele_title === null) {
alert("No such element");
} else {
ele_title.setAttribute('class', 'title animate__animated animate__bounce')
setTimeout(() => {
if (ele_title != null) {
ele_title.setAttribute('class', 'title')
}
}, 800);
}
}
</script>

发现一切正常,说明 animate.css 本身并没有问题。

标题动画

那会不会是像最初的那个 transition 和 transitionName 对不上类似,不是赋像 animate__animated animate__bounce 这样的值,稍加试验我们也可以发现并不是这样,并且观察 vue-devtools 也可以观察到 router 的 transition name 是对应正确的,那会是什么原因呢?

万变不离其宗,不如我们去看看 vue 的 transition。

翻看文档我们可以发现,transiton 的 name 其实并不是实际生效的 css,vue 会对其进行自动处理添加尾缀,如 enter-active, enter-from 等,由于之前我们都是手打的,补齐了这部分尾缀,因此实现了动画效果,那我们是不是可以合理怀疑 animate.css 不符合这部分的要求,所以才导致了动画效果并未触发。好像很有道理的样子,正好上面我们对 title 进行了点小改动,让我们再对它下下手。

1
2
3
4
5
6
7
8
9
<template>
...
<transition name="animate__animated animate__bounce">
<div id="title" class="title">
This is the Main Page
</div>
</transition>
...
</template>

发现的确,标题现在失去了动画效果,按照之前的猜想,我们现在给它手动指定好动画效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
...
<transition name="custom"
enter-active-class="animate__animated animate__tada"
leave-active-class="animate__animated animate__bounceOutRight">
<div id="title" class="title" v-show="showTitle">
This is the Main Page
</div>
</transition>
...
</template>

<script setup lang="ts">
...
const showTitle = ref(false);

const toggleAnime = () => {
showTitle.value = !showTitle.value;
}
</script>

终于,标题重新动起来了!说明我们的猜想是没有错的,也和文档中的 Custom Transition Classes 一致,说明就应该这么做。

那最后当然就是重新添加”细节”了,由于各种原因,并不能百分百达到手写的效果,相较起来也没有方便多少。具体代码可以查看 dynamic-router/animated

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- App.vue -->
<script setup lang="ts">
const build_router_transitionname = (name: any): string => {
return (name === undefined ? '' : name)
}
</script>

<template>
<router-view v-slot="{ Component, route }">
<transition
:name="route.path"
:enter-to-class="`animate__animated ${route.meta.transitionEnterFrom}`"
:leave-to-class="`animate__animated ${route.meta.transitionLeaveTo}`">
<component :is="Component" />
</transition>
</router-view>
</template>

<style lang="less">
@import "./assets/style/reset.css";
@import "./assets/style/RouteTransition.less";

html, body {
overflow: hidden;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// router/index.ts
router.afterEach((to, from) => {
if (to.path == '/right') {
to.meta.transitionEnterFrom = "animate__slideInRight";
to.meta.transitionLeaveTo = "animate__slideOutLeft";
} else if (to.path == '/left') {
to.meta.transitionEnterFrom = "animate__slideInLeft";
to.meta.transitionLeaveTo = "animate__slideOutRight";
} else if (to.path == '/up') {
to.meta.transitionEnterFrom = "animate__slideInUp";
to.meta.transitionLeaveTo = "animate__slideOutDown";
} else if (to.path == '/down') {
to.meta.transitionEnterFrom = "animate__slideInDown";
to.meta.transitionLeaveTo = "animate__slideOutUp";
} else if (to.path == '/' && from.path == '/right') {
to.meta.transitionEnterFrom = "animate__slideInLeft";
to.meta.transitionLeaveTo = "animate__slideOutRight";
} else if (to.path == '/' && from.path == '/left') {
to.meta.transitionEnterFrom = "animate__slideInRight";
to.meta.transitionLeaveTo = "animate__slideOutLeft";
} else if (to.path == '/' && from.path == '/up') {
to.meta.transitionEnterFrom = "animate__slideInDown";
to.meta.transitionLeaveTo = "animate__slideOutUp";
} else if (to.path == '/' && from.path == '/down') {
to.meta.transitionEnterFrom = "animate__slideInUp";
to.meta.transitionLeaveTo = "animate__slideOutDown";
}
})

好像会抖?还会卡?!

看到小标题先不要怕,如果你使用的就是我上面写的代码,其实是不会遇到这个问题的。但往往动画效果会根据实际情况而有所不同,这个时候页面就可能出现抖动或者卡的现象了。由于这部分的效果 gif 图无法展示,想要直观观察的话请 clone dynamic-router/shake 分支代码,并在本地运行。

实际的抖动很容易触发,而我们实际上修改的地方只有一处,即 RouteTransition.less 中的动画效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ./assets/style/RouterTransition.less
// before
.route-slide-in-left-leave-to {
opacity: 0;
}

.route-slide-in-left-leave-from {
opacity: 1;
}

// after
.route-slide-in-left-enter-to {
position: absolute;
left: 0%;
top: 0;
}

.route-slide-in-left-enter-from {
position: absolute;
left: -100%;
top: 0;
}

相信反应快的同学已经知道原因了。没错,就是因为元素占位问题:在 Left Page 的进入动画执行到最终的时候,由于我们并没有对 Main Page 设置执行动画,其元素始终在文档中保持着相应位置,最终路由完成过渡又要求它一下子消失,因此导致了抖动的问题。

那应该怎么样改动让他不抖动呢?很简单,让元素不占位即可。我们可以通过修改过渡效果,如设置 opacity:0display:none 来让元素不占位。当然也应该还有其他方法可以做到,欢迎补充。

好像还是有点问题

确实还是有点问题,不知道你能不能想到。

现在让我们来假设这样一个场景,Mainpage 为 100vh,是个简单的展示界面,Leftpage 或者 Rightpage 是个表单界面,超过 100vh, 此时触发路由过渡动画后将会发生什么事情?让我们来做个简单的实验吧。具体代码请 clone dynamic-router/height 分支代码,并在本地运行。

当我们前往 Left Page,下拉至页面最下端,点击最下面的跳转按钮。

我们可以看到 Mainpage 最后被强行拉长了,留了一段空白用以匹配不同页面间缺少的长度。很明显,这是我们不想要的,而原因也很简单,就是不同页面间长度不匹配造成的。那么怎么解决呢?

  1. 所有页面都添加一个最外层的container,高度应填充满整个页面,同时所有组件需被包含于该 container,不允许 absolute 等定位
  2. 在路由过渡时自动滚动至最顶层

很明显,方案一限制性很大,方案二可行,但额外的动画效果不一定是我们想要的,具体选择哪个,就看实际需要了。当然,作者相信肯定还有更好的方案来解决这一问题,欢迎补充!


有关 Vue3 路由过渡动效的实践
http://example.com/2022/10/19/有关-Vue3-路由过渡动效的实践/
作者
Steins Gu
发布于
2022年10月19日
许可协议