Vue Router 进阶:路由懒加载、导航守卫与元信息的高效运用

前言

如果你问我:在一个Vue应用中,最重要的部分是什么?我的答案是:路由系统。路由就像是应用的骨架,它决定了:

  • 用户如何从一个页面导航到另一个页面
  • 哪些用户可以访问哪些页面
  • 页面的加载速度有多快
  • 用户体验是否流畅

但很多开发者对 Vue Router 的理解,仅仅停留在配置路径和组件的层面。这就像知道如何使用电灯开关,却不懂电路设计一样。本文将从最基础的概念讲起,一步步深入Vue Router 4的核心功能,最终帮你构建一个健壮、高效、易维护的企业级路由系统。无论你是刚接触Vue3的新手,还是经验丰富的老手,都能在这里找到有价值的内容。

为什么需要深入理解路由?

从一个真实场景开始

假设我们在开发一个后台管理系统,需要实现以下功能:

  • 未登录用户只能访问登录页
  • 不同角色的用户看到不同的菜单
  • 页面切换时显示进度条
  • 离开页面时如果有未保存数据要提示
  • 某些页面需要预加载数据
  • 页面标题要动态更新

如果只是简单地配置路由,代码很快就会变得混乱不堪:

  • 每个组件都要自己检查权限
  • 每个组件都要自己更新标题
  • 每个组件都要自己处理数据预加载

这就是为什么我们需要深入理解路由,路由系统可以统一处理这些横切关注点,让代码更加清晰、可维护。

Vue Router 4 的核心设计哲学

从 Vue Router 3 到 4 的演进

Vue Router 4 专为 Vue3 设计,带来了几个重要的变化:

特性 Vue Router 3 Vue Router 4 优势
API风格 Options API Composition API优先 更好的逻辑复用
TypeScript 有限支持 原生支持 类型安全
动态路由 addRoutes addRoute(更灵活) 精细控制

路由懒加载:让首屏飞起来

为什么要懒加载?

我们先看一个反例,如果没有路由懒加载,那么所有路由组件都会直接打包成一个 JS 文件,导致首屏加载慢,白屏时间长:

typescript 复制代码
// ❌ 错误写法:所有组件一起打包
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import User from '@/views/User.vue'
import Dashboard from '@/views/Dashboard.vue'
// ... 假设有50个页面

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
  // ...
]

上述代码,问题出在哪?

  • 所有页面代码都打包成一个巨大的JS文件
  • 用户访问首页,却要下载所有页面的代码
  • 首屏加载时间随着项目变大而线性增长

正确的懒加载方式

typescript 复制代码
// ✅ 正确写法:使用动态导入
const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('@/views/About.vue')
  },
  {
    path: '/user/:id',
    name: 'User',
    component: () => import('@/views/User.vue')
  }
]

动态导入的原理

  1. () => import('@/views/Home.vue') 看起来像函数调用,但本质是一个操作符(类似 typeof),它返回一个 Promise
  2. 构建时的处理(Webpack/Vite):
    1. 解析这个动态导入语句
    2. 为这个模块创建一个独立的 chunk(代码块)
    3. 生成对应的 chunk 文件名(如:Home.[hash].js
    4. 记录这个映射关系
  3. 在路由匹配时动态加载这个 chunk 文件
  4. 加载完成后渲染组件

路由懒加载的最佳实践

策略一:按路由层级拆分

typescript 复制代码
// 每个路由单独打包,适合页面之间差异大的场景
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/layouts/DashboardLayout.vue'),
    children: [
      {
        path: 'overview',
        component: () => import('@/views/dashboard/Overview.vue')
      },
      {
        path: 'analytics',
        component: () => import('@/views/dashboard/Analytics.vue')
      }
    ]
  }
]
打包结果
text 复制代码
dashboard.js      - 包含布局组件
overview.js       - 包含概览页面
analytics.js      - 包含分析页面

策略二:按功能模块拆分

typescript 复制代码
// 同一个模块的路由打包在一起,适合关联性强的页面
const UserModule = () => import(/* webpackChunkName: "user" */ '@/views/user')

const routes = [
  {
    path: '/user',
    component: UserModule,
    children: [
      { 
        path: 'profile', 
        component: () => import('@/views/user/Profile.vue') 
      },
      { 
        path: 'settings', 
        component: () => import('@/views/user/Settings.vue') 
      }
    ]
  }
]
打包结果
text 复制代码
user.js  - 包含用户模块的所有页面(适合模块内页面关联性强的场景)

