🔐 前端架构进阶:后端动态路由 + RBAC 权限控制全实战(Vue3 + TS)
导读 :在企业级中后台系统中,权限控制是安全基石。传统的"前端硬编码路由表 + 过滤"模式存在维护成本高、安全性弱等痛点。"后端返回动态路由表" 才是大型项目的标准解法!本文将以高级前端开发 视角,深度解析 RBAC 模型 ,并手把手带你实现一套菜单级 + 按钮级 的精细化权限控制系统。核心亮点:路由表完全由后端根据角色动态生成,前端零配置维护!
一、什么是 RBAC?为什么它是权限系统的黄金标准?
1.1 核心概念
RBAC (Role-Based Access Control) ,即基于角色的访问控制 。
它颠覆了"用户直接绑定权限"的传统模式,引入了"角色"作为中间层:
- 用户 (User):系统的使用者(如:张三)。
- 角色 (Role) :职位的抽象(如:
admin,editor,viewer)。 - 权限 (Permission) :具体的资源操作标识(如:
sys:user:add,menu:dashboard)。
关系链:用户 👉 分配角色 👉 角色拥有权限 👉 权限控制资源。
1.2 为什么要用 RBAC?
- 解耦:新增用户只需分配角色,无需逐个配置权限。
- 灵活:调整某类人的权限,只需修改角色配置,批量生效。
- 安全:配合后端动态路由,彻底杜绝前端"猜路径"越权访问。
1.3 实现原理流程图
路由实例 后端 (API) 前端 (Vue) 用户 路由实例 后端 (API) 前端 (Vue) 用户 用户刷新页面? 重新触发流程,恢复路由 输入账号密码登录 请求登录接口 返回 Token + 用户信息 (含角色) 存储 Token 携带 Token 请求 "获取用户路由/菜单" 接口 返回动态路由树 (JSON) 递归处理路由数据 (转换组件路径) router.addRoute() 动态注册路由 跳转首页 (渲染侧边栏菜单)
二、核心策略:后端动态路由 vs 前端静态过滤
| 维度 | 前端静态过滤 (传统) | 后端动态路由 (推荐) |
|---|---|---|
| 路由表维护 | 前端写死所有路由,通过 meta.roles 过滤 |
前端只留白名单/404,路由表完全由后端返回 |
| 安全性 | 低。懂技术的人可手动拼凑 URL 访问隐藏页面 | 高。后端不返回的路由,前端根本不存在,访问即 404 |
| 维护成本 | 高。新增页面需前后端同时改代码 | 低。后端配置数据库,前端无感上线** |
| 菜单生成 | 前端遍历路由表生成菜单 | 直接用后端返回的树形结构渲染菜单 |
💡 本文重点 :我们将采用 "后端动态路由" 方案,这是阿里、字节等大厂中后台的标准实践。
三、实战演练:Vue3 + TypeScript + Pinia 完整实现
3.1 后端数据结构约定
假设后端 /api/getUserMenus 接口返回如下结构(关键):
json
{
"code": 200,
"data": [
{
"path": "/system",
"component": "Layout", // 特殊标识,代表布局组件
"redirect": "/system/user",
"meta": { "title": "系统管理", "icon": "setting" },
"children": [
{
"path": "user",
"component": "system/user/index", // 前端需映射为 views/system/user/index.vue
"name": "UserManage",
"meta": { "title": "用户管理", "icon": "user" },
"permissions": ["sys:user:list", "sys:user:add"] // 按钮权限标识
}
]
},
{
"path": "/dashboard",
"component": "dashboard/index",
"name": "Dashboard",
"meta": { "title": "首页", "icon": "home" }
}
]
}
3.2 第一步:路由守卫与动态注册 (核心逻辑)
我们需要在 src/router/index.ts 中拦截登录后的第一次访问,拉取路由并注册。
typescript
// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import { useUserStore } from '@/stores/user';
import { usePermissionStore } from '@/stores/permission';
const router = createRouter({
history: createWebHistory(),
// 初始只放公共路由:登录页、404、403
routes: [
{ path: '/login', name: 'Login', component: () => import('@/views/login/index.vue') },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/error/404.vue') }
]
});
// 白名单
const whiteList = ['/login'];
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore();
const permissionStore = usePermissionStore();
const hasToken = userStore.token;
if (hasToken) {
if (to.path === '/login') {
next({ path: '/' });
} else {
// 判断是否已获取过动态路由
if (!userStore.hasGetUserInfo) {
try {
// 1. 获取用户基本信息 (含角色)
await userStore.getUserInfo();
// 2. 【关键】获取动态路由表
// 后端根据当前用户角色,返回他能看到的路由树
const accessRoutes = await permissionStore.generateRoutes();
// 3. 将动态路由添加到 router 实例
accessRoutes.forEach((route: RouteRecordRaw) => {
router.addRoute(route);
});
// 4. 确保添加完成,replace 当前路由以触发新的匹配
next({ ...to, replace: true });
} catch (error) {
// 获取失败,强制登出
await userStore.resetToken();
next(`/login?redirect=${to.path}`);
}
} else {
next();
}
}
} else {
// 无 Token
if (whiteList.includes(to.path)) {
next();
} else {
next(`/login?redirect=${to.path}`);
}
}
});
export default router;
3.3 第二步:Permission Store 处理路由转换
后端返回的 component 是字符串(如 "system/user/index"),前端需要将其转换为真实的 import() 组件对象。
typescript
// src/stores/permission.ts
import { defineStore } from 'pinia';
import { getMenusApi } from '@/api/menu'; // 假设的后端接口
import { RouteRecordRaw } from 'vue-router';
import Layout from '@/layout/index.vue';
// 本地组件映射表 (可根据项目规范自动扫描,这里演示手动映射)
const modules = import.meta.glob('../views/**/*.vue');
export const usePermissionStore = defineStore('permission', () => {
// 将后端字符串路径转换为组件对象
const loadComponent = (path: string) => {
if (path === 'Layout') return Layout;
// 假设后端传的是 'system/user/index',对应 views/system/user/index.vue
const key = `../views/${path}.vue`;
if (modules[key]) {
return modules[key];
}
console.warn(`未找到组件:${key}`);
return undefined;
};
// 递归处理路由树
const filterAsyncRoutes = (routes: any[]): RouteRecordRaw[] => {
const res: RouteRecordRaw[] = [];
routes.forEach(route => {
const tmp: any = { ...route };
// 转换 component
if (tmp.component) {
if (tmp.component === 'Layout') {
tmp.component = Layout;
} else {
tmp.component = loadComponent(tmp.component);
}
}
// 递归处理 children
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children);
}
// 只有 component 存在才加入路由表(防止无效数据)
if (tmp.component) {
res.push(tmp as RouteRecordRaw);
}
});
return res;
};
// 核心动作:获取并生成路由
const generateRoutes = async (): Promise<RouteRecordRaw[]> => {
// 调用后端接口
const { data } = await getMenusApi();
// data 是后端返回的树形结构
// 转换并返回
const accessedRoutes = filterAsyncRoutes(data);
return accessedRoutes;
};
return {
generateRoutes
};
});
四、按钮级权限控制:自定义指令 v-permission
菜单有了,但页面内的"删除"、"编辑"按钮如何控制?我们使用自定义指令。
4.1 封装指令
typescript
// src/directives/permission.ts
import { DirectiveBinding } from 'vue';
import { useUserStore } from '@/stores/user';
export const permission = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const { value } = binding; // 获取传入的权限码,如 'sys:user:add'
const userStore = useUserStore();
// 获取当前用户的所有权限列表 (通常在 getUserInfo 时存入 store)
const allPermissions = userStore.permissions || [];
if (value && value instanceof Array && value.length > 0) {
// 支持数组:只要拥有其中一个权限即可显示
const hasAuth = value.some(perm => allPermissions.includes(perm));
if (!hasAuth) {
// 无权限,移除 DOM 元素
el.parentNode && el.parentNode.removeChild(el);
}
} else if (typeof value === 'string') {
// 支持单个字符串
if (!allPermissions.includes(value)) {
el.parentNode && el.parentNode.removeChild(el);
}
} else {
throw new Error('v-permission 需要传入 string 或 array 类型的权限码');
}
}
};
4.2 注册与使用
注册 (main.ts):
typescript
import { permission } from './directives/permission';
app.directive('permission', permission);
使用 (UserList.vue):
vue
<template>
<div class="user-container">
<h1>用户列表</h1>
<!-- 只有拥有 'sys:user:add' 权限才能看到 -->
<el-button
type="primary"
v-permission="'sys:user:add'"
@click="handleAdd"
>
新增用户
</el-button>
<!-- 拥有 'sys:user:edit' 或 'sys:user:delete' 任一权限即可看到 -->
<el-button
type="danger"
v-permission="['sys:user:edit', 'sys:user:delete']"
@click="handleBatchDelete"
>
批量删除
</el-button>
<el-table :data="tableData">
<el-table-column prop="name" label="姓名" />
<el-table-column label="操作">
<template #default="scope">
<!-- 行内按钮权限控制 -->
<el-button link v-permission="'sys:user:edit'">编辑</el-button>
<el-button link v-permission="'sys:user:delete'">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
五、菜单渲染:直接使用后端数据
由于路由表是后端生成的,侧边栏菜单也不需要前端去遍历 router.options.routes,直接使用 Store 中保存的后端原始数据(或稍作格式化)即可。
vue
<!-- components/SideMenu.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { usePermissionStore } from '@/stores/permission';
// 假设 generateRoutes 后,我们将原始数据也存了一份在 store 里用于渲染菜单
// 或者直接复用路由表逻辑生成菜单树
const permissionStore = usePermissionStore();
// 这里简化处理,实际项目中建议在 permissionStore 中单独存一份 menuTree
const menuList = computed(() => permissionStore.menuTree);
</script>
<template>
<el-menu :default-active="activeIndex">
<template v-for="item in menuList" :key="item.path">
<!-- 递归组件渲染多级菜单 -->
<SubMenu :item="item" />
</template>
</el-menu>
</template>
六、常见坑点与最佳实践
-
刷新页面路由丢失?
- 现象:F5 刷新后,跳转到 404。
- 原因 :
addRoute是内存操作,刷新后重置。 - 解决 :必须在
router.beforeEach中判断!userStore.hasGetUserInfo,如果为真,先拉取路由再next({ ...to, replace: true })。
-
组件路径映射错误?
- 建议 :后端返回的
component路径最好与前端views目录结构保持严格一致,或者建立明确的映射字典。避免后端写UserIndex,前端找不到文件。
- 建议 :后端返回的
-
404 页面的陷阱
- 注意 :动态路由添加后,必须最后添加一个通配符
:pathMatch(.*)*指向 404 页面。否则用户访问不存在的路由会停留在空白页,而不是跳转 404。 - 顺序 :先
addRoute业务路由 -> 最后addRoute全局 404。
- 注意 :动态路由添加后,必须最后添加一个通配符
-
安全性提醒
- 前端隐藏按钮只是"防君子"。所有接口必须在后端进行权限校验。如果用户通过 Postman 调用删除接口,后端必须拦截。
七、总结
采用 "后端动态返回路由表" 的 RBAC 方案,实现了真正的配置化权限管理:
- 前端轻量化:无需维护庞大的路由表,新增页面无需改前端代码。
- 安全可控:权限收口在后端,前端只做展示层控制。
- 体验优良:千人千面,不同角色登录后看到完全不同的系统界面。
这套架构是中大型后台系统的标配,掌握它,你的前端架构能力将迈上一个新台阶!
参考资料:
👍 觉得有用请点赞、收藏、关注,获取更多前端架构干货!