目录
01: 前言
02: VueRouter 过渡动效可行性分析
03: 主动介入浏览器堆栈管理,分析可行性方案
04: 主动介入浏览器堆栈管理
05: 基于 GSAP 实现高阶路由过渡动画分析
06: 基于 GSAP 实现高阶路由过渡动画处理
10: 处理刷新空白问题
11: 总结
01: 前言
在 vue 中,两个路由进行跳转的时候,会为其增加一些跳转的过渡动画,这是一个非常常见的需求。通常情况下,这种过渡动画,我们可以使用 vue-router 的过渡动效 进行实现。
对于咱们的项目而言,当我们 item 中点击进入 详情页 的时候,我们也希望可以有一个对应的过渡动效。从而提升用户体验。
我们期望这个过渡动效可以:
1. 同时在 PC 端 和 移动端 生效。
2. 进入新页面时:在点击的具体 item 中呈现 由小到大的缩放动画。
3. 退出新页面时:呈现 由大到小的缩放动画 缩回至点击的具体 item 中。
这样的一个过渡动效,我们应该如何进行实现呢?
直接通过 vue-router 的过渡动效 可以实现吗?
如果不行的话,我们又应该如何去做呢?
02: VueRouter 过渡动效可行性分析
接下来我们要实现的是:item 到详情页的路由过渡动效。这样的一个过渡动效,我们如何去做呢?通常针对这种功能,我们首先都会想到 vue-router 提供的 过渡动效。
想要判断这个问题,我们首先需要搞明白 vue-router 的过渡动效的过渡机制是什么?然后再根据这个机制来分析可行性。
<!-- 使用动态过渡名称 -->
<router-view v-slot="{ Component, route }">
<transition :name="route.meta.transition">
<component :is="Component" />
</transition>
</router-view>
以上这段代码是实现过渡动效的关键代码。
其中涉及到了三个组件:
1. router-view:路由出口
2. transition:动画组件
3. component:动态组件
据此可以得知,vue-router 过渡动效产生的关键就是 transition 动画组件。
同时我们知道对于 transition 而言,它能够产生动画的关键,其实是其内部元素 component 的动态组件切换。
但是这样的切换,它一定是基于整个 页面组件的。也就是说如果我们利用这种过渡动效,一定是:从 home 页面到 detail 页面 的整体页面组件的切换。
所以:是 无法 实现咱们期望的这种路由过渡效果的。
03: 主动介入浏览器堆栈管理,分析可行性方案
根据上一小节的分析,我们知道通过 vue-router 的过渡动效是无法实现咱们期望的路由切换效果的。那么应该如何去做?
想要搞明白咱们的可行性方案,首先得先搞清楚 什么是路由跳转?所谓路由的跳转无非指的是两部分:
1. 浏览器的 url 发生了变化。
2. 浏览器中展示的页面组件发生了变化。
只要满足这两点,我们就认为 路径进行了跳转。
所以说,我们可不可以换个思路,我们 不去进行真实的路由跳转,而是 先修改浏览器的 URL,再切换展示的页面(以组件的形式覆盖整个浏览器可视区域)。这样对于用户而言,是不是就完成了整个路由的跳转工作。
这样我们的具体问题就变成了:
1. 如何让浏览器的 url 发生变化,但是不跳转页面。
2. 如何让一个新的组件以包含动画的形式进行展示。
想要完成第一个功能我们可以使用:History.pushState() 方法。而第二个功能我们可以使用 这个 GSAP 动画库进行实现。
04: 主动介入浏览器堆栈管理
// src/views/main/components/list/item.vue
<script setup>
const emits = defineEmits(['click'])
/*
* 进入详情页点击事件
*/
const onToPinsClick = () => {
emits('click', {
id: props.data.id
})
}
</script>
// src/views/main/components/list/index.vue
<template>
<itemVue @click="onToPins" />
</template>
<script setup>
/*
* 进入 pins
*/
const onToPins = (item) => {
history.pushState(null, null, `/pins/${item.id}`)
}
</script>
05: 基于 GSAP 实现高阶路由过渡动画分析
当 url 发生变化之后,我们接下来就只需要处理对应的动画就可以了。
动画的处理我们依赖于 GSAP 进行实现。对于 GSAP 而言,主要依赖两个方法:
1. gsap.set(): 这个方法通常使用在动画开始之前,表示设置动画开始前的元素属性。
2. gsap.to(): 这个方法表示 最终元素展示的状态。
GSAP 会基于 set 和 to 的状态,来自动执行中间的补间动画。
所以我们只需要:
1. 创建一个对应的组件,使用 transition 进行包裹。
2. 计算出 set 时,组件元素对应的样式属性。
3. 计算出 to 时,组件元素对应的样式属性。
然后就可以由 GSAP 自动实现对应的补间动画了。
06: 基于 GSAP 实现高阶路由过渡动画处理
- src/views
- - pins
- - - components
- - - - pins.vue
- - - index.vue
npm install --save gsap@3.9.1
// src/views/main/components/list/index.vue
<template>
<!-- 大图详情处理 -->
<transition
:css="false"
@before-enter="beforeEnter"
@enter="enter"
@leave="leave"
>
<pins-vue v-if="isVisiblePins" :id="currentPins.id" />
</transition>
</template>
<script setup>
// 控制 pins 展示
const isVisiblePins = ref(false)
// 当前选中的 pins 属性
const currentPins = ref({})
/**
* 进入 pins
*/
const onToPins = (item) => {
history.pushState(null, null, `/pins/${item.id}`)
currentPins.value = item
isVisiblePins.value = true
}
/**
* 监听浏览器后退按钮事件
*/
useEventListener(window, 'popstate', () => {
isVisiblePins.value = false
})
/**
* 进入动画开始前
*/
const beforeEnter = (el) => {
gsap.set(el, {
scaleX: 0,
scaleY: 0,
transformOrigin: '0 0',
translateX: currentPins.value.localtion?.translateX,
translateY: currentPins.value.localtion?.translateY,
opacity: 0
})
}
/**
* 进入动画执行中
*/
const enter = (el, done) => {
gsap.to(el, {
duration: 0.3,
scaleX: 1,
scaleY: 1,
opacity: 1,
translateX: 0,
translateY: 0,
onComplete: done
})
}
/**
* 离开动画执行中
*/
const leave = (el, done) => {
gsap.to(el, {
duration: 0.3,
scaleX: 0,
scaleY: 0,
x: currentPins.value.localtion?.translateX,
y: currentPins.value.localtion?.translateY,
opacity: 0
})
}
</script>
// src/views/main/components/list/item.vue
<template>
<div @click="onToPinsClick" />
</template>
<script setup>
/**
* 查看 vueuse 的源代码
*(https://github.com/vueuse/vueuse/blob/main/packages/core/useElementBounding/index.ts)
* 发现 useElementBounding 方法是仅在 window 的 scroll 时被触发,
* 所以在移动端状态下会导致 useElementBounding 的返回值不再具备响应性。从而计算失败。
* 所以我们可以修改 imgContainerCenter 为一个方法,
* 利用 el.getBoundingClientRect 方法获取动态的 x、y、width、height , 从而进行正确的计算。
*/
const imgContainerCenter = () => {
const {
x: imgContainerX,
y: imgContainerY,
width: imgContainerWidth,
height: imgContainerHeight
} = imgTarget.value.getBoundingClientRect()
return {
translateX: parseInt(imgContainerX + imgContainerWidth / 2),
translateY: parseInt(imgContainerY + imgContainerHeight / 2)
}
}
/**
* 进入详情点击事件
*/
const onToPinsClick = () => {
emits('click', {
id: props.data.id,
localtion: imgContainerCenter()
})
}
</script>
知识点讲解:
你可以通过监听 <Transition>
组件事件的方式在过渡过程中挂上钩子函数。
这些钩子可以与 CSS 过渡或动画结合使用,也可以单独使用。
在使用仅由 JavaScript 执行的动画时,最好是添加一个 :css="false"
prop。这显式地向 Vue 表明可以跳过对 CSS 过渡的自动探测。除了性能稍好一些之外,还可以防止 CSS 规则意外地干扰过渡效果。
在有了 :css="false"
后,我们就自己全权负责控制什么时候过渡结束了。这种情况下对于 @enter
和 @leave
钩子来说,回调函数 done
就是必须的。否则,钩子将被同步调用,过渡将立即完成。
07: 通用组件:navbar 构建方案分析
接下来我们就需要处理 pins 中对应的页面样式了。
pins 的页面样式同时可以应用到 pc端 和 移动端。而在 移动端 中,则会展示对应的 navbar 的内容,所以我们首先构建出 navbar 通用组件,然后基于 navbar 构建对应的 pins 样式。
对于 navbar 而言:
1. 它分为 左、中、右 三个大的部分,三个部分都可以通过插槽进行指定。
2. 左、右 两边的插槽可以自定义点击事件。
3. 同时 navbar 有时候会存在吸顶效果,所以我们最好还可以通过一个 prop 指定对应的吸顶展示。
分析完成之后,接下来实现对应的 navbar 构建。
08: 通用组件:构建 navbar
- src/libs
- - navbar
- - - index.vue
// src/libs/navbar/index.vue
<template>
<div
class="w-full h-5 border-b flex items-center z-10 bg-white dark:bg-zinc-800 border-b-zinc-200 dark:border-b-zinc-700"
:class="[sticky ? 'sticky top-0 left-0' : 'relative']"
>
<!-- 左 -->
<div
class="h-full w-5 absolute left-0 flex items-center justify-center"
@click="onClickLeft"
>
<slot name="left">
<m-svg-icon
name="back"
class="w-2 h-2"
fillClass="fill-zinc-900 dark:fill-zinc-200"
/>
</slot>
</div>
<!-- 中 -->
<div
class="h-full flex items-center justify-center m-auto font-bold text-base text-zinc-900 dark:text-zinc-200"
>
<slot></slot>
</div>
<!-- 右 -->
<div
class="h-full w-5 absolute right-0 flex items-center justify-center"
@click="onClickRight"
>
<slot name="right" />
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const props = defineProps({
clickLeft: {
type: Function
},
clickRight: {
type: Function
},
sticky: {
type: Boolean
}
})
const router = useRouter()
/**
* 左侧按钮点击事件
*/
const onClickLeft = () => {
if (props.clickLeft) {
props.clickLeft()
return
}
router.back()
}
/**
* 右侧按钮点击事件
*/
const onClickRight = () => {
if (props.clickRight) {
props.clickRight()
}
}
</script>
<style lang="scss" scoped></style>
09: 基于 navbar 处理响应式的 pins 页面
// src/views/pins/components/pins.vue
<template>
<div
class="fixed left-0 top-0 w-screen h-screen z-20 backdrop-blur-4xl bg-white dark:bg-zinc-800 pb-2 overflow-y-auto xl:p-2 xl:bg-transparent"
>
<!-- 移动端下展示 navbar -->
<m-navbar
v-if="isMobileTerminal"
sticky
@clickLeft="onPop"
@clickRight="onPop"
>
{{ pexelData.title }}
<template #right>
<m-svg-icon
name="share"
class="w-3 h-3"
fillClass="fill-zinc-900 dark:fill-zinc-200"
></m-svg-icon>
</template>
</m-navbar>
<!-- pc 端下展示关闭图标 -->
<m-svg-icon
v-else
name="close"
class="w-3 h-3 ml-1 p-0.5 cursor-pointer duration-200 rounded-sm hover:bg-zinc-100 absolute right-2 top-2"
fillClass="fill-zinc-400"
@click="onPop"
></m-svg-icon>
<div class="xl:w-[80%] xl:h-full xl:mx-auto xl:rounded-lg xl:flex">
<img
class="w-screen mb-2 xl:w-3/5 xl:h-full xl:rounded-tl-lg xl:rounded-bl-lg"
:src="pexelData.photo"
/>
<div
class="xl:w-2/5 xl:h-full xl:bg-white xl:dark:bg-zinc-900 xl:rounded-tr-lg xl:rounded-br-lg xl:p-3"
>
<div v-if="!isMobileTerminal" class="flex justify-between mb-2">
<m-svg-icon
name="share"
class="w-4 h-4 p-1 cursor-pointer hover:bg-zinc-200 dark:hover:bg-zinc-800 duration-300 rounded"
fillClass="fill-zinc-900 dark:fill-zinc-200"
></m-svg-icon>
<m-button
class=""
type="info"
icon="heart"
iconClass="fill-zinc-900 dark:fill-zinc-200"
/>
</div>
<!-- 标题 -->
<p
class="text-base text-zinc-900 dark:text-zinc-200 ml-1 font-bold xl:text-xl xl:mb-5"
>
{{ pexelData.title }}
</p>
<!-- 作者 -->
<div class="flex items-center mt-1 px-1">
<img
v-lazy
class="h-3 w-3 rounded-full"
:src="pexelData.avatar"
alt=""
/>
<span class="text-base text-zinc-900 dark:text-zinc-200 ml-1">{{
pexelData.author
}}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { getPexelsFromId } from '@/api/pexels'
import { isMobileTerminal } from '@/utils/flexible'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
const props = defineProps({
id: {
type: String,
required: true
}
})
const pexelData = ref({})
/**
* 获取详情数据
*/
const getPexelData = async () => {
const data = await getPexelsFromId(props.id)
pexelData.value = data
}
getPexelData()
/**
* 关闭按钮处理事件
*/
const router = useRouter()
const store = useStore()
const onPop = () => {
// 配置跳转方式
store.commit('app/changeRouterType', 'back')
router.back()
}
</script>
<style lang="scss" scoped></style>
10: 处理刷新空白问题
问题:例如 xx.xxx.xx/pins/5313576 这样的路径刷新浏览器时,会显示空白页面。
原因:项目中该路径未配置路由。
// src/views/pins/index.vue
<template>
<div class="w-full h-full bg-zinc-200 dark:bg-zinc-800">
<pins-vue :id="$route.params.id" />
</div>
</template>
<script setup>
import pinsVue from './components/pins.vue'
</script>
// src/router/modules/mobile-routes.js
export default [
{
path: '/',
name: 'home',
component: () => import('@/views/main/index.vue')
},
{
path: '/pins/:id',
name: 'pins',
component: () => import('@/views/pins/index.vue')
}
]
// src/router/modules/pc-routes.js
export default [
{
path: '/',
name: 'main',
component: () => import('@/views/layout/index.vue'),
children: [
{
path: '',
name: 'home',
component: () => import('@/views/main/index.vue')
},
{
path: '/pins/:id',
name: 'pins',
component: () => import('@/views/pins/index.vue')
}
]
}
]
11: 总结
本篇文章中咱们处理了详情页面,在详情页面的处理中,我们通过另外一种方式完成了 路由的过渡行为。同时也接触到了 GSAP 这样的动画库,可以使我们的动画处理变的更加方便。
接下来我们将要处理 登录、注册,大家拭目以待吧。