策略三:路由预加载(Preloading)

typescript 复制代码
// 用户鼠标悬停在链接上时预加载
const handleMouseEnter = () => {
  // 预加载用户页面
  import('@/views/User.vue')
}

// 或者在路由元信息中配置预加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: { 
      preload: true  // 表示需要预加载
    }
  }
]

// 全局预加载策略
router.beforeEach(async (to, from) => {
  // 预加载可能访问的下一个页面
  const likelyNextRoutes = ['/products', '/about']
  likelyNextRoutes.forEach(routePath => {
    // 找到对应的路由配置并预加载
    const route = router.resolve(routePath)
    if (route.matched.length) {
      // 触发组件加载
      route.matched.forEach(record => {
        if (record.components?.default) {
          // 预加载组件
          const component = record.components.default
          if (typeof component === 'function') {
            component()  // 调用加载函数
          }
        }
      })
    }
  })
})

懒加载的性能收益分析

指标 优化前 优化后 提升
首屏 JS 体积 2.3 MB 450 KB 80%
FCP 2.8 s 1.2 s 57%
LCP 3.5 s 1.6 s 54%
TTI 4.2 s 2.1 s 50%

导航守卫:路由的守门人

什么是导航守卫?

想象一下,当我们需要进入一个安检的大楼:

  • 门口保安:检查身份证(全局前置守卫)
  • 楼层管理员:检查是否有权限进入该楼层(路由独享守卫)
  • 办公室门禁:检查是否是该办公室的员工(组件内守卫)

导航守卫就是路由系统的安检系统

导航守卫的执行流程全景图

text 复制代码
用户点击链接
    ↓
触发导航
    ↓
【离开当前页面的组件守卫】← 如果有未保存数据,可以阻止离开
    ↓
【全局前置守卫】← 检查登录状态、权限等
    ↓
【路由独享守卫】← 特定路由的额外检查
    ↓
【组件内守卫(进入前)】← 可以在这里预加载数据
    ↓
解析异步组件(如果还没加载)
    ↓
【全局解析守卫】← 所有守卫都通过后,导航确认前
    ↓
导航被确认
    ↓
更新DOM
    ↓
【全局后置钩子】← 可以记录日志、更新标题等

三种守卫的详细用法

1. 全局守卫 - 适合处理通用逻辑

全局前置守卫 - 导航触发时调用
typescript 复制代码
router.beforeEach(async (to, from) => {
  console.log('→ 开始导航:', to.path)
  
  // 场景1:检查登录状态
  const userStore = useUserStore()
  const isAuthenticated = userStore.isLoggedIn
  
  // 如果页面需要登录但用户未登录
  if (to.meta.requiresAuth && !isAuthenticated) {
    // 重定向到登录页,并记录要访问的页面
    return {
      path: '/login',
      query: { redirect: to.fullPath }
    }
  }
  
  // 场景2:如果已登录用户访问登录页,跳转到首页
  if (to.path === '/login' && isAuthenticated) {
    return '/'
  }
})
全局解析守卫:所有守卫完成后,导航确认前
typescript 复制代码
router.beforeResolve(async (to, from) => {
  // 适合做数据预加载
  if (to.meta.preload) {
    await to.meta.preload(to)
  }
})
全局后置守卫:导航完成后调用
typescript 复制代码
router.afterEach((to, from, failure) => {
  console.log('← 导航完成:', to.path)
  
  // 场景1:更新页面标题
  if (to.meta.title) {
    document.title = `${to.meta.title} - 我的应用`
  }
  
  // 场景2:页面访问统计
  if (!failure) {
    sendAnalytics({
      page: to.path,
      title: to.meta.title,
      referrer: from.path
    })
  }
  
  // 场景3:滚动到顶部
  window.scrollTo(0, 0)
})

2. 路由独享守卫 - 只对特定路由生效

typescript 复制代码
const routes = [
  {
    path: '/admin',
    component: () => import('@/views/Admin.vue'),
    beforeEnter: (to, from) => {
      // 只在这个路由进入时触发
      // 参数变化时不会触发
      
      // 检查权限
      const userStore = useUserStore()
      if (!userStore.isAdmin) {
        return { path: '/403' }
      }
    }
  },
  {
    path: '/user/:id',
    component: () => import('@/views/User.vue'),
    beforeEnter: [
      // 可以传入数组,按顺序执行
      checkUserExists,
      checkUserStatus,
      logUserAccess
    ]
  }
]

