企业级RBAC 实战(八)手撸后端动态路由,拒绝前端硬编码
在企业级后台中,硬编码路由(写死在 router/index.js)是维护的噩梦。本文将深入讲解如何根据用户权限动态生成侧边栏菜单。我们将构建后端的 getRouters 递归接口,并在前端利用 Vite 的 import.meta.glob 实现组件的动态挂载,最后彻底解决路由守卫中经典的“死循环”和“刷新白屏”问题。
学习之前先浏览 前置专栏文章
一、 引言:为什么要做动态路由?
在简单的后台应用中,我们通常会在前端 router/routes.ts 中写死所有路由。但在 RBAC(基于角色的权限控制)模型下,这种做法有两个致命缺陷:
- 安全性低:普通用户虽然看不到菜单,但如果在浏览器地址栏手动输入 URL,依然能进入管理员页面。
- 维护成本高:每次新增页面都要修改前端代码并重新打包部署。
目标:前端只保留“登录”和“404”等基础页面,其他所有业务路由由后端根据当前用户的角色权限动态返回。
二、 后端实现:构建路由树 (getRouters)
后端的核心任务是:查询当前用户的菜单 -> 过滤掉隐藏的/无权限的 -> 组装成 Vue Router 需要的 JSON 树。
1. 数据结构转换
我们在上一篇设计了 sys_menus 表。Vue Router 需要的结构包含 path, component, meta 等字段。我们需要一个递归函数将扁平的数据库记录转为树形结构。
文件:routes/menu.js
// 辅助函数:将数据库扁平数据转为树形结构
function buildTree(items, parentId = 0) {
const result = []
for (const item of items) {
// 兼容字符串和数字的 ID 对比
if (item.parent_id == parentId) {
// 组装 Vue Router 标准结构
const route = {
name: toCamelCase(item.path), // 自动生成驼峰 Name
path: item.path,
hidden: item.hidden === 1, // 数据库 1/0 转布尔
component: item.component, // 此时还是字符串,如 "system/user/index"
// 只有当 redirect 有值时才添加该字段
...(item.redirect && { redirect: item.redirect }),
// 只有当 always_show 为 1 时才添加,并转为布尔
...(item.alwaysShow === 1 && { alwaysShow: true }),
meta: {
title: item.menu_name,
icon: item.icon,
noCache: item.no_cache === 1
}
}
const children = buildTree(items, item.id)
if (children.length > 0) {
route.children = children
}
result.push(route)
}
}
return result
}
2. 接口实现
这里有一个关键逻辑:上帝模式与普通模式的区别。
- Admin:直接查 sys_menus 全表(排除被物理删除的)。
- 普通用户:通过 sys_users -> sys_roles -> sys_role_menus -> sys_menus 进行四表联查,只获取拥有的权限。
文件 route/menu.js
router.get('/getRouters', authMiddleware, async (req, res, next) => {
try {
const userId = req.user.userId
const { isAdmin } = req.user
let sql = ''
let params = []
const baseFields = `m.id, m.parent_id, m.menu_name, m.path, m.component, m.icon, m.hidden`
if (isAdmin) {
// 管理员:看所有非隐藏菜单
sql = `SELECT ${baseFields} FROM sys_menus m WHERE m.hidden = 0 ORDER BY m.sort ASC`
} else {
// 普通用户:通过中间表关联查询
sql = `
SELECT ${baseFields}
FROM sys_menus m
LEFT JOIN sys_role_menus rm ON m.id = rm.menu_id
LEFT JOIN sys_users u ON u.role_id = rm.role_id
WHERE u.id = ? AND m.hidden = 0
ORDER BY m.sort ASC
`
params.push(userId)
}
const [rows] = await pool.query(sql, params)
const menuTree = buildTree(rows)
res.json({ code: 200, data: menuTree })
} catch (err) {
next(err)
}
})
三、 前端实现:组件动态加载
前端拿到后端的 JSON 后,最大的难点在于:后端返回的 component 是字符串 "system/user/index",而 Vue Router 需要的是一个 Promise 组件对象 () => import(...) 。
在 Webpack 时代我们要用 require.context,而在 Vite 中,我们要用 import.meta.glob。
1. Store 逻辑 (store/modules/permission.ts)
import { defineStore } from 'pinia'
import { constantRoutes } from '@/router'
import { getRouters } from '@/api/menu'
import Layout from '@/layout/index.vue'
// 1. Vite 核心:一次性匹配 views 目录下所有 .vue 文件
// 结果类似: { '../../views/system/user.vue': () => import(...) }
const modules = import.meta.glob('../../views/**/*.vue')
export const usePermissionStore = defineStore('permission', {
state: () => ({
routes: [], // 完整路由(侧边栏用)
addRoutes: [], // 动态路由(router.addRoute用)
sidebarRouters: [] // 侧边栏菜单
}),
actions: {
async generateRoutes() {
// 请求后端
const res: any = await getRouters()
const sdata = JSON.parse(JSON.stringify(res.data))
// 转换逻辑
const rewriteRoutes = filterAsyncRouter(sdata)
this.addRoutes = rewriteRoutes
this.sidebarRouters = constantRoutes.concat(rewriteRoutes)
return rewriteRoutes
}
}
})
// 遍历后台传来的路由字符串,转换为组件对象
function filterAsyncRouter(asyncRouterMap: any[]) {
return asyncRouterMap.filter(route => {
if (route.component) {
if (route.component === 'Layout') {
route.component = Layout
} else {
// 核心:根据字符串去 modules 里找对应的 import 函数
route.component = loadView(route.component)
}
}
// ... 递归处理 children
if (route.children && route.children.length) {
route.children = filterAsyncRouter(route.children)
}
return true
})
}
export const loadView = (view: string) => {
let res
for (const path in modules) {
// 路径匹配逻辑:从 ../../views/system/user.vue 中提取 system/user
const dir = path.split('views/')[1].split('.vue')[0]
if (dir === view) {
res = () => modules[path]()
}
}
return res
}
四、 核心难点:路由守卫与“死循环”
在 src/permission.ts 中,我们需要拦截页面跳转,如果是第一次进入,则请求菜单并添加到路由中。
1. 经典死循环问题
很多新手会这样写判断:
// ❌ 错误写法
if (userStore.roles.length === 0) {
// 去拉取用户信息 -> 生成路由 -> next()
}
Bug 场景:如果新建了一个没有任何角色的用户 user01,后端返回的 roles 是空数组。
- roles.length 为 0,进入 if。
- 拉取信息,发现还是空数组。
- next(to) 重定向,重新进入守卫。
- roles.length 依然为 0... 死循环,浏览器崩溃。
2. 解决方案:引入 isInfoLoaded 状态位
我们在 userStore 中增加一个 isInfoLoaded 布尔值,专门标记“是否已经尝试过拉取用户信息”
文件:src/permission.ts
import router from '@/router'
import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission' // 引入新的 store
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
NProgress.configure({ showSpinner: false })
const whiteList = ['/login', '/404']
router.beforeEach(async (to, from, next) => {
NProgress.start()
const userStore = useUserStore()
const permissionStore = usePermissionStore()
if (userStore.token) {
if (to.path === '/login') {
next({ path: '/' })
NProgress.done()
} else {
// 判断当前用户是否已拉取完 user_info 信息
// 这里我们可以简单判断:如果 userStore.userInfo.roles.length==0 动态添加的菜单长度为0,说明还没请求菜单,但似乎 这么写 如果 用户没角色 会陷入死循环
if (!userStore.isInfoLoaded) {
try {
// 2. 生成动态路由 (后端请求)
const userInfo = await userStore.getInfo()
console.log('userInfo--', userInfo)
if (userInfo && userInfo.data.id) {
const accessRoutes = await permissionStore.generateRoutes()
// // 3. 后端返回的路由
console.log('accessRoutes', accessRoutes)
// 4. 动态添加路由
accessRoutes.forEach((route) => {
router.addRoute(route)
})
}
// 4. 确保路由添加完成 (Hack方法)
next({ path: to.path, query: to.query, replace: true })
} catch (err) {
console.log('userinfo -err', err)
userStore.logout()
next(`/login?redirect=${to.path}`)
NProgress.done()
}
} else {
next()
}
}
} else {
if (whiteList.includes(to.path)) {
console.log('to.path', to.path)
next()
} else {
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
})
router.afterEach(() => {
NProgress.done()
})
五、 侧边栏递归渲染
最后一步是将 sidebarRouters 渲染到左侧菜单.
这里需要注意一个细节:el-tree 或 el-menu 的父节点折叠问题。
如果一个目录只有一个子菜单(例如“首页”),我们通常希望直接显示子菜单,不显示父目录。
文件 layout/components/slideBar/index.vue
<template>
<div :class="classObj">
<Logo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar wrap-class="scrollbar-wrapper" :class="sideTheme">
<el-menu
router
:default-active="activeMenu"
:collapse="isCollapse"
:background-color="
settingsStore.sideTheme === 'dark'
? variables.menuBackground
: variables.menuLightBackground
"
:text-color="
settingsStore.sideTheme === 'dark'
? variables.menuColor
: variables.menuLightColor
"
:active-text-color="theme"
:unique-opened="true"
:collapse-transition="false"
>
<template v-for="item in slidebarRouters" :key="item.path">
<!-- 如果只有一个子菜单,直接显示子菜单 -->
<el-menu-item
v-if="item.children && item.children.length === 1"
:index="item.path + '/' + item.children[0].path"
>
<el-icon v-if="item.children[0].meta?.icon">
<component :is="item.children[0].meta.icon" />
</el-icon>
<span>{{ item.children[0].meta.title }}</span>
</el-menu-item>
<!-- 如果有多个子菜单,显示下拉菜单 -->
<el-sub-menu
v-else-if="item.children && item.children.length > 1"
:index="item.path"
>
<template #title>
<el-icon v-if="item.meta?.icon">
<component :is="item.meta.icon" />
</el-icon>
<span>{{ item.meta.title }}</span>
</template>
<el-menu-item
v-for="subItem in item.children"
:key="subItem.path"
:index="item.path + '/' + subItem.path"
>
<el-icon v-if="subItem.meta?.icon">
<component :is="subItem.meta.icon" />
</el-icon>
<span>{{ subItem.meta.title }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 如果没有子菜单,直接显示当前菜单 -->
<el-menu-item v-else :index="item.path">
<el-icon v-if="item.meta?.icon">
<component :is="item.meta.icon" />
</el-icon>
<span>{{ item.meta.title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-scrollbar>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Logo from './Logo.vue'
import { useRoute } from 'vue-router'
import variables from '@/assets/styles/var.module.scss'
const route = useRoute()
import { useSettingsStore } from '@/store/modules/settings'
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()
const sideTheme = computed(() => settingsStore.sideTheme)
const theme = computed(() => settingsStore.theme)
const showLogo = computed(() => settingsStore.showLogo)
const isCollapse = computed(() => !appStore.sidebar.opened)
const classObj = computed(() => ({
dark: sideTheme.value === 'dark',
light: sideTheme.value === 'light',
'has-logo': settingsStore.showLogo,
}))
const slidebarRouters = computed(() =>
permissionStore.sidebarRouters.filter((item) => {
return !item.hidden
})
)
console.log('slidebarRouters', slidebarRouters.value)
// 激活菜单
const activeMenu = computed(() => {
const { path } = route
return path
})
</script>
<style scoped lang="scss">
#app {
.main-container {
height: 100%;
transition: margin-left 0.28s;
margin-left: $base-sidebar-width;
position: relative;
}
.sidebarHide {
margin-left: 0 !important;
}
.sidebar-container {
-webkit-transition: width 0.28s;
transition: width 0.28s;
width: $base-sidebar-width !important;
background-color: $base-menu-background;
height: 100%;
position: fixed;
font-size: 0px;
top: 0;
bottom: 0;
left: 0;
z-index: 1001;
overflow: hidden;
-webkit-box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
// reset element-ui css
.horizontal-collapse-transition {
transition:
0s width ease-in-out,
0s padding-left ease-in-out,
0s padding-right ease-in-out;
}
.scrollbar-wrapper {
overflow-x: hidden !important;
}
.el-scrollbar__bar.is-vertical {
right: 0px;
}
.el-scrollbar {
height: 100%;
}
&.has-logo {
.el-scrollbar {
height: calc(100% - 50px);
}
}
.is-horizontal {
display: none;
}
a {
display: inline-block;
width: 100%;
overflow: hidden;
}
.svg-icon {
margin-right: 16px;
}
.el-menu {
border: none;
height: 100%;
width: 100% !important;
}
.el-menu-item,
.menu-title {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.el-menu-item .el-menu-tooltip__trigger {
display: inline-block !important;
}
// menu hover
.sub-menu-title-noDropdown,
.el-sub-menu__title {
&:hover {
background-color: rgba(0, 0, 0, 0.06) !important;
}
}
& .theme-dark .is-active > .el-sub-menu__title {
color: $base-menu-color-active !important;
}
& .nest-menu .el-sub-menu > .el-sub-menu__title,
& .el-sub-menu .el-menu-item {
min-width: $base-sidebar-width !important;
&:hover {
background-color: rgba(0, 0, 0, 0.06) !important;
}
}
& .theme-dark .nest-menu .el-sub-menu > .el-sub-menu__title,
& .theme-dark .el-sub-menu .el-menu-item {
background-color: $base-sub-menu-background !important;
&:hover {
background-color: $base-sub-menu-hover !important;
}
}
}
.hideSidebar {
.sidebar-container {
width: 54px !important;
}
.main-container {
margin-left: 54px;
}
.sub-menu-title-noDropdown {
padding: 0 !important;
position: relative;
.el-tooltip {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
}
}
.el-sub-menu {
overflow: hidden;
& > .el-sub-menu__title {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
}
}
.el-menu--collapse {
.el-sub-menu {
& > .el-sub-menu__title {
& > span {
height: 0;
width: 0;
overflow: hidden;
visibility: hidden;
display: inline-block;
}
& > i {
height: 0;
width: 0;
overflow: hidden;
visibility: hidden;
display: inline-block;
}
}
}
}
}
.el-menu--collapse .el-menu .el-sub-menu {
min-width: $base-sidebar-width !important;
}
// mobile responsive
.mobile {
.main-container {
margin-left: 0px;
}
.sidebar-container {
transition: transform 0.28s;
width: $base-sidebar-width !important;
}
&.hideSidebar {
.sidebar-container {
pointer-events: none;
transition-duration: 0.3s;
transform: translate3d(-$base-sidebar-width, 0, 0);
}
}
}
.withoutAnimation {
.main-container,
.sidebar-container {
transition: none;
}
}
}
// when menu collapsed
.el-menu--vertical {
& > .el-menu {
.svg-icon {
margin-right: 16px;
}
}
.nest-menu .el-sub-menu > .el-sub-menu__title,
.el-menu-item {
&:hover {
// you can use $sub-menuHover
background-color: rgba(0, 0, 0, 0.06) !important;
}
}
// the scroll bar appears when the sub-menu is too long
> .el-menu--popup {
max-height: 100vh;
overflow-y: auto;
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}
}
.dark {
background-color: $base-menu-background !important;
}
.light {
background-color: $base-menu-light-background !important;
}
.scrollbar-wrapper {
overflow-x: hidden !important;
}
.has-logo {
.el-scrollbar {
height: calc(100% - 50px);
}
}
</style>
六、 总结与下篇预告
通过本篇实战,我们实现了:
- 后端:根据角色权限过滤菜单数据。
- 前端:利用 Vite 的 glob 导入特性,将后端字符串动态转为前端组件。
- 守卫:通过 isInfoLoaded 状态位完美解决了空权限用户的死循环问题。
现在的系统已经具备了动态菜单的能力。但是,如何更方便地管理这些用户和角色呢? 如果用户很多,列表怎么分页?怎么模糊搜索?
下一篇:《企业级全栈 RBAC 实战 (九):用户管理与 SQL 复杂查询优化》,我们将深入 Element Plus 表格组件与 MySQL 分页查询的结合。