中前台解决方案2——移动端路由切换

361 阅读4分钟

移动端项目一般有两种:APP和H5。相比之下,APP由于有原生的支持,一些动效自带(例如路由切换),体验很丝滑,而H5则需要手动实现。

目前APP开发中,混合式开发是常态,即部分页面原生,部分使用WebView内嵌H5页面。但因为在APP内使用,用户针对这些内嵌的页面,也会希望和APP一样丝滑。

例如路由切换时,我们会期望:

  1. 动画:像原生APP一样,有左右滑动的效果
  2. 缓存:页面内容缓存,记录之前页面的位置数据状态,不再重复请求接口

为此就需要做一些特殊处理

任务栈

在Android中有一个任务栈的概念,它可以很好地记录用户在APP中的操作路径

Android任务栈.png

按照官网上的说法,用户在使用电子邮件的App时,可能有如下操作

  • 首页看到新邮件列表
  • 进入列表页,看到邮件列表
  • 用户选择某一条消息,进入消息的详情页面
  • 看完消息,用户返回了邮件列表页

如果我们要能做到“记忆”,即记住那些用户已经访问过的页面,并在下次用户重新访问的时候,从用户上次使用的地方继续,则应该这样

  • 缓存首页
  • 缓存列表页
  • 缓存用户查看的消息的详情页
  • 展示缓存好的列表页,消息详情页的缓存清除

image.png

这样的一个记忆过程其实本质用了这个数据结构,后进先出,而页面也只有两种动作

  • 入栈push:下钻子页面
  • 出栈pop:返回父页面

路由切换实现

移动端路由切换主要包括两个部分

  • 页面切换动画
  • 页面缓存:参考Android的任务栈实现一个虚拟任务栈,对页面进行缓存

虚拟任务栈

为了能在Web项目中也能实现页面缓存,我们就需要考虑模拟Android的任务栈,称之为虚拟任务栈

虚拟任务栈要做的是

  1. 记录需要缓存的页面
  2. 页面切换的时候(下钻子页面返回父页面时)展示切换动画

页面缓存需要记录上次用户浏览到的位置数据,避免接口重复请求进入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里面,我们可以通过单指左右滑动页面,实现页面的切换,而且切换的时候是有动画效果的,即上一个页面在下个页面出来之前,会存在于当前页面侧面,有点像我们在翻书

翻书.jpeg

Vue3中,可以结合router-viewtransition实现页面的切换动画,transition上的name属性可以指定几个阶段的动画效果,这里主要添加进入和退出时active状态的

  • 进入时:新老页面从右往左
  • 退出时:新老页面从左往右

Vue transition过渡动画阶段.png

另外注意,同名组件是不用有动画,也不用多次存在虚拟任务栈中的,所以需要给组件添加一个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
}

页面缓存

对于已经浏览过的页面,作为用户,期望的就是:让我从之前浏览的状态继续

对应到开发,要做的就是两个事情

  1. 记住用户上次浏览到的位置数据
  2. 避免接口的重复请求

在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
})