// 独立的守卫函数
async function checkUserExists(to, from) {
  const userStore = useUserStore()
  const exists = await userStore.checkExists(to.params.id)
  if (!exists) {
    return { path: '/404' }
  }
}

3. 组件内守卫 - 处理组件相关的逻辑

1. 离开当前组件时调用
typescript 复制代码
onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value) {
    const answer = window.confirm('有未保存的更改,确定离开吗?')
    if (!answer) return false
  }
})

2. 路由参数变化但组件复用时调用

typescript 复制代码
onBeforeRouteUpdate(async (to, from) => {
  console.log('路由参数变化', to.params, from.params)
  
  // 当路由参数变化时,重新获取数据
  if (to.params.id !== from.params.id) {
    const userId = to.params.id as string
    await fetchUserData(userId)
  }
})

3. 选项式 API 中的 beforeRouteEnter

typescript 复制代码
export default {
  beforeRouteEnter(to, from, next) {
    // ⚠️ 注意:此时不能访问组件实例this
    // 因为组件还没创建
    
    // 可以通过next回调访问实例
    next(vm => {
      // vm就是组件实例
      vm.loadData()
    })
  },
  
  beforeRouteUpdate(to, from) {
    // 可以访问this
    this.fetchData(to.params.id)
  },
  
  beforeRouteLeave(to, from) {
    // 可以访问this
    if (this.hasUnsavedChanges) {
      return confirm('确定离开吗?')
    }
  }
}

导航守卫的实战模式

模式一:权限检查统一处理

typescript 复制代码
// router/guards/permission.ts
import { useUserStore } from '@/stores/user'

export async function permissionGuard(to, from) {
  const userStore = useUserStore()
  
  // 不需要登录的页面
  const publicPages = ['/login', '/register', '/forgot-password']
  if (publicPages.includes(to.path)) {
    return true
  }
  
  // 检查是否登录
  if (!userStore.isLoggedIn) {
    return {
      path: '/login',
      query: { redirect: to.fullPath }
    }
  }
  
  // 检查角色权限
  const requiredRoles = to.meta.roles as string[]
  if (requiredRoles) {
    const hasRole = requiredRoles.some(role => 
      userStore.roles.includes(role)
    )
    if (!hasRole) {
      return { path: '/403' }
    }
  }
  
  // 检查权限点
  const requiredPermissions = to.meta.permissions as string[]
  if (requiredPermissions) {
    const hasPermission = requiredPermissions.every(perm => 
      userStore.permissions.includes(perm)
    )
    if (!hasPermission) {
      return { path: '/403' }
    }
  }
}

模式二:页面数据预加载

typescript 复制代码
// router/guards/prefetch.ts
import { useLoadingStore } from '@/stores/loading'

export async function prefetchGuard(to, from) {
  // 如果路由配置了需要预加载的数据
  if (to.meta.prefetch) {
    const loadingStore = useLoadingStore()
    
    try {
      loadingStore.start()
      
      // 执行预加载函数
      if (typeof to.meta.prefetch === 'function') {
        await to.meta.prefetch(to)
      }
    } finally {
      loadingStore.stop()
    }
  }
}

// 在路由配置中使用
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: {
      prefetch: async (to) => {
        const userStore = useUserStore()
        const dashboardStore = useDashboardStore()
        
        // 并行预加载多个数据
        await Promise.all([
          userStore.fetchProfile(),
          dashboardStore.fetchStats(),
          dashboardStore.fetchCharts()
        ])
      }
    }
  }
]

模式三:页面切换进度条

typescript 复制代码
// router/guards/progress.ts
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

NProgress.configure({
  minimum: 0.1,
  easing: 'ease',
  speed: 500,
  showSpinner: false
})

export function setupProgressGuard(router) {
  let timer: NodeJS.Timeout
  
  router.beforeEach(() => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      NProgress.start()
    }, 200) // 延迟200ms显示,避免快速切换时闪烁
  })
  
  router.afterEach(() => {
    clearTimeout(timer)
    NProgress.done()
  })
  
  router.onError(() => {
    clearTimeout(timer)
    NProgress.done()
  })
}

模式四:页面访问日志

typescript 复制代码
// router/guards/logger.ts
export function setupLoggerGuard(router) {
  router.beforeEach((to, from) => {
    if (import.meta.env.DEV) {
      console.group('🚀 路由导航')
      console.log('从:', from.fullPath || '首次访问')
      console.log('到:', to.fullPath)
      console.log('时间:', new Date().toLocaleString())
      console.log('元信息:', to.meta)
      console.groupEnd()
    }
  })
  
  router.afterEach((to, from, failure) => {
    if (import.meta.env.DEV) {
      if (failure) {
        console.error('❌ 导航失败:', failure)
      } else {
        console.log('✅ 导航成功')
      }
    }
  })
}

