前言
如果你问我:在一个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')
}
]
动态导入的原理
() => import('@/views/Home.vue')看起来像函数调用,但本质是一个操作符(类似typeof),它返回一个Promise- 构建时的处理(Webpack/Vite):
- 解析这个动态导入语句
- 为这个模块创建一个独立的
chunk(代码块) - 生成对应的
chunk文件名(如:Home.[hash].js) - 记录这个映射关系
- 在路由匹配时动态加载这个
chunk文件 - 加载完成后渲染组件
路由懒加载的最佳实践
策略一:按路由层级拆分
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 和组件的映射,更是整个应用的骨架 和神经系统,把路由设计好了,整个应用就成功了一半。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!