目录
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 = itemisVisiblePins.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><divclass="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']"><!-- 左 --><divclass="h-full w-5 absolute left-0 flex items-center justify-center"@click="onClickLeft"><slot name="left"><m-svg-iconname="back"class="w-2 h-2"fillClass="fill-zinc-900 dark:fill-zinc-200"/></slot></div><!-- 中 --><divclass="h-full flex items-center justify-center m-auto font-bold text-base text-zinc-900 dark:text-zinc-200"><slot></slot></div><!-- 右 --><divclass="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><divclass="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-navbarv-if="isMobileTerminal"sticky@clickLeft="onPop"@clickRight="onPop">{{ pexelData.title }}<template #right><m-svg-iconname="share"class="w-3 h-3"fillClass="fill-zinc-900 dark:fill-zinc-200"></m-svg-icon></template></m-navbar><!-- pc 端下展示关闭图标 --><m-svg-iconv-elsename="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"><imgclass="w-screen mb-2 xl:w-3/5 xl:h-full xl:rounded-tl-lg xl:rounded-bl-lg":src="pexelData.photo"/><divclass="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-iconname="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-buttonclass=""type="info"icon="heart"iconClass="fill-zinc-900 dark:fill-zinc-200"/></div><!-- 标题 --><pclass="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"><imgv-lazyclass="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.jsexport 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.jsexport 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 这样的动画库,可以使我们的动画处理变的更加方便。
接下来我们将要处理 登录、注册,大家拭目以待吧。