路由元信息:路由的隐形背包

什么是路由元信息?

**路由元信息(meta)**是附加在路由配置上的自定义数据,想象每个路由都有一个"背包",我们可以往里面放任何我们需要的东西,可以包含任何业务需要的字段:

typescript 复制代码
const routes = [
  {
    path: '/admin',
    component: AdminLayout,
    meta: {
      requiresAuth: true,      // 需要登录
      roles: ['admin'],        // 允许的角色
      title: '管理后台',        // 页面标题
      icon: 'admin-icon',      // 菜单图标
      keepAlive: true,         // 需要缓存
      transition: 'fade',       // 切换动画
      breadcrumb: [            // 面包屑
        { name: '首页', path: '/' },
        { name: '管理' }
      ],
      permissions: [           // 权限点
        'user:view',
        'user:edit'
      ]
    },
    children: [
      {
        path: 'users',
        component: UserList,
        meta: {
          title: '用户管理',
          icon: 'user-icon'
        }
      }
    ]
  }
]

元信息的合并策略

typescript 复制代码
// 嵌套路由中的元信息会合并(对象合并,不是覆盖)
const routes = [
  {
    path: '/dashboard',
    meta: { 
      requiresAuth: true, 
      title: '仪表盘',
      breadcrumb: ['首页']
    },
    children: [
      {
        path: 'analytics',
        meta: { 
          title: '数据分析',     // 覆盖父级 title
          breadcrumb: ['首页', '分析']  // 追加到父级 breadcrumb
        },
        component: Analytics
      }
    ]
  }
]

// 最终 Analytics 的 meta:
// {
//   requiresAuth: true,
//   title: '数据分析',
//   breadcrumb: ['首页', '分析']
// }

元信息的高效运用

场景一:动态页面标题

typescript 复制代码
// router/index.ts
router.afterEach((to) => {
  // 获取路由的标题元信息
  const title = to.meta.title as string
  const appName = import.meta.env.VITE_APP_NAME
  
  if (title) {
    document.title = `${title} - ${appName}`
  } else {
    document.title = appName
  }
})

场景二:控制页面缓存

html 复制代码
<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <!-- 使用keep-alive缓存需要缓存的页面 -->
    <keep-alive :include="cachedViews">
      <component 
        :is="Component" 
        v-if="route.meta.keepAlive"
        :key="route.fullPath"
      />
    </keep-alive>
    
    <!-- 不需要缓存的页面 -->
    <component 
      :is="Component" 
      v-else
      :key="route.fullPath"
    />
  </router-view>
</template>

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()

// 获取所有需要缓存的视图名称
const cachedViews = computed(() => {
  return route.matched
    .filter(r => r.meta.keepAlive)
    .map(r => r.components?.default.name)
    .filter(Boolean)
})
</script>

场景三:动态菜单生成

typescript 复制代码
// utils/menu.ts
export function generateMenu(routes, parentPath = '') {
  return routes
    .filter(route => !route.meta?.hidden)  // 过滤隐藏菜单
    .filter(route => route.meta?.title)    // 必须有标题
    .map(route => {
      const fullPath = parentPath + route.path
      
      const menuItem = {
        key: fullPath,
        title: route.meta.title,
        icon: route.meta.icon,
        children: [],
        permissions: route.meta.permissions || []
      }
      
      if (route.children) {
        menuItem.children = generateMenu(route.children, fullPath + '/')
      }
      
      return menuItem
    })
}

// 在组件中使用
const menuList = computed(() => {
  const userStore = useUserStore()
  const routes = router.getRoutes()
  
  return generateMenu(routes).filter(menu => {
    // 过滤没有权限的菜单
    if (menu.permissions.length) {
      return menu.permissions.every(p => userStore.hasPermission(p))
    }
    return true
  })
})

场景四:动态面包屑

typescript 复制代码
// composables/useBreadcrumb.ts
import { computed } from 'vue'
import { useRoute } from 'vue-router'

