Vue Router嵌套路由的那些坑我帮你踩过了
一个管理后台项目的路由设计
最近做了个企业级的后台管理系统,说实话嵌套路由这块还是挺折腾的。项目需要实现多个模块的深度嵌套,比如订单管理下面还要分待支付、已支付、已完成这些子状态页,用户管理里也要细分权限设置、角色分配等功能页。一开始觉得Vue Router的嵌套路由应该挺简单的,结果实际开发中遇到了不少意料之外的问题。
基础架构搭建
项目结构定的是典型的管理后台样式,左侧菜单,顶部导航,主内容区域动态切换。路由层级大概长这样:
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/components/Layout.vue'
const routes = [
{
path: '/admin',
component: Layout,
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue')
},
{
path: 'orders',
name: 'Orders',
component: () => import('@/views/orders/OrdersLayout.vue'),
children: [
{
path: '',
redirect: 'pending'
},
{
path: 'pending',
name: 'OrdersPending',
component: () => import('@/views/orders/Pending.vue')
},
{
path: 'paid',
name: 'OrdersPaid',
component: () => import('@/views/orders/Paid.vue')
},
{
path: 'completed',
name: 'OrdersCompleted',
component: () => import('@/views/orders/Completed.vue')
}
]
},
{
path: 'users',
name: 'Users',
component: () => import('@/views/users/UsersLayout.vue'),
children: [
{
path: '',
redirect: 'list'
},
{
path: 'list',
name: 'UsersList',
component: () => import('@/views/users/List.vue')
},
{
path: 'roles',
name: 'UserRoles',
component: () => import('@/views/users/Roles.vue')
},
{
path: 'permissions',
name: 'UserPermissions',
component: () => import('@/views/users/Permissions.vue')
}
]
}
]
}
]
Layout组件就是基本的框架布局:
<!-- components/Layout.vue -->
<template>
<div class="admin-layout">
<header class="top-nav">
<!-- 顶部导航栏 -->
<TopNav />
</header>
<div class="main-content">
<aside class="sidebar">
<!-- 左侧菜单 -->
<SideMenu />
</aside>
<main class="content-area">
<!-- 主要内容区域 -->
<router-view />
</main>
</div>
</div>
</template>
<script setup>
import TopNav from './TopNav.vue'
import SideMenu from './SideMenu.vue'
</script>
这里的关键在于每层都有独立的router-view,让不同层级的组件可以渲染不同的子路由内容。OrdersLayout和UsersLayout分别承载各自的子页面,避免所有组件都在同一层级。
踩坑最多的是keep-alive缓存
这个问题真的让我折腾了两天。用户反映切换菜单的时候页面会重新加载,之前填的数据都没了。开始还以为是数据响应式的问题,查了半天才发现是嵌套路由下keep-alive的行为和单层不太一样。
最初我是这么写的:
<!-- 错误写法 -->
<template>
<div class="content-area">
<keep-alive>
<router-view />
</keep-alive>
</div>
</template>
这样只缓存了最外层的路由组件,内部的嵌套子路由还是会被销毁重建。后来调整成了动态include的方式:
<!-- 正确写法 -->
<template>
<div class="content-area">
<keep-alive :include="cachedViews">
<router-view :key="$route.fullPath" />
</keep-alive>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'
const store = useStore()
const cachedViews = computed(() => store.state.cachedViews)
</script>
配合Vuex来管理缓存的页面名称:
// store/modules/cache.js
const state = {
cachedViews: []
}
const mutations = {
ADD_CACHED_VIEW(state, view) {
if (!state.cachedViews.includes(view.name)) {
state.cachedViews.push(view.name)
}
},
REMOVE_CACHED_VIEW(state, view) {
const index = state.cachedViews.indexOf(view.name)
if (index > -1) {
state.cachedViews.splice(index, 1)
}
},
CLEAR_ALL_CACHED_VIEWS(state) {
state.cachedViews = []
}
}
在路由守卫里添加缓存管理:
// router/index.js
router.beforeEach((to, from, next) => {
// 添加缓存
if (to.meta.keepAlive) {
store.commit('ADD_CACHED_VIEW', to)
}
// 移除不需要缓存的
if (from.meta.keepAlive && !to.meta.keepAlive) {
store.commit('REMOVE_CACHED_VIEW', from)
}
next()
})
这里注意我踩过好几次坑的地方:嵌套路由的缓存顺序很重要,如果某个父级路由没被缓存,那么它的子路由也不会被缓存。所以要在路由meta里明确标记是否需要keepAlive。
菜单高亮和面包屑同步
另一个头疼的问题是菜单选中状态和面包屑的动态更新。嵌套层级一深,父子路由之间的状态同步就很复杂了。
我的解决方案是在SideMenu组件里监听$route的变化:
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const activeMenu = ref('')
watch(
() => route.path,
(newPath) => {
// 根据当前路径设置激活菜单
setActiveMenu(newPath)
},
{ immediate: true }
)
const setActiveMenu = (path) => {
// 找到对应的菜单项
const matched = findMenuByPath(path)
activeMenu.value = matched ? matched.name : ''
}
const findMenuByPath = (path) => {
// 遍历菜单数据找对应项
return menuData.find(item =>
path.includes(item.path) ||
item.children?.some(child => path.includes(child.path))
)
}
</script>
面包屑也是类似的逻辑,在Layout组件里处理:
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const breadcrumbItems = computed(() => {
const matched = route.matched.filter(item => item.meta && item.meta.title)
return matched.map(item => ({
title: item.meta.title,
path: item.path
}))
})
</script>
权限控制和动态路由
最复杂的是结合权限验证的动态路由加载。用户的权限等级决定了能看到哪些菜单和页面,这就需要在登录成功后根据权限信息动态生成路由配置。
// utils/routeGenerator.js
export function generateRoutes(userPermission, routes) {
const res = []
routes.forEach(route => {
const tmp = { ...route }
if (hasPermission(userPermission, tmp)) {
if (tmp.children) {
tmp.children = generateRoutes(userPermission, tmp.children)
}
res.push(tmp)
}
})
return res
}
function hasPermission(permissions, route) {
if (route.meta && route.meta.permission) {
return permissions.some(p => route.meta.permission.includes(p))
}
return true
}
登录后动态添加路由:
// store/modules/user.js
async login({ commit }, userInfo) {
const response = await loginApi(userInfo)
const { permissions } = response.data
// 生成用户专属路由
const userRoutes = generateRoutes(permissions, baseRoutes)
// 动态添加路由
userRoutes.forEach(route => {
router.addRoute('AdminLayout', route)
})
commit('SET_ROUTES', userRoutes)
}
这里要注意动态添加路由的时机,必须在路由初始化之后,否则会出现找不到路由的情况。
性能优化和内存泄漏
嵌套路由层级一深,组件销毁和创建的开销就明显增加了。特别是某些页面有大量数据渲染和定时器操作,退出页面时清理不及时就会造成内存泄漏。
在每个页面组件里都加了onUnmounted钩子:
<script setup>
import { onUnmounted } from 'vue'
let timer = null
const startTimer = () => {
timer = setInterval(() => {
// 定时任务
}, 5000)
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = null
}
// 其他清理工作
})
</script>
回顾与反思
这套嵌套路由系统跑到现在已经有两个月了,整体稳定性还可以。最大的收获是明白了缓存策略的重要性,以及动态路由生成的时机控制。不过还是有些地方不够完美,比如深层嵌套下的错误边界处理还不够完善,某些极端情况下还是会闪屏。这些问题暂时不影响主要功能,计划下个版本统一修复。
总的来说嵌套路由虽然复杂,但只要把各个层级的关系理清楚,配合好状态管理和缓存策略,还是能做出稳定可靠的系统的。以后类似需求会考虑更早规划好缓存机制和生命周期管理。
以上是我踩坑后的总结,希望对你有帮助。
