后端动态路由 + RBAC 权限控制全实战(Vue3 + TS)

🔐 前端架构进阶:后端动态路由 + 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. 解耦:新增用户只需分配角色,无需逐个配置权限。
  2. 灵活:调整某类人的权限,只需修改角色配置,批量生效。
  3. 安全:配合后端动态路由,彻底杜绝前端"猜路径"越权访问。

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>

六、常见坑点与最佳实践

  1. 刷新页面路由丢失?

    • 现象:F5 刷新后,跳转到 404。
    • 原因addRoute 是内存操作,刷新后重置。
    • 解决 :必须在 router.beforeEach 中判断 !userStore.hasGetUserInfo,如果为真,先拉取路由再 next({ ...to, replace: true })
  2. 组件路径映射错误?

    • 建议 :后端返回的 component 路径最好与前端 views 目录结构保持严格一致,或者建立明确的映射字典。避免后端写 UserIndex,前端找不到文件。
  3. 404 页面的陷阱

    • 注意 :动态路由添加后,必须最后添加一个通配符 :pathMatch(.*)* 指向 404 页面。否则用户访问不存在的路由会停留在空白页,而不是跳转 404。
    • 顺序 :先 addRoute 业务路由 -> 最后 addRoute 全局 404。
  4. 安全性提醒

    • 前端隐藏按钮只是"防君子"。所有接口必须在后端进行权限校验。如果用户通过 Postman 调用删除接口,后端必须拦截。

七、总结

采用 "后端动态返回路由表" 的 RBAC 方案,实现了真正的配置化权限管理

  1. 前端轻量化:无需维护庞大的路由表,新增页面无需改前端代码。
  2. 安全可控:权限收口在后端,前端只做展示层控制。
  3. 体验优良:千人千面,不同角色登录后看到完全不同的系统界面。

这套架构是中大型后台系统的标配,掌握它,你的前端架构能力将迈上一个新台阶!

参考资料:

👍 觉得有用请点赞、收藏、关注,获取更多前端架构干货!

相关推荐
Cobyte2 小时前
面试官:大模型是怎么调用工具的呢 ?
前端·后端·aigc
@大迁世界2 小时前
32.CSS魔术师 (CSS Houdini)
前端·css·人工智能·tensorflow·houdini
李白的天不白2 小时前
ERROR Failed to compile with 9 errors 以来报错文件配置问题 缓存顽固问题
前端·缓存
cx28892 小时前
20260305 位于两台不同电脑的chrome局域网全程调试配置
前端·chrome·爬虫
程序员敲代码吗2 小时前
探索Vite:新潮流下的前端开发未来
前端·xss
Han.miracle2 小时前
JavaScript 中 var、let、const 的核心区别与实战应用
开发语言·前端·javascript
徐小夕3 小时前
被CRUD拖垮的第5年,我用Cursor 一周"复仇":pxcharts-vue开源,一个全栈老兵的AI编程账本
前端·vue.js·github
Wect5 小时前
LeetCode 39. 组合总和:DFS回溯解法详解
前端·算法·typescript
Wect5 小时前
LeetCode 46. 全排列:深度解析+代码拆解
前端·算法·typescript