export function useBreadcrumb() {
  const route = useRoute()
  
  const breadcrumbs = computed(() => {
    const matched = route.matched.filter(item => item.meta?.breadcrumb)
    
    // 收集所有的面包屑
    const items: Array<{ title: string; path?: string }> = []
    
    matched.forEach((item, index) => {
      const bc = item.meta.breadcrumb
      
      if (Array.isArray(bc)) {
        // 如果是数组,直接添加
        bc.forEach((crumb, i) => {
          // 最后一个面包屑不需要路径
          if (index === matched.length - 1 && i === bc.length - 1) {
            items.push({ title: crumb.title })
          } else {
            items.push(crumb)
          }
        })
      } else if (typeof bc === 'string') {
        // 如果是字符串,转换为对象
        if (index === matched.length - 1) {
          items.push({ title: bc })
        } else {
          items.push({ title: bc, path: item.path })
        }
      }
    })
    
    return items
  })
  
  return { breadcrumbs }
}

场景五:路由切换动画

html 复制代码
<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <transition :name="route.meta.transition || 'fade'" mode="out-in">
      <component :is="Component" :key="route.fullPath" />
    </transition>
  </router-view>
</template>

<style>
/* 基础动画 */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

/* 滑动动画 */
.slide-enter-active,
.slide-leave-active {
  transition: transform 0.3s ease;
}

.slide-enter-from {
  transform: translateX(100%);
}

.slide-leave-to {
  transform: translateX(-100%);
}

/* 缩放动画 */
.scale-enter-active,
.scale-leave-active {
  transition: transform 0.2s ease, opacity 0.2s ease;
}

.scale-enter-from {
  transform: scale(0.9);
  opacity: 0;
}

.scale-leave-to {
  transform: scale(1.1);
  opacity: 0;
}
</style>

路由性能优化策略

组件缓存策略

html 复制代码
<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive :include="cachedViews" :max="10">
      <component 
        :is="Component" 
        :key="route.fullPath"
        v-if="route.meta.keepAlive"
      />
    </keep-alive>
    
    <component 
      :is="Component" 
      :key="route.fullPath"
      v-else
    />
  </router-view>
</template>

<script setup>
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const cachedViews = ref([])

// 动态管理缓存
watch(() => route.meta.keepAlive, (keepAlive) => {
  if (keepAlive && route.name) {
    if (!cachedViews.value.includes(route.name)) {
      cachedViews.value.push(route.name)
    }
  }
}, { immediate: true })

// 监听路由离开,清理不需要的缓存
watch(() => route.fullPath, (newPath, oldPath) => {
  // 如果离开的页面不需要缓存,从缓存中移除
  if (route.matched.some(r => r.meta.keepAlive === false)) {
    const componentName = route.matched[route.matched.length - 1]?.components?.default?.name
    if (componentName) {
      cachedViews.value = cachedViews.value.filter(name => name !== componentName)
    }
  }
})
</script>

数据预加载策略

策略一:路由守卫中预加载

typescript 复制代码
router.beforeResolve(async (to) => {
  if (to.meta.prefetch) {
    const start = performance.now()
    
    // 显示加载状态
    const loading = ElLoading.service({
      fullscreen: true,
      text: '加载中...'
    })
    
    try {
      await to.meta.prefetch(to)
    } finally {
      loading.close()
      
      const end = performance.now()
      console.log(`预加载耗时: ${(end - start).toFixed(2)}ms`)
    }
  }
})

策略二:组件内预加载

typescript 复制代码
import { onBeforeRouteUpdate } from 'vue-router'

// 路由参数变化时重新获取数据
onBeforeRouteUpdate(async (to, from) => {
  if (to.params.id !== from.params.id) {
    // 显示骨架屏
    showSkeleton.value = true
    
    try {
      await fetchData(to.params.id)
    } finally {
      showSkeleton.value = false
    }
  }
})

// 初始加载
await fetchData(route.params.id)

策略三:路由元信息配置预加载函数

typescript 复制代码
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: {
      prefetch: async () => {
        // 并行预加载多个数据
        await Promise.all([
          useDashboardStore().fetchStats(),
          useDashboardStore().fetchCharts(),
          useUserStore().fetchProfile()
        ])
      }
    }
  }
]

滚动行为优化

typescript 复制代码
const router = createRouter({
  scrollBehavior(to, from, savedPosition) {
    // 返回上一页时恢复滚动位置
    if (savedPosition) {
      // 延迟执行,等待页面渲染完成
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve(savedPosition)
        }, 100)
      })
    }
    
    // 有hash时滚动到对应元素
    if (to.hash) {
      return {
        el: to.hash,
        behavior: 'smooth',
        top: 80 // 减去固定头部的高度
      }
    }
    
    // 不同路由使用不同的滚动行为
    if (to.meta.scrollToTop === false) {
      return {} // 保持当前位置
    }
    
    // 默认滚动到顶部
    return { 
      top: 0, 
      left: 0,
      behavior: 'smooth'
    }
  }
})

