深入解析 Vue.js 企业级动态路由权限控制与菜单生成实战方案,涵盖基础路由与权限路由拆分、状态管理与过滤算法、全局路由守卫拦截、防刷新丢失与递归菜单渲染。
Vue.js 动态路由权限控制与菜单生成实战方案
时间:2026-11-03
浏览:638
文章目录
在一个典型的企业中后台系统里,往往同时存在系统管理员、业务运营和财务审计等多种角色。如果将系统所有的功能路由全部硬编码在前端路由表内,未授权的运营人员即使在页面侧边栏看不到“财务审计”入口,只要在浏览器地址栏手动输入 /finance/audit 敲下回车,就能直接越权进入敏感功能模块;同时,多角色切换与深层路由直接刷新时,极易诱发白屏、死循环或 404 捕获失常等连锁问题。
真正完备的前端权限方案必须兼顾两个核心诉求:其一是视图层(侧边栏菜单)依据角色动态生成,保证界面整洁且不泄露功能清单;其二是路由层(URL 导航拦截)实现严格的物理级鉴权隔离,未授权页面在底层路由池中根本不加载。本文基于 Vue 3、Vue Router 4 与 Pinia,详细讲解一套在各大企业中后台项目中广泛验证的标准方案:基础路由与权限路由分离配置、登录拉取身份凭据、递归算法过滤路由树、router.addRoute() 动态挂载、全局路由守卫拦截,以及由状态驱动的递归侧边栏菜单生成。
一、架构方案选型:前端路由过滤 vs 后端完全动态配置
在技术方案落地前,需要理清目前业内两种主流的权限控制思路。两者在配置位置、维护成本与业务敏捷度上有着清晰的边界:
| 对比维度 | 方案 A:前端配置路由表 + 后端返回角色/权限标识(推荐) | 方案 B:后端直接返回完整路由树配置 |
|---|---|---|
| 工作机制 | 前端完整定义路由组件及 Meta 权限声明,接口仅返回用户所属角色或权限数组(如 ['admin', 'finance:read']),前端自行匹配过滤。 |
后端在数据库中维护路由路径、组件映射标识、菜单图标与层级,登录后直接向前端输出完整的嵌套 JSON 路由树。 |
| 组件加载体验 | 直接使用标准的 () => import('./views/xxx.vue') 静态分析,TypeScript 类型支持完善,开发体验极佳。 |
需在前端维护一套组件字符串到异步加载器的映射字典(如 glob 动态加载),配置错漏时排查成本高。 |
| 变更成本 | 前端新增或调整页面只需修改前端代码,Git 版本追踪清晰,敏捷度高。 | 新增页面除前端编写组件外,还需在后端数据库插入菜单记录并配置权限关联,流程较长。 |
| 适用场景 | 绝大多数中后台管理系统、SaaS 平台、内部运营系统。 | 低代码平台、菜单结构与层级由系统管理员完全自定义拖拽配置的超大型系统。 |
对于大部分业务系统,方案 A(前端配置路由表 + 角色过滤)在稳定性、开发效率与维护成本之间取得了最佳平衡。下面我们围绕该方案展开系统全景架构与数据流设计。
二、四步闭环实施全流程
构建动态路由与权限菜单并不是单个函数的编写,而是一个贯穿应用生命周期的系统工程。整个闭环可分解为四个清晰的实施阶段:
三、步骤一:路由结构拆分与 Meta 权限元数据设计
第一步是将系统的路由划分为两张独立的配置表:公共静态路由表(constantRoutes)与动态权限路由表(asyncRoutes)。两者的存放位置与初始化逻辑完全不同:
- 公共静态路由(
constantRoutes):无需任何登录凭证或权限即可访问的公开页面。例如登录页/login、无权提示页/403,以及基础重定向路由。这些路由在创建 Vue Router 实例时直接传入routes参数。 - 动态权限路由(
asyncRoutes):受权限保护的业务页面模块。每个路由项通过meta.roles数组明确声明哪些角色具备访问资格,并通过meta.title、meta.icon和meta.hidden提供菜单渲染所需的元数据。
初学者极易将 path: '/:pathMatch(.*)*' 通配符路由直接写在静态路由表中。由于初始化时动态权限路由尚未注入,当用户在浏览器直接输入受权限保护的 URL 刷新时,Router 将直接提前匹配到 404 页面,导致用户看到页面丢失。通配符路由必须在后续动态路由注入时作为最后一条动态追加。
以下是规范化的路由拆分定义代码示例(src/router/routes.ts):
// src/router/routes.ts
import type { RouteRecordRaw } from 'vue-router';
import Layout from '@/layout/index.vue';
/**
* 公共静态路由表(无需权限,初始化即挂载)
*/
export const constantRoutes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { hidden: true, title: '系统登录' }
},
{
path: '/403',
name: 'Forbidden',
component: () => import('@/views/error/403.vue'),
meta: { hidden: true, title: '无权访问' }
},
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '工作台仪表盘', icon: 'dashboard', affix: true }
}
]
}
];
/**
* 动态权限路由表(按角色/权限码过滤后动态挂载)
*/
export const asyncRoutes: RouteRecordRaw[] = [
{
path: '/system',
component: Layout,
redirect: '/system/user',
name: 'SystemManagement',
meta: {
title: '系统管理',
icon: 'setting',
roles: ['admin'] // 仅 admin 角色可访问
},
children: [
{
path: 'user',
name: 'UserList',
component: () => import('@/views/system/user/index.vue'),
meta: { title: '用户权限分配', roles: ['admin'] }
},
{
path: 'role',
name: 'RoleList',
component: () => import('@/views/system/role/index.vue'),
meta: { title: '角色字典维护', roles: ['admin'] }
}
]
},
{
path: '/finance',
component: Layout,
name: 'FinanceAudit',
meta: {
title: '财务审计',
icon: 'money',
roles: ['admin', 'auditor'] // 允许多角色协同访问
},
children: [
{
path: 'payroll',
name: 'PayrollReport',
component: () => import('@/views/finance/payroll.vue'),
meta: { title: '薪资核算报表', roles: ['admin', 'auditor'] }
}
]
}
];
四、步骤二:Pinia 状态管理与递归权限过滤算法
当用户登录成功后,前端从后端获取其角色列表(如 roles: ['auditor'])。我们需要利用过滤算法比对 asyncRoutes,筛选出该用户有权访问的子路由树。
在设计过滤算法时,必须满足深度优先遍历与不可变数据克隆原则,防止污染全局定义的原始路由配置对象。算法规则如下:
- 若当前路由未声明
meta.roles,默认视作通用路由,直接放行。 - 若声明了
meta.roles,使用roles.some(role => route.meta.roles.includes(role))判定当前用户是否命中其一。 - 若当前节点命中且存在
children子路由,递归对其子路由树执行过滤。若子节点全部无权限,可视业务需求决定是否剔除无可用子项的父菜单。
// src/store/modules/permission.ts
import { defineStore } from 'pinia';
import type { RouteRecordRaw } from 'vue-router';
import { constantRoutes, asyncRoutes } from '@/router/routes';
/**
* 校验单条路由是否匹配当前用户角色
*/
function hasPermission(roles: string[], route: RouteRecordRaw): boolean {
if (route.meta && route.meta.roles) {
return roles.some(role => (route.meta!.roles as string[]).includes(role));
}
return true;
}
/**
* 递归过滤异步路由表
*/
export function filterAsyncRoutes(
routes: RouteRecordRaw[],
roles: string[]
): RouteRecordRaw[] {
const res: RouteRecordRaw[] = [];
routes.forEach(route => {
// 浅拷贝避免直接修改原始配置对象
const tmp = { ...route };
if (hasPermission(roles, tmp)) {
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, roles);
}
res.push(tmp);
}
});
return res;
}
export const usePermissionStore = defineStore('permission', {
state: () => ({
// routes 包含常量路由与权限路由,用于驱动侧边栏菜单生成
routes: [] as RouteRecordRaw[],
// addRoutes 仅保存当前需要动态注入到 Router 的路由
addRoutes: [] as RouteRecordRaw[],
isRoutesLoaded: false
}),
actions: {
generateRoutes(roles: string[]) {
let accessedRoutes: RouteRecordRaw[];
if (roles.includes('admin')) {
// 超管默认拥有全量权限路由
accessedRoutes = asyncRoutes || [];
} else {
accessedRoutes = filterAsyncRoutes(asyncRoutes, roles);
}
this.addRoutes = accessedRoutes;
this.routes = constantRoutes.concat(accessedRoutes);
this.isRoutesLoaded = true;
return accessedRoutes;
},
resetRoutes() {
this.routes = [];
this.addRoutes = [];
this.isRoutesLoaded = false;
}
}
});
五、步骤三:全局路由守卫拦截与动态挂载闭环
动态路由能否在浏览器中正确呈现,关键取决于 router.beforeEach 守卫的严密逻辑。守卫必须承担以下四个核心职责:
- 凭证状态检验:未登录用户只能访问白名单路由,其余一律拦截重定向至
/login,并在 Query 参数中保留redirect目标路径。 - 防二次登录拦截:已持有有效 Token 的用户直接访问
/login时,守卫将其主动重定向至根路径首页/。 - 刷新重新挂载:当用户在已登录状态下按 F5 刷新页面时,内存中的 Pinia 状态被重置(
isRoutesLoaded变为false)。守卫必须重新发起接口请求拉取角色,计算路由表并完成挂载。 - 解决路由注入竞态:动态添加路由必须通过特定的导航中断指令刷新上下文,避免白屏。
核心技术痛点:为什么必须使用 next({ ...to, replace: true })?
在执行 router.addRoute() 时,虽然路由已经注册进底层匹配器,但当前的这次导航已经以尚未注入新路由的状态触发。如果此时直接执行 next(),Vue Router 不会重新匹配,而是继续使用进入守卫时的空路由映射,导致页面渲染空白或跳入 404。
通过传入 next({ ...to, replace: true }),其作用是显式中断当前的这次导航,并以全新的目标配置重新触发一次导航。此时动态路由已经完全就绪,重新进入守卫时能够准确匹配到目标组件;同时设置 replace: true 可以保证浏览器的回退历史记录栈不被中断产生的过渡记录所污染。
// src/permission.ts
import router from '@/router';
import { useUserStore } from '@/store/modules/user';
import { usePermissionStore } from '@/store/modules/permission';
import type { RouteRecordRaw } from 'vue-router';
// 免登录白名单
const whiteList = ['/login', '/403', '/404'];
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore();
const permissionStore = usePermissionStore();
// 1. 检查是否存在有效 Token
if (userStore.token) {
if (to.path === '/login') {
// 已登录用户访问登录页,直接跳转主页
next({ path: '/' });
} else {
// 2. 判断动态路由是否已挂载
if (permissionStore.isRoutesLoaded) {
next();
} else {
try {
// 3. 获取用户角色及权限列表
const { roles } = await userStore.getUserInfo();
// 4. 过滤并生成可访问路由表
const accessRoutes = permissionStore.generateRoutes(roles);
// 5. 逐个将动态路由注入 Vue Router
accessRoutes.forEach(route => {
router.addRoute(route);
});
// 6. 【关键】在所有动态路由挂载完毕后,最后追加 404 通配符路由
router.addRoute({
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
meta: { hidden: true }
});
// 7. 【关键】中断并重新进入导航,确保 addRoute 完全生效
next({ ...to, replace: true });
} catch (error) {
// 发生异常(如 Token 过期或网络错误),清理凭证并强制登出
await userStore.resetToken();
permissionStore.resetRoutes();
next({ path: `/login?redirect=${encodeURIComponent(to.fullPath)}` });
}
}
}
} else {
// 未登录状态下的路由放行与拦截
if (whiteList.includes(to.path)) {
next();
} else {
next({ path: `/login?redirect=${encodeURIComponent(to.fullPath)}` });
}
}
});
六、步骤四:响应式侧边栏菜单生成与递归组件实现
完成底层的路由挂载后,上层 UI 侧边栏的渲染则完全转化为一个数据驱动的递归组件树。我们直接消费 permissionStore.routes,该状态包含了常量路由与当前用户有权访问的全部动态路由。
在将路由树映射为菜单树时,有两个核心业务细节必须妥善处理:
- 过滤
meta.hidden === true:登录页、404、详情页等路由不应出现在侧边栏中。 - 单一子路由(Only One Child)提升折叠:许多页面在路由表中具有一个根父级(Layout)和一个唯一的子路由(例如首页仪表盘)。如果直接循环渲染,侧边栏会产生一个多余的折叠嵌套。当检测到仅有一个可展示子节点且父级没有显式设置
alwaysShow: true时,应直接将其提升为主菜单项。
1. 侧边栏主容器组件(Sidebar.vue)
<!-- src/layout/components/Sidebar/index.vue -->
<template>
<aside class="app-sidebar">
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:background-color="'#142537'"
:text-color="'#83ADD0'"
:active-text-color="'#77B9AE'"
:unique-opened="true"
mode="vertical"
>
<sidebar-item
v-for="route in menuRoutes"
:key="route.path"
:item="route"
:base-path="route.path"
/>
</el-menu>
</el-scrollbar>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { usePermissionStore } from '@/store/modules/permission';
import SidebarItem from './SidebarItem.vue';
const route = useRoute();
const permissionStore = usePermissionStore();
// 获取当前登录用户可访问的全部菜单路由
const menuRoutes = computed(() => permissionStore.routes);
// 计算当前高亮激活的菜单项
const activeMenu = computed(() => {
const { meta, path } = route;
if (meta && meta.activeMenu) {
return meta.activeMenu as string;
}
return path;
});
</script>
2. 递归子菜单组件(SidebarItem.vue)
<!-- src/layout/components/Sidebar/SidebarItem.vue -->
<template>
<!-- 若当前路由标记为 hidden,直接跳过 -->
<template v-if="!item.meta?.hidden">
<!-- 场景 1: 单一子节点折叠提升为独立一级菜单 -->
<template v-if="hasOneShowingChild(item.children, item) && (!onlyOneChild.children || onlyOneChild.noShowingChildren)">
<el-menu-item
v-if="onlyOneChild.meta"
:index="resolvePath(onlyOneChild.path)"
@click="navigateTo(resolvePath(onlyOneChild.path))"
>
<i v-if="onlyOneChild.meta.icon" :class="onlyOneChild.meta.icon" />
<template #title>
<span>{{ onlyOneChild.meta.title }}</span>
</template>
</el-menu-item>
</template>
<!-- 场景 2: 拥有多个子菜单项,渲染为可展开的 el-sub-menu 并递归自身 -->
<el-sub-menu v-else :index="resolvePath(item.path)" popper-append-to-body>
<template #title>
<i v-if="item.meta?.icon" :class="item.meta.icon" />
<span>{{ item.meta?.title }}</span>
</template>
<sidebar-item
v-for="child in item.children"
:key="child.path"
:item="child"
:base-path="resolvePath(child.path)"
/>
</el-sub-menu>
</template>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
const props = defineProps<{
item: RouteRecordRaw;
basePath: string;
}>();
const router = useRouter();
const onlyOneChild = ref<any>(null);
// 判断是否只有一个可展示的子节点
function hasOneShowingChild(children: RouteRecordRaw[] = [], parent: RouteRecordRaw) {
const showingChildren = children.filter(item => !item.meta?.hidden);
if (showingChildren.length === 1) {
onlyOneChild.value = showingChildren[0];
return true;
}
if (showingChildren.length === 0) {
onlyOneChild.value = { ...parent, path: '', noShowingChildren: true };
return true;
}
return false;
}
// 路径规范化合并
function resolvePath(routePath: string) {
if (/^(https?:|mailto:|tel:)/.test(routePath)) {
return routePath;
}
const cleanBase = props.basePath.replace(/\/+$/, '');
const cleanPath = routePath.replace(/^\/+/, '');
return `${cleanBase}/${cleanPath}`.replace(/\/+/g, '/');
}
function navigateTo(path: string) {
router.push(path);
}
</script>
七、关键排查对照:四大典型生产陷阱与防御方案
动态路由的实施过程中,由于路由生命周期与状态管理的异步交织,容易触发几种典型疑难故障。开发时建议对照以下检查矩阵进行防御性编码:
1. 角色切换导致权限残留与泄漏问题
在单页应用(SPA)中,用户点击“退出登录”后如果直接通过 router.push('/login') 引导回登录页,Vue Router 实例底层注册的路由规则依然保留在内存中。若随后在同一个浏览器窗口登录另一个权限较低的用户(例如从 admin 切换到 editor),该低权限用户在地址栏手动输入高权限页面路径仍能正常加载!
标准解法:在退出登录的 Action 中,执行路由重置。在 Vue Router 4 中,最简洁彻底的方式是使用新的空 Router 重新实例化,或遍历 permissionStore.addRoutes 调用 router.removeRoute(route.name) 逐一注销动态添加的命名路由。
// src/router/index.ts - 路由重置机制
import { createRouter, createWebHistory } from 'vue-router';
import { constantRoutes } from './routes';
export function resetRouter(routerInstance: any) {
const newRouter = createRouter({
history: createWebHistory(),
routes: constantRoutes
});
// 重置底层 matcher 规则
routerInstance.matcher = (newRouter as any).matcher;
}
八、实战验证清单与测试用例
完成全套架构搭建后,在部署生产前必须严格执行以下 5 项测试用例,全部通过方能确认权限闭环搭建成功:
- 未认证拦截测试:清空 LocalStorage 与 Cookie 中的 Token,在地址栏直接输入
/system/user回车。期望表现:页面立即重定向至/login?redirect=%2Fsystem%2Fuser,白名单未越权放行。 - 多角色差异化渲染测试:分别使用
auditor与admin账号登录系统。期望表现:auditor登录后侧边栏仅展示“工作台”和“财务审计”,不存在“系统管理”;admin登录后展示全部完整菜单。 - 深层受控页面直接刷新测试:以
auditor身份登录后,访问/finance/payroll并连续按 F5 刷新页面。期望表现:页面原地正常重载,无白屏、无死循环提示,侧边栏选中高亮位置保持正确。 - 非法 URL 手动越权访问测试:以
auditor身份登录后,在浏览器地址栏强制手动输入/system/user并回车。期望表现:由于/system/user根本未被注入到当前 Router 实例中,命中通配符路由,页面正确跳转至 404 或拦截至 403 错误页。 - 账号登出切换隔离测试:
admin账号登出系统,紧接着在当前窗口登录editor账号。期望表现:原admin拥有的高权限菜单与路由完全销毁,无残留泄漏。
九、总结与演进思考
动态路由权限控制与菜单生成并不是孤立的 UI 特性,而是整个前端安全体系的第一道防线。本文所落地的“静态基础路由 + 权限路由过滤 + router.addRoute() 动态挂载 + Pinia 数据驱动递归菜单”架构,通过严密的生命周期守卫与防御性编码,能够从根本上规避未授权访问与状态错乱问题。
在实际生产扩展中,还可将此套机制与按钮级细粒度自定义指令(v-permission)结合使用,针对页面内的增删改查按钮实施双重受控,构建出坚固、规范的企业级权限全生命周期管理体系。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。