1. 业务需求
当一个APP的页面切换,没有动画效果时就会显得很突兀,对用户来说交互性欠佳;
如上图所示,而且在每次返回首页之后,都会重复请求商品数据,同时上一次浏览到的位置没有记录,如此即浪费网络资源,也导致用户体验不佳。
2. 明确目标
整体实现方面分为两种:
- 过渡动画:利用 过渡动效 实现
- 组件缓存:虚拟任务栈 - 数组 配合
keep-alive
中的include
实现
2.1. 过渡动画
需要使用到 过渡动效 这个功能,它描述了两个路由之间进行过渡时的动画效果。
通过利用内置组件<Transition>
,制作基于状态变化的过渡和动画。
JS
<!-- 路由出口 -->
<router-view v-slot="{ Component }">
<!-- 动画 -->
<transition :name="transitionName">
<!-- 动态组件 -->
<component :is="Component" />
</transition>
</router-view>
2.2. 组件缓存 + 虚拟任务栈
组件缓存: 通过内置组件<KeepAlive>
实现。但是,不是所有的组件都需要被缓存。 当组件进入时,我们要缓存,当组件退出时,我们就不需要缓存了。
虚拟任务栈: 通过用 数组 来实现这一流程,当进入组件router.push()
时,把组件名push
,当退出组件router.back()
时,把组件名pop
,在通过 keep-alive
中的 include
有条件性的缓存组件名构成的数组。
JS
<!-- 路由出口 -->
<router-view v-slot="{ Component }">
<!-- 动画组件 -->
<transition
:name="transitionName"
>
<!-- 缓存组件-->
<keep-alive :include="virtualTaskStack">
<!-- 动态组件 -->
<component
:is="Component"
:key="$route.fullPath" // 同域名下的跳转。比如(动态路由 /detail/:id)
/>
</keep-alive>
</transition>
</router-view>
3. 代码实现
3.1. 封装组件 transition-router-view
- 定义组件接收参数:
-
- 路由跳转类型:[ push, back, none ]
- 页面栈中必须存在至少一个页面,作为起始页。
JS
<script>
// 无需监听路由的各种状态(在 PC 端下)
const NONE = 'none'
// 路由进入
const PUSH = 'push'
// 路由退出
const BACK = 'back'
// 路由跳转的 enum
const ROUTER_TYPE_ENUM = [NONE, PUSH, BACK]
</script>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const props = defineProps({
// 路由跳转的类型,对应 ROUTER_TYPE_ENUM
routerType: {
type: String,
default: NONE,
validator(val) {
const result = ROUTER_TYPE_ENUM.includes(val)
if (!result) {
throw new Error(`你的 routerType 必须是 ${ROUTER_TYPE_ENUM.join('、')} 中的一个`)
}
return result
}
},
// 首页的组件名称,对应任务栈中的第一个组件
mainComponentName: {
type: String,
required: true
}
})
</script>
- 创建如下
template
:
-
name
代表 transition 组件使用的动画样式,根据路由跳转类型指定key
路由只有参数变化时,会复用组件。让组件实例不复用,强制销毁重建
js
<template>
<!-- 路由出口 -->
<router-view v-slot="{ Component }">
<!-- 动画组件 -->
<transition :name="transitionName">
<!-- 缓存组件 -->
<keep-alive>
<component
:is="Component"
:key="$route.fullPath"
/>
</keep-alive>
</transition>
</router-view>
</template>
- 路由过渡动画样式
css
<style lang="scss" scoped>
// push页面时:新页面的进入动画
.push-enter-active {
animation-name: push-in;
animation-duration: 0.4s;
}
// push页面时:老页面的退出动画
.push-leave-active {
animation-name: push-out;
animation-duration: 0.4s;
}
// push页面时:新页面的进入动画
@keyframes push-in {
0% {
transform: translate(100%, 0);
}
100% {
transform: translate(0, 0);
}
}
// push页面时:老页面的退出动画
@keyframes push-out {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(-50%, 0);
}
}
// 后退页面时:即将展示的页面动画
.back-enter-active {
animation-name: back-in;
animation-duration: 0.4s;
}
// 后退页面时:后退的页面执行的动画
.back-leave-active {
animation-name: back-out;
animation-duration: 0.4s;
}
// 后退页面时:即将展示的页面动画
@keyframes back-in {
0% {
width: 100%;
transform: translate(-100%, 0);
}
100% {
width: 100%;
transform: translate(0, 0);
}
}
// 后退页面时:后退的页面执行的动画
@keyframes back-out {
0% {
width: 100%;
transform: translate(0, 0);
}
100% {
width: 100%;
transform: translate(50%, 0);
}
}
</style>
- 因为 routerType 不同状态下值不同,所有我们通过全局的状态来管理:
-
- 在
PC端
时:routerType
始终为NONE
- 在移动端时:
routerType
根据进入或退出的状态指定为push
或者back
- 在
js
export default {
namespaced: true,
state: () => ({
// 路由跳转类型
routerType: 'none'
}),
mutations: {
/**
* 修改 routerType
*/
changeRouterType(state, newType) {
state.routerType = newType
}
}
}
js
// 路由跳转方式
routerType: (state) => {
// 在 PC 端下,永远为 none
if (!isMobileTerminal.value) {
return 'none'
}
return state.app.routerType
}
- 在每次进行
router.push()
操作前,修改routerType
为push
store.commit('changeRouterType', 'push')
- 在执行
router.back()
操作前,修改routerType
为back
。
store.commit('changeRouterType', 'back')
但是需要注意两点:
-
- 不要再
libs
中修改,因为libs
为通用组件 - 不要再跳转到 首页 时添加该操作,因为首页为任务栈的 根
- 不要再
3.2. 处理过渡动效展示样式错误问题
- 使用
transition
提供的钩子函数,动态添加样式
js
<script setup>
...
// 处理动画状态变化
const isAnimation = ref(false)
const beforeEnter = () => {
isAnimation.value = true
}
const afterLeave = () => {
isAnimation.value = false
}
</script>
<template>
<transition :name="transitionName"
@before-enter="beforeEnter"
@after-leave="afterLeave"
>
<!-- 缓存组件 -->
<keep-alive >
<component
:is="Component"
:class="{ 'fixed top-0 left-0 w-screen z-50': isAnimation }"
:key="$route.fullPath"
/>
</keep-alive>
</transition>
</template>
3.3. 虚拟任务栈处理
- 监听路由变化,根据路由跳转类型,维护跳转动画和虚拟栈数组。
- 如果是首页,就清空栈,只保留主页面。
- 为所有的路由表和组件,指定对应的
name
,并保持相同。
js
<script setup>
// 跳转动画
const transitionName = ref('')
// 任务栈
const virtualTaskStack = ref([props.mainComponentName])
/**
* 监听路由变化
*/
router.beforeEach((to, from) => {
transitionName.value = props.routerType
if (props.routerType === PUSH) {
// 入栈
virtualTaskStack.value.push(to.name)
} else if (props.routerType === BACK) {
// 出栈
virtualTaskStack.value.pop()
}
// 进入首页默认清空栈
if (to.name === props.mainComponentName) {
clearTask()
}
})
/**
* 清空栈
*/
const clearTask = () => {
virtualTaskStack.value = [props.mainComponentName]
}
</script>
<template>
<keep-alive :include="virtualTaskStack">
...
</keep-alive>
</template>
- 调用组件
js
<transiton-router-view
:routerType="$store.getters.routerType"
mainComponentName="home"
></transiton-router-view>
3.4. 记录页面滚动位置
- 使用 useuse 提供的 useScroll API 获取当前容器滚动拒绝
- 当被缓存组件重新激活是,利用 onActivated 周期函数实现位置滚动
js
<script setup>
import { useScroll } from '@vueuse/core'
/**
* 记录页面滚动位置
*/
const containerTarget = ref(null)
const { y: containerTargetScrollY } = useScroll(containerTarget)
// 被缓存的组件再次可见,会回调 onActivated 方法
onActivated(() => {
if (!containerTarget.value) {
return
}
containerTarget.value.scrollTop = containerTargetScrollY.value
})
</script>
<template>
<div ref="containerTarget" />
</template>
踩雷:给元素设置scrollTop
不生效时
- 确保在获取该元素时,元素一定要有
height
- 确保该元素的
overflow
为auto
或者scroll