路由解析性能监控

typescript 复制代码
// 开发环境下监控路由性能
if (import.meta.env.DEV) {
  router.beforeEach((to) => {
    to.meta.startTime = performance.now()
  })
  
  router.afterEach((to) => {
    const end = performance.now()
    const start = to.meta.startTime
    const duration = (end - start).toFixed(2)
    
    console.log(`✅ 路由 ${to.path} 加载完成: ${duration}ms`)
    
    // 如果超过阈值,记录警告
    if (duration > 500) {
      console.warn(`⚠️ 路由加载较慢: ${duration}ms`)
      
      // 分析哪个部分耗时
      const matched = to.matched
      matched.forEach(record => {
        if (record.components?.default) {
          const comp = record.components.default
          if (typeof comp === 'function') {
            console.log(`  组件 ${record.path} 是懒加载的`)
          }
        }
      })
    }
  })
  
  // 监控组件加载时间
  router.beforeResolve((to) => {
    const components = to.matched.map(record => 
      record.components?.default?.name || record.path
    )
    console.log('即将加载组件:', components)
  })
}

路由性能优化策略

路由组件缓存策略

html 复制代码
<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <!-- 使用 include 精确控制缓存 -->
    <keep-alive :include="cachedViews" :max="10">
      <component 
        :is="Component" 
        :key="route.fullPath"
      />
    </keep-alive>
  </router-view>
</template>

<script setup>
import { useCacheStore } from '@/stores/cache'

const cacheStore = useCacheStore()

// 动态控制需要缓存的视图
const cachedViews = computed(() => {
  return cacheStore.cachedViews
})

// 手动清除缓存
function clearCache(routeName) {
  cacheStore.removeCachedView(routeName)
}

// 监听路由变化,动态添加/移除缓存
watch(route, (to, from) => {
  // 如果离开的页面需要缓存
  if (from.meta?.keepAlive) {
    cacheStore.addCachedView(from.name)
  }
  
  // 如果进入的页面不需要缓存,且之前缓存了
  if (!to.meta?.keepAlive && cacheStore.hasCachedView(to.name)) {
    cacheStore.removeCachedView(to.name)
  }
})
</script>

路由切换时的数据预加载

方案一:路由守卫中预加载

typescript 复制代码
router.beforeEach(async (to, from) => {
  if (to.meta.preload) {
    const start = performance.now()
    await to.meta.preload(to)
    const end = performance.now()
    console.log(`预加载耗时: ${(end - start).toFixed(2)}ms`)
  }
})

方案二:组件内预加载

typescript 复制代码
// 路由参数变化时重新获取数据
onBeforeRouteUpdate(async (to, from) => {
  if (to.params.page !== from.params.page) {
    await fetchData(to.params.page)
  }
})

// 使用 Suspense 预加载
await fetchData()

方案三:路由元信息配置预加载

typescript 复制代码
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: {
      preload: async () => {
        // 并行预加载多个数据
        await Promise.all([
          useDashboardStore().fetchStats(),
          useDashboardStore().fetchCharts(),
          useUserStore().fetchProfile()
        ])
      }
    }
  }
]

路由过渡动画的性能优化

css 复制代码
/* 使用 transform 代替 left/top 触发硬件加速 */
.slide-enter-active,
.slide-leave-active {
  transition: transform 0.3s ease;
  transform: translate3d(0, 0, 0); /* 开启硬件加速 */
  will-change: transform; /* 提示浏览器优化 */
}

/* 避免同时动画太多元素 */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s ease;
  /* 只动画 opacity,性能更好 */
  will-change: opacity;
}

/* 使用 CSS 动画代替 JS 动画 */
@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

.fade-enter-active {
  animation: fadeIn 0.3s ease;
}

滚动行为的优化

typescript 复制代码
const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    // 返回上一页时恢复滚动位置
    if (savedPosition) {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve(savedPosition)
        }, 100) // 延迟100ms,等待页面渲染完成
      })
    }
    
    // 有 hash 时滚动到对应元素
    if (to.hash) {
      return {
        el: to.hash,
        behavior: 'smooth',  // 平滑滚动
        top: 80 // 考虑固定头部的高度
      }
    }
    
    // 默认滚动到顶部
    return { 
      top: 0, 
      left: 0,
      behavior: 'smooth' 
    }
  }
})

