高阶路由过渡处理方案 —— 浏览器堆栈主动介入

目录

01: 前言

02: VueRouter 过渡动效可行性分析

03: 主动介入浏览器堆栈管理,分析可行性方案

04: 主动介入浏览器堆栈管理

05: 基于 GSAP 实现高阶路由过渡动画分析 

06: 基于 GSAP 实现高阶路由过渡动画处理

07: 通用组件:navbar 构建方案分析

08: 通用组件:构建 navbar

09: 基于 navbar 处理响应式的 pins 页面

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 这样的动画库,可以使我们的动画处理变的更加方便。

        接下来我们将要处理 登录、注册,大家拭目以待吧。 

  • 14
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

chengbo_eva

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值