移动端项目一般有两种:APP和H5。相比之下,APP由于有原生的支持,一些动效自带(例如路由切换),体验很丝滑,而H5则需要手动实现。
目前APP开发中,混合式开发是常态,即部分页面原生,部分使用WebView
内嵌H5页面。但因为在APP内使用,用户针对这些内嵌的页面,也会希望和APP一样丝滑。
例如路由切换时,我们会期望:
- 动画:像原生APP一样,有左右滑动的效果
- 缓存:页面内容缓存,记录之前页面的位置和数据状态,不再重复请求接口
为此就需要做一些特殊处理
任务栈
在Android中有一个任务栈的概念,它可以很好地记录用户在APP中的操作路径
按照官网上的说法,用户在使用电子邮件的App时,可能有如下操作
- 首页看到新邮件列表
- 进入列表页,看到邮件列表
- 用户选择某一条消息,进入消息的详情页面
- 看完消息,用户返回了邮件列表页
如果我们要能做到“记忆”,即记住那些用户已经访问过的页面,并在下次用户重新访问的时候,从用户上次使用的地方继续,则应该这样
- 缓存首页
- 缓存列表页
- 缓存用户查看的消息的详情页
- 展示缓存好的列表页,消息详情页的缓存清除
这样的一个记忆过程其实本质用了栈这个数据结构,后进先出,而页面也只有两种动作
- 入栈
push
:下钻子页面 - 出栈
pop
:返回父页面
路由切换实现
移动端路由切换主要包括两个部分
- 页面切换动画
- 页面缓存:参考Android的任务栈实现一个虚拟任务栈,对页面进行缓存
虚拟任务栈
为了能在Web项目中也能实现页面缓存,我们就需要考虑模拟Android的任务栈,称之为虚拟任务栈
虚拟任务栈要做的是
- 记录需要缓存的页面
- 页面切换的时候(下钻子页面和返回父页面时)展示切换动画
页面缓存需要记录上次用户浏览到的位置和数据,避免接口重复请求进入loading
状态
基于以上,我们需要创建一些动作,包括:入栈、出栈、清空栈
const NONE = 'none'
const ROUTER_TYPE_PUSH = 'push'
const ROUTER_TYPE_BACK = 'back'
const ROUTER_TYPE_ENUM = [NONE, ROUTER_TYPE_PUSH, ROUTER_TYPE_BACK]
此外需要注意的是,虚拟任务栈是有一个不用出栈的基础页面的,一般就是项目的首页,可以通过传参的方式固定下来
const props = defineProps({
// 首页组件名称
mainComponentName: {
type: String,
required: true
}
})
// 虚拟任务栈
const virtualTaskStack = ref([props.mainComponentName])
// 入栈
const pushTaskStack = (taskName: string) => {
virtualTaskStack.value.push(taskName)
}
// 出栈
const popTaskStack = () => {
virtualTaskStack.value.pop()
}
// 清空栈
const clearTaskStack = () => {
virtualTaskStack.value = [props.mainComponentName]
}
页面切换动画
在原生的APP里面,我们可以通过单指左右滑动页面,实现页面的切换,而且切换的时候是有动画效果的,即上一个页面在下个页面出来之前,会存在于当前页面侧面,有点像我们在翻书
Vue3中,可以结合router-view
和transition
实现页面的切换动画,transition
上的name
属性可以指定几个阶段的动画效果,这里主要添加进入和退出时active
状态的
- 进入时:新老页面从右往左
- 退出时:新老页面从左往右
另外注意,同名组件是不用有动画,也不用多次存在虚拟任务栈中的,所以需要给组件添加一个key
区分
<router-view v-slot="{ Component }">
<transition :name="transitionName">
<component :is="Component" :key="$route.fullPath" />
</transition>
</router-view>
// 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);
}
}
过渡动画的名字和页面下钻/回退相关,所以索性就和跳转方式绑定起来,跳转方式也作为一个入参传入,而跳转方式的改变可以使用局部路由守卫
const props = defineProps({
// 路由跳转类型
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
}
}
})
const router = useRouter()
// 跳转的动画
const transitionName = ref('')
// router前置路由守卫
router.beforeEach((to, from) => {
transitionName.value = props.routerType
if (props.routerType === ROUTER_TYPE_PUSH) {
// 入栈
pushTaskStack(to.name)
} else if (props.routerType === ROUTER_TYPE_BACK) {
// 出栈
popTaskStack()
}
if (to.name === props.mainComponentName) {
// 进入首页,清空栈
clearTaskStack()
}
})
踩坑点
页面切换的时候,老页面在新页面还没嵌入的时候会卡顿一下,核心是因为在新页面进入前,老页面还在文档流中
因此需要在组件进入前,让页面脱离文档流,等老页面离开了再恢复
<transition
:name="transitionName"
@before-enter="handleBeforeEnter"
@after-leave="handleAfterLeave"
>
<component
:is="Component"
:class="{ 'fixed top-0 left-0 z-50 w-screen': showComponentInline }"
:key="$route.fullPath"
/>
</transition>
const showComponentInline = ref(false)
const handleBeforeEnter = () => {
showComponentInline.value = true
}
const handleAfterLeave = () => {
showComponentInline.value = false
}
页面缓存
对于已经浏览过的页面,作为用户,期望的就是:让我从之前浏览的状态继续
对应到开发,要做的就是两个事情
- 记住用户上次浏览到的位置和数据
- 避免接口的重复请求
在Vue中,页面的缓存(数据)主要通过keep-alive
实现
<keep-alive>
<component :is="Component" />
</keep-alive>
keep-alive
一般是缓存所有页面的,如果想指定一些页面不缓存,要使用include
参数,里面传入要缓存的组件名称
<keep-alive :include="virtualTaskStack">
<component
:is="Component"
:class="{ 'fixed top-0 left-0 z-50 w-screen': showComponentInline }"
:key="$route.fullPath"
/>
</keep-alive>
页面滚动位置记忆
页面的滚动位置需要额外记忆一下,这里用到的是keep-alive
中的缓存实例生命周期
当缓存组件初次渲染/再次出现时,会调用onActivated
钩子
// 记录当前组件的滚动位置
const scrollContainerTarget = ref(null)
const { y: containerTargetScrollY } = useScroll(scrollContainerTarget)
// 缓存的组件再次出现的时候,会调用onActivated钩子
onActivated(() => {
if (!scrollContainerTarget.value) return
// 恢复滚动位置
scrollContainerTarget.value.scrollTop = containerTargetScrollY.value
})