路由解析的性能监控

typescript 复制代码
// 开发环境下监控路由解析时间
if (import.meta.env.DEV) {
  router.beforeEach((to, from) => {
    const start = performance.now()
    to.meta.startTime = start
    
    // 记录导航开始
    console.log(`开始导航到: ${to.path}`)
  })
  
  router.afterEach((to, from) => {
    const end = performance.now()
    const start = to.meta.startTime
    const duration = (end - start).toFixed(2)
    
    console.log(`✅ 导航完成: ${to.path} (${duration}ms)`)
    
    // 如果超过阈值,记录警告
    if (duration > 300) {
      console.warn(`⚠️ 路由 ${to.path} 加载较慢: ${duration}ms`)
    }
  })
  
  // 监控组件加载性能
  router.beforeResolve((to) => {
    const components = to.matched.map(record => 
      record.components?.default.name
    ).filter(Boolean)
    
    console.log('即将加载组件:', components)
  })
}

常见问题与解决方案

问题一:重复添加路由导致警告

typescript 复制代码
// ❌ 错误:多次添加相同路由
function addRoutes() {
  asyncRoutes.forEach(route => {
    router.addRoute(route)  // 第二次调用时会警告
  })
}

解决方法1:检查是否已添加

typescript 复制代码
// ✅ 正确:检查是否已添加
function addRoutes() {
  asyncRoutes.forEach(route => {
    // 使用 router.hasRoute 检查
    if (!router.hasRoute(route.name)) {
      router.addRoute(route)
    }
  })
}

解决方法2:先移除再添加

typescript 复制代码
// ✅ 正确:先移除再添加
function updateRoute(route) {
  if (router.hasRoute(route.name)) {
    router.removeRoute(route.name)
  }
  router.addRoute(route)
}

解决方案3:批量添加时使用 addRoute 的 parent 参数

typescript 复制代码
function addChildRoutes(parentName, routes) {
  routes.forEach(route => {
    if (!router.hasRoute(route.name)) {
      router.addRoute(parentName, route)
    }
  })
}

问题2:路由参数变化但组件不更新

html 复制代码
<template>
  <div>
    <h2>{{ user?.name }}</h2>
    <p>{{ user?.email }}</p>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const user = ref(null)

// ❌ 错误:只在组件创建时获取一次数据
user.value = await fetchUser(route.params.id)
</script>

解决方法1:监听 route.params 的变化

typescript 复制代码
watch(() => route.params.id, async (newId, oldId) => {
  console.log(`ID从 ${oldId} 变为 ${newId}`)
  await fetchData(newId)
}, { immediate: true })

解决方法2:使用 onBeforeRouteUpdate

typescript 复制代码
import { onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteUpdate(async (to, from) => {
  if (to.params.id !== from.params.id) {
    await fetchData(to.params.id)
  }
})

解决方法3:使用 key 强制重新渲染

html 复制代码
<router-view :key="route.fullPath" />

问题3:路由守卫中的异步操作导致导航卡顿

typescript 复制代码
// ❌ 错误:守卫中做太多同步操作
router.beforeEach((to) => {
  const start = Date.now()
  while (Date.now() - start < 1000) {
    // 模拟耗时操作 - 会阻塞导航
  }
})

解决方法1:使用异步操作(但要注意异步操作不影响导航)

typescript 复制代码
router.beforeEach(async (to) => {
  // 显示 loading
  const loading = ElLoading.service({
    fullscreen: true,
    text: '加载中...'
  })
  
  try {
    // 执行异步操作
    await loadData()
  } finally {
    // 导航完成后隐藏 loading
    router.afterEach(() => {
      loading.close()
    })
  }
})

解决方法2:使用 nextTick 延迟执行

typescript 复制代码
router.beforeEach((to) => {
  // 先放行导航
  nextTick(() => {
    // 导航完成后执行耗时操作
    doHeavyWork()
  })
})

解决方案3:使用Web Worker处理复杂计算

typescript 复制代码
router.beforeEach((to) => {
  if (to.meta.heavyComputation) {
    const worker = new Worker('/worker.js')
    worker.postMessage(to.meta.data)
    worker.onmessage = (e) => {
      // 处理计算结果
    }
  }
})

问题4:路由懒加载导致的白屏闪烁

解决方法:加载占位动画

html 复制代码
<!-- App.vue -->
<template>
  <router-view v-slot="{ Component }">
    <Suspense>
      <template #default>
        <component :is="Component" />
      </template>
      <template #fallback>
        <!-- 加载占位动画 -->
        <div class="page-loading">
          <LoadingSpinner />
          <p>页面加载中...</p>
        </div>
      </template>
    </Suspense>
  </router-view>
</template>

<style>
.page-loading {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  min-height: 400px;
  color: #909399;
}

@keyframes spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.loading-spinner {
  animation: spin 1s linear infinite;
  font-size: 32px;
}
</style>

问题5:浏览器前进后退时滚动位置丢失

解决方法1:使用 scrollBehavior

typescript 复制代码
const router = createRouter({
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition  // 返回时恢复位置
    }
    return { top: 0 }       // 新页面滚动到顶部
  }
})

