移动端h5路由过渡动画实现教程

Vue 3 + Vue Router 路由过渡动画:前进 / 返回区分

在 H5 或 App 内嵌页里,我们常希望:

  • 前进(点链接、按钮):新页面从右侧滑入,当前页略微左移(类似 iOS 右推)。
  • 返回(NavBar 返回、浏览器/微信后退):当前页向右滑出,上一页露出。

Vue Router 本身不区分「这次导航是用户点了返回还是点了链接」,浏览器后退又依赖 popstate,触发时机可能晚于路由更新,导致动画方向错乱。下面是一套可复用的实现方式。


一、效果与思路

操作 期望动画
点击进入下一页 新页从右滑入(slide-cover)
NavBar / 浏览器返回 当前页向右滑出,上一页露出(slide-back)

做法:用一个全局的「方向」状态(forward / back),在每次导航前根据「是谁触发的」设好方向,再在根组件的 <transition :name="..."> 里根据方向选不同动画名和 CSS。


二、用 Pinia 存「过渡方向」

// stores/transition.js
import { defineStore } from 'pinia'

export const useTransitionStore = defineStore('transition', {
  state: () => ({ direction: 'forward' }),
  actions: {
    setForward() {
      this.direction = 'forward'
    },
    setBack() {
      this.direction = 'back'
    },
  },
})

默认 forward,只在「确定是返回」时改成 back


三、根组件:按方向切换过渡名和样式

router-view + 单层 transition,根据 store 的 directionslide-coverslide-back

<!-- App.vue -->
<template>
  <div class="wrapper">
    <router-view v-slot="{ Component }">
      <transition :name="transitionName">
        <component :is="Component" :key="$route.fullPath" class="page-layer" />
      </transition>
    </router-view>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useTransitionStore } from '@/stores/transition'

const transitionStore = useTransitionStore()
const transitionName = computed(() =>
  transitionStore.direction === 'back' ? 'slide-back' : 'slide-cover'
)
</script>

页面层需要铺满且可滚动,且两页能叠在一起做过渡,所以用绝对定位:

.wrapper {
  position: fixed;
  inset: 0;
  overflow: hidden;
}
.page-layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: auto;
  -webkit-overflow-scrolling: touch;
}

前进(slide-cover):新页从右边进来,旧页向左挪一点。

.slide-cover-enter-from { transform: translateX(100%); }
.slide-cover-enter-to   { transform: translateX(0); }
.slide-cover-leave-from { transform: translateX(0); }
.slide-cover-leave-to   { transform: translateX(-24%); }

返回(slide-back):当前页向右出去,上一页从左侧露出。

.slide-back-enter-from { transform: translateX(-24%); }
.slide-back-enter-to   { transform: translateX(0); }
.slide-back-leave-from { transform: translateX(0); }
.slide-back-leave-to   { transform: translateX(100%); }

记得给 enter-active / leave-active 设好 transitionz-index,让「进来的」在上层,避免穿帮。完整样式见文末附录。


四、在入口里统一设置「方向」

main.js 里做三件事:popstate 提前 setBackbeforeEach 兜底包装 push/back。且要在 import router 之前就挂好 popstate,这样浏览器后退时尽量先改方向再让路由更新。

1. 浏览器后退:监听 popstate(捕获阶段)

let transitionStoreRef = null
window.addEventListener('popstate', () => transitionStoreRef?.setBack(), true)

import router from './router'

用第三个参数 true 进入捕获阶段,尽量在 Vue Router 自己的 popstate 之前执行,这样路由切过去时 direction 已经是 back

2. 创建 app 并拿到 store 引用

const app = createApp(App)
app.use(pinia)
app.use(router)

transitionStoreRef = useTransitionStore()

3. beforeEach:路径变浅时兜底为「返回」

有些环境下 popstate 会晚于路由更新,第一次点浏览器后退时方向可能还是 forward。可以用「目标路径比当前路径浅」在 beforeEach 里再设一次 setBack() 作为兜底。

注意要排除从登录页离开的情况:登录 → 首页是「前进」,不是返回,所以 from.path === '/login' 时不要当成 back。

const pathDepth = (path) => path.split('/').filter(Boolean).length
router.beforeEach((to, from) => {
  if (pathDepth(to.path) < pathDepth(from.path) && from.path !== '/login') {
    transitionStoreRef.setBack()
  }
})

4. 包装 router.push / router.back

这样无论是 <router-link>router.push() 还是 NavBar 里调 router.back(),方向都会正确:

const originalPush = router.push
const originalBack = router.back
router.push = function (...args) {
  transitionStoreRef.setForward()
  return originalPush.apply(this, args)
}
router.back = function () {
  transitionStoreRef.setBack()
  return originalBack.apply(this, arguments)
}

五、使用约定

  • 所有「返回」都用 router.back()history.back(),不要用 router.push(上一页),否则会走成「前进」动画。
  • 登录页:若你设计成「登录才是第一页、从首页点去登录算退出」,则从登录页跳首页应保持「前进」;用 from.path !== '/login' 排除即可。若有其它类似「路径变浅但算前进」的页面,可以同样加例外条件。

六、小结

触发方式 如何设成 back / forward
点击链接 / 按钮 包装的 router.pushsetForward()
NavBar 返回 包装的 router.back()setBack()
浏览器 / 微信 返回 popstate(捕获)→ setBack(),必要时 beforeEach 按路径深度兜底并排除登录页

这样就能在 Vue 3 + Vue Router 4 下稳定做出「前进从右进、返回向右出」的页面过渡,并兼容浏览器后退和登录等特殊路由。


附录:完整过渡 CSS(含 z-index 与 duration)

.slide-cover-enter-active,
.slide-cover-leave-active {
  transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-cover-enter-active { z-index: 2; }
.slide-cover-enter-from { transform: translateX(100%); }
.slide-cover-enter-to { transform: translateX(0); }
.slide-cover-leave-active { z-index: 1; }
.slide-cover-leave-from { transform: translateX(0); }
.slide-cover-leave-to { transform: translateX(-24%); }

.slide-back-enter-active,
.slide-back-leave-active {
  transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-back-enter-active { z-index: 1; }
.slide-back-enter-from { transform: translateX(-24%); }
.slide-back-enter-to { transform: translateX(0); }
.slide-back-leave-active { z-index: 2; }
.slide-back-leave-from { transform: translateX(0); }
.slide-back-leave-to { transform: translateX(100%); }
posted @ 2026-02-08 19:10  deajax  阅读(36)  评论(0)    收藏  举报