Vue.js 动态路由权限控制与菜单生成实战方案

作者:山岚时间:2026-11-03 13:46:47浏览:639

深入解析 Vue.js 企业级动态路由权限控制与菜单生成实战方案,涵盖基础路由与权限路由拆分、状态管理与过滤算法、全局路由守卫拦截、防刷新丢失与递归菜单渲染。

在一个典型的企业中后台系统里,往往同时存在系统管理员、业务运营和财务审计等多种角色。如果将系统所有的功能路由全部硬编码在前端路由表内,未授权的运营人员即使在页面侧边栏看不到“财务审计”入口,只要在浏览器地址栏手动输入 /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(前端配置路由表 + 角色过滤)在稳定性、开发效率与维护成本之间取得了最佳平衡。下面我们围绕该方案展开系统全景架构与数据流设计。

Vue.js 动态路由权限控制与菜单生成实战方案
图 1:Vue.js 动态路由权限控制与菜单生成全景数据流图

二、四步闭环实施全流程

构建动态路由与权限菜单并不是单个函数的编写,而是一个贯穿应用生命周期的系统工程。整个闭环可分解为四个清晰的实施阶段:

Vue.js 动态路由权限控制与菜单生成实战方案
图 2:动态路由权限控制与菜单生成实施流程图

三、步骤一:路由结构拆分与 Meta 权限元数据设计

第一步是将系统的路由划分为两张独立的配置表:公共静态路由表(constantRoutes)与动态权限路由表(asyncRoutes)。两者的存放位置与初始化逻辑完全不同:

  • 公共静态路由(constantRoutes):无需任何登录凭证或权限即可访问的公开页面。例如登录页 /login、无权提示页 /403,以及基础重定向路由。这些路由在创建 Vue Router 实例时直接传入 routes 参数。
  • 动态权限路由(asyncRoutes):受权限保护的业务页面模块。每个路由项通过 meta.roles 数组明确声明哪些角色具备访问资格,并通过 meta.title、meta.icon 和 meta.hidden 提供菜单渲染所需的元数据。
⚠️ 重要避坑提醒:通配符 404 路由绝不能写在 constantRoutes 中

初学者极易将 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,筛选出该用户有权访问的子路由树。

在设计过滤算法时,必须满足深度优先遍历与不可变数据克隆原则,防止污染全局定义的原始路由配置对象。算法规则如下:

  1. 若当前路由未声明 meta.roles,默认视作通用路由,直接放行。
  2. 若声明了 meta.roles,使用 roles.some(role => route.meta.roles.includes(role)) 判定当前用户是否命中其一。
  3. 若当前节点命中且存在 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)。守卫必须重新发起接口请求拉取角色,计算路由表并完成挂载。
  • 解决路由注入竞态:动态添加路由必须通过特定的导航中断指令刷新上下文,避免白屏。
Vue.js 动态路由权限控制与菜单生成实战方案
图 3:Vue Router 全局前置守卫鉴权决策与生命周期流程图

核心技术痛点:为什么必须使用 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>

七、关键排查对照:四大典型生产陷阱与防御方案

动态路由的实施过程中,由于路由生命周期与状态管理的异步交织,容易触发几种典型疑难故障。开发时建议对照以下检查矩阵进行防御性编码:

Vue.js 动态路由权限控制与菜单生成实战方案
图 4:动态路由权限控制四大典型陷阱与排查对照图

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)结合使用,针对页面内的增删改查按钮实施双重受控,构建出坚固、规范的企业级权限全生命周期管理体系。

作者最新文章
相关文章
更多