解决方案2:手动保存滚动位置

typescript 复制代码
import { onActivated, onDeactivated } from 'vue'

let scrollTop = 0

onDeactivated(() => {
  // 离开时保存滚动位置
  const container = document.querySelector('.scroll-container')
  scrollTop = container?.scrollTop || 0
})

onActivated(() => {
  // 回来时恢复滚动位置
  nextTick(() => {
    const container = document.querySelector('.scroll-container')
    if (container) {
      container.scrollTop = scrollTop
    }
  })
})

路由使用的最佳实践清单

路由设计原则

原则 说明 示例
按功能拆分 将路由按业务模块拆分成独立文件 router/modules/*.ts
懒加载 所有路由组件使用动态导入 () => import('@/views/xxx.vue')
命名路由 使用 name 而不是 path 跳转 router.push({ name: 'User' })
参数验证 在导航守卫中验证路由参数 if (!to.params.id) return '/404'
元信息丰富 把配置都放在 meta 中 meta: { title, requiresAuth }
404放在最后 通配符路由放最后 { path: '/:pathMatch(.*)*', redirect: '/404' }

导航守卫使用原则

原则 说明 示例
职责单一 每个守卫只做一件事 authGuard, permissionGuard
全局守卫通用 认证、日志、进度条 router.beforeEach(authGuard)
路由独享特定 特定路由的权限检查 beforeEnter: checkPermission
组件内守细腻 数据加载、离开确认 onBeforeRouteLeave
避免耗时操作 守卫中不要做同步耗时操作 使用异步或推迟执行
顺序很重要 按依赖关系排列守卫:认证 -> 权限 -> 预加载 router.beforeEach(auth)
返回值明确 返回false/路径/undefined return '/login'

性能优化清单

  • 路由懒加载:所有路由组件使用动态导入
  • 预加载关键路由 :使用 meta.preload 配置,预加载用户可能访问的下一个页面
  • 合理使用缓存 :合理使用 keep-alive 缓存频繁访问的页面
  • 体验优化 :使用 transform 代替位置属性,使用 Suspense 和骨架屏提升用户体验
  • 监控性能:监控路由解析时间,优化慢的路由
  • 滚动优化:优化滚动行为,保存/恢复滚动位置,实现平滑滚动和位置恢复

用户体验清单

  • 进度条:切换页面时显示进度反馈
  • 加载占位 :使用 Suspense 处理异步组件
  • 错误处理:统一处理路由错误页面
  • 标题更新 :根据 meta.title 更新 document.title
  • 面包屑:根据路由元信息生成面包屑
  • 过渡动画:添加合适的页面切换动画
  • 保存提示:离开页面时提示未保存更改

结语

Vue Router 不仅仅是 URL 和组件的映射,更是整个应用的骨架神经系统,把路由设计好了,整个应用就成功了一半。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
SoaringHeart2 小时前
Flutter进阶|源码修改:给 DecorationImage 源码添加偏移量
前端·flutter
wuhen_n2 小时前
虚拟列表完全指南:从原理到实战,轻松渲染10万条数据
前端·javascript·vue.js
兆子龙2 小时前
React Hooks 避坑指南:那些让你 debug 到凌晨的陷阱
前端·javascript
兆子龙3 小时前
你不会使用 CSS 函数 clamp()?那你太 low 了😀
前端·javascript
兆子龙3 小时前
前端性能优化终极清单:从 3 秒到 0.5 秒的实战经验
前端·javascript
兆子龙3 小时前
babel-loader:让你的 JS 代码兼容所有浏览器
前端
百万蹄蹄向前冲3 小时前
支付宝 VS 微信 小程序差异
前端·后端·微信小程序
兆子龙3 小时前
JavaScript 的 Symbol.iterator:手写一个可迭代对象
前端