企业级,动态路由设计方案

129 阅读22分钟

背景与概述

在大型前端项目中,多角色、多权限的场景非常常见且复杂。随着业务规模扩大,用户角色和权限的多样化使得传统的静态路由配置难以满足这种灵活的需求。静态路由在项目初始化时就完全确定,无法根据用户身份动态调整,这导致了以下问题:

  1. 所有路由配置都会被加载,即使用户无权访问
  2. 权限控制逻辑分散,难以统一管理
  3. 代码冗余,影响应用性能

为解决这些问题,动态路由方案应运而生。动态路由是指根据用户权限和角色,在运行时动态生成和注册路由配置的技术。它不仅能实现按需加载和精确的权限控制,还能通过代码分割带来显著的性能优化,为用户提供更流畅的体验。

核心优势与技术价值

动态路由方案相比传统静态路由配置具有以下显著优势:

  1. 精确的权限隔离:不同角色只能访问其权限范围内的路由,系统会根据用户的权限数据动态构建路由表,确保用户界面只展示有权访问的功能模块
  2. 性能优化与按需加载:实现路由组件的懒加载,只加载用户有权访问的组件,大幅减少首屏加载时间和资源消耗,提升应用响应速度
  3. 灵活配置与可扩展性:支持通过后端接口动态调整路由结构,无需修改前端代码即可更新菜单和权限,便于系统功能扩展
  4. 增强的安全控制:在路由层面实现权限控制,防止未授权访问,同时可结合 API 权限验证形成双重防护机制,显著提升应用安全性
  5. 简化的权限管理:集中化的权限控制逻辑,便于维护和调试,降低开发复杂度

实现流程与架构设计

动态路由的实现涉及前后端协作,下图展示了完整的工作流程:

graph TD
    A[用户登录] --> B{权限校验}
    B -->|失败| H[重定向到登录页]
    B -->|成功| C[获取用户权限数据]
    C --> D[前端路由映射与过滤]
    D --> E[动态添加符合权限的路由]
    E --> F[渲染导航菜单与界面]
    F --> G[用户访问业务模块]

    subgraph 后端处理
    AA[权限管理系统] --> BB[用户角色管理]
    BB --> CC[权限数据生成]
    CC --> DD[权限API接口]
    end

    subgraph 前端处理
    EE[路由配置解析] --> FF[权限匹配逻辑]
    FF --> GG[动态路由注册]
    GG --> HH[菜单渲染组件]
    end

    C -.-> DD

关键流程说明

  1. 用户认证:用户提供凭证进行身份验证
  2. 权限获取:认证成功后,从后端获取该用户的权限数据
  3. 路由映射:将后端返回的权限数据与前端预设的路由配置进行匹配
  4. 动态注册:根据匹配结果,动态添加用户有权访问的路由
  5. 界面渲染:基于最终的路由配置渲染导航菜单和页面内容
  6. 安全访问:用户只能访问其权限范围内的功能模块

具体实现

1. 后端接口设计与数据结构

权限系统的后端设计需要考虑灵活性和可扩展性,通常包含以下核心接口:

1.1 权限数据接口

该接口在用户登录成功后调用,返回用户可访问的路由结构和权限列表:

// GET /api/user/permissions
{
  "code": 200,
  "message": "获取成功",
  "data": {
    // 用户可访问的路由结构
    "routes": [
      {
        "name": "Dashboard",
        "path": "/dashboard",
        "icon": "dashboard-icon",
        "children": [
          {
            "name": "Analytics",
            "path": "/dashboard/analytics",
            "icon": "chart-icon"
          },
          {
            "name": "Overview",
            "path": "/dashboard/overview",
            "icon": "overview-icon"
          }
        ]
      },
      {
        "name": "Settings",
        "path": "/settings",
        "icon": "settings-icon"
      }
    ],
    // 用户拥有的权限编码列表
    "permissions": ["view_dashboard", "edit_settings", "view_analytics"],
    // 用户角色信息
    "roles": ["admin", "editor"]
  }
}

1.2 权限数据结构说明

字段类型说明
routesArray用户可访问的路由配置数组
routes[].nameString路由名称,用于显示和路由匹配
routes[].pathString路由路径
routes[].iconString菜单图标标识
routes[].childrenArray子路由配置数组
permissionsArray用户拥有的权限编码列表
rolesArray用户角色列表

2. 前端路由配置与实现

前端路由配置是动态路由实现的核心,需要区分静态路由和动态路由,并提供灵活的路由注册机制。

2.1 路由分类与基础配置

// router/index.js - 路由配置主文件,定义应用的路由结构
import { createRouter, createWebHashHistory } from "vue-router"; // 导入Vue Router核心函数,createWebHashHistory用于创建基于hash的历史记录
import store from "../store"; // 导入Vuex存储,用于获取和管理状态

/**
 * 路由配置分为两类:
 * 1. 静态路由(constantRoutes):无需权限验证,所有用户都可访问
 * 2. 动态路由(asyncRoutes):需要根据用户权限动态添加
 */

// 静态路由 - 无需权限验证的基础路由,这些路由对所有用户都可见
const constantRoutes = [
  {
    path: "/", // 根路径,应用的入口
    name: "home", // 路由名称,用于编程式导航
    component: () => import("../components/Layout.vue"), // 使用动态导入实现懒加载,提高首屏加载速度
    redirect: "/dashboard", // 重定向到仪表盘页面,用户访问根路径时自动跳转
    meta: {
      // 路由元数据,存储路由相关的额外信息
      title: "首页", // 页面标题,用于显示在浏览器标签和面包屑导航
      requiresAuth: false, // 标记不需要身份验证
      icon: "home-icon", // 菜单图标标识
    },
  },
  {
    path: "/login", // 登录页路径
    name: "login", // 路由名称
    component: () => import("../views/login/login.vue"), // 懒加载登录组件
    meta: {
      title: "登录", // 页面标题
      requiresAuth: false, // 不需要身份验证(否则会陷入循环)
      hideInMenu: true, // 在导航菜单中隐藏此项,登录页不应显示在菜单中
    },
  },
  {
    path: "/403", // 403禁止访问页面路径
    name: "forbidden", // 路由名称
    component: () => import("../views/error/403.vue"), // 懒加载403错误页组件
    meta: {
      title: "无权限访问", // 页面标题
      requiresAuth: false, // 不需要身份验证,错误页应该对所有人可见
      hideInMenu: true, // 在菜单中隐藏,错误页不应出现在导航菜单中
    },
  },
  {
    path: "/:pathMatch(.*)*", // 通配符路径,匹配所有未定义的路由
    name: "notFound", // 路由名称
    component: () => import("../views/error/404.vue"), // 懒加载404错误页组件
    meta: {
      title: "页面不存在", // 页面标题
      requiresAuth: false, // 不需要身份验证
      hideInMenu: true, // 在菜单中隐藏
    },
  },
];

// 动态路由 - 需要根据权限动态添加的路由,这些路由只对有特定权限的用户可见
const asyncRoutes = [
  {
    path: "/dashboard", // 仪表盘页面路径
    name: "dashboard", // 路由名称
    component: () => import("../views/dashboard/index.vue"), // 懒加载仪表盘主组件
    meta: {
      title: "仪表盘", // 页面标题
      requiresAuth: true, // 需要身份验证
      icon: "dashboard-icon", // 菜单图标
      permissions: ["view_dashboard"], // 查看此页面需要的权限编码
    },
    children: [
      // 子路由配置,将作为嵌套路由显示
      {
        path: "analytics", // 子路由路径,完整路径为/dashboard/analytics
        name: "analytics", // 路由名称
        component: () => import("../views/dashboard/analytics.vue"), // 懒加载分析页组件
        meta: {
          title: "数据分析", // 页面标题
          icon: "chart-icon", // 菜单图标
          permissions: ["view_analytics"], // 查看此页面需要的权限编码
        },
      },
      {
        path: "overview", // 子路由路径,完整路径为/dashboard/overview
        name: "overview", // 路由名称
        component: () => import("../views/dashboard/overview.vue"), // 懒加载概览页组件
        meta: {
          title: "总览", // 页面标题
          icon: "overview-icon", // 菜单图标
          permissions: ["view_overview"], // 查看此页面需要的权限编码
        },
      },
    ],
  },
  {
    path: "/project", // 项目管理页面路径
    name: "project", // 路由名称
    component: () => import("../views/project/index.vue"), // 懒加载项目管理主组件
    meta: {
      title: "项目管理", // 页面标题
      requiresAuth: true, // 需要身份验证
      icon: "project-icon", // 菜单图标
      permissions: ["project_edit", "project_operate_group"], // 查看此页面需要的权限编码,满足任一权限即可访问
    },
    children: [
      // 子路由配置
      {
        path: "list", // 子路由路径,完整路径为/project/list
        name: "projectList", // 路由名称
        component: () => import("../views/project/list.vue"), // 懒加载项目列表组件
        meta: {
          title: "项目列表", // 页面标题
          icon: "list-icon", // 菜单图标
          permissions: ["project_view"], // 查看此页面需要的权限编码
        },
      },
      {
        path: "create", // 子路由路径,完整路径为/project/create
        name: "projectCreate", // 路由名称
        component: () => import("../views/project/create.vue"), // 懒加载项目创建组件
        meta: {
          title: "创建项目", // 页面标题
          icon: "create-icon", // 菜单图标
          permissions: ["project_edit"], // 查看此页面需要的权限编码
        },
      },
    ],
  },
];

// 创建路由实例 - 初始只包含静态路由,动态路由将在用户登录后根据权限添加
const router = createRouter({
  history: createWebHashHistory(), // 使用hash模式的历史记录,兼容性更好,不需要服务器配置
  routes: constantRoutes, // 初始路由只包含静态路由
  // 可选:自定义滚动行为,控制页面切换时的滚动位置
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      // 如果存在保存的位置(如用户点击后退按钮),则恢复到该位置
      return savedPosition;
    } else {
      // 否则滚动到页面顶部
      return { top: 0 };
    }
  },
});

// 导出路由相关配置,供其他模块使用
export { router, constantRoutes, asyncRoutes };

2.2 动态路由管理与权限过滤

动态路由的核心是根据用户权限过滤和添加路由。以下是一个完整的动态路由管理模块:

// router/permission.js - 权限控制模块,负责动态路由的过滤和添加

import { router, asyncRoutes } from "./index"; // 导入路由实例和异步路由配置
import store from "../store"; // 导入Vuex存储,用于获取和管理状态

/**
 * 权限检查函数 - 检查用户是否拥有访问路由所需的权限
 * @param {Object} route - 路由配置对象
 * @param {Array} permissions - 用户拥有的权限列表
 * @returns {Boolean} - 是否有权限访问
 */
function hasPermission(route, permissions) {
  // 检查路由是否设置了权限要求
  if (route.meta && route.meta.permissions) {
    // 路由需要的权限与用户拥有的权限取交集
    // 使用some方法,只要用户拥有路由所需的任一权限即可访问
    return route.meta.permissions.some((permission) =>
      permissions.includes(permission)
    );
  }
  // 没有设置权限要求,默认可访问
  // 这种情况通常是路由没有配置meta.permissions属性
  return true;
}

/**
 * 过滤路由 - 根据用户权限过滤路由配置
 * @param {Array} routes - 路由配置数组
 * @param {Array} permissions - 用户拥有的权限列表
 * @returns {Array} - 过滤后的路由配置
 */
function filterRoutes(routes, permissions) {
  // 创建一个新数组存储过滤后的路由
  const filteredRoutes = [];

  // 遍历所有路由配置
  routes.forEach((route) => {
    // 创建路由副本,避免修改原始配置
    // 使用浅拷贝,因为我们只需要复制顶层属性
    const routeCopy = { ...route };

    // 检查用户是否有权限访问该路由
    if (hasPermission(routeCopy, permissions)) {
      // 如果有子路由,递归过滤子路由
      if (routeCopy.children && routeCopy.children.length) {
        // 递归调用过滤函数处理子路由
        routeCopy.children = filterRoutes(routeCopy.children, permissions);
      }

      // 将有权限访问的路由添加到过滤后的路由列表
      filteredRoutes.push(routeCopy);
    }
    // 如果没有权限,则不添加到过滤后的路由列表中
  });

  // 返回过滤后的路由配置数组
  return filteredRoutes;
}

/**
 * 动态添加路由的函数
 * @param {string} [parent='home'] - 父路由名称,默认为'home'
 * @param {Array} routes - 要添加的路由配置数组
 * @returns {Promise<void>}
 *
 * @description
 * 该函数用于动态添加路由配置,支持多级路由的递归添加。
 * 路由配置对象应包含以下属性:
 * - name: 路由名称(必填)
 * - component: 路由组件(必填)
 * - path: 路由路径(必填)
 * - meta: 路由元信息(可选)
 * - children: 子路由数组(可选)
 * - root: 根路由名称(用于特殊情况下的二级目录)
 */
async function addRoutes(parent = "home", routes) {
  try {
    // 遍历路由配置数组
    for (const route of routes) {
      // 深拷贝路由配置,避免修改原始对象
      // 使用JSON序列化和解析实现深拷贝
      const routeConfig = JSON.parse(JSON.stringify(route));

      // 重新设置组件引用,因为JSON序列化会丢失函数引用
      // 这一步非常重要,否则路由组件将无法正确加载
      routeConfig.component = route.component;

      // 根据路由配置类型选择不同的添加方式
      if (routeConfig.component) {
        // 如果路由有组件,直接添加到父路由
        router.addRoute(parent, routeConfig);

        // 递归处理子路由,将当前路由名称作为子路由的父路由
        if (route.children?.length) {
          await addRoutes(route.name, route.children);
        }
      } else if (route.children?.length) {
        // 处理特殊情况:没有组件但有子路由的情况(如分组菜单)
        // 这种情况通常是路由分组,只作为菜单分类使用,不对应实际页面
        for (const child of route.children) {
          // 获取子路由的父路由名称,优先使用子路由指定的root属性
          const parentName = child.root || parent;
          // 将子路由添加到指定的父路由下
          router.addRoute(parentName, child);
        }
      }

      // 可选:记录已添加的路由名称,用于后续清理
      // 这对于用户登出时清除动态路由非常重要
      store.commit("ADD_DYNAMIC_ROUTE", routeConfig.name);
    }

    // 添加404路由作为兜底,确保未匹配的路由都重定向到404页面
    // 注意:这必须在所有路由添加完成后执行,否则会拦截其他路由
    router.addRoute({
      path: "/:pathMatch(.*)*", // 通配符路径,匹配所有未定义的路由
      redirect: "/404", // 重定向到404页面
    });
  } catch (error) {
    // 捕获并处理添加路由过程中的错误
    console.error("动态添加路由失败:", error);
    throw error; // 抛出错误,让调用者处理
  }
}

/**
 * 生成动态路由 - 根据用户权限生成可访问的路由配置
 * @param {Array} permissions - 用户权限列表
 * @returns {Promise<Array>} - 生成的路由配置
 */
export async function generateRoutes(permissions) {
  try {
    // 1. 根据用户权限过滤路由,只保留用户有权访问的路由
    const accessibleRoutes = filterRoutes(asyncRoutes, permissions);

    // 2. 动态添加过滤后的路由到路由实例
    await addRoutes("home", accessibleRoutes);

    // 3. 返回可访问的路由配置(可用于生成菜单)
    return accessibleRoutes;
  } catch (error) {
    // 捕获并处理生成路由过程中的错误
    console.error("生成动态路由失败:", error);
    throw error; // 抛出错误,让调用者处理
  }
}

/**
 * 重置路由 - 在用户登出时清除所有动态添加的路由
 * 这是防止内存泄漏和权限混乱的重要步骤
 */
export function resetRouter() {
  // 从store中获取已添加的动态路由名称
  const dynamicRouteNames = store.state.dynamicRoutes || [];

  // 遍历并移除所有动态添加的路由
  dynamicRouteNames.forEach((name) => {
    // 检查路由是否存在,避免移除不存在的路由导致错误
    if (router.hasRoute(name)) {
      // 从路由实例中移除指定名称的路由
      router.removeRoute(name);
    }
  });

  // 清空store中的动态路由记录
  store.commit("RESET_DYNAMIC_ROUTES");
}

3. 完整实现与集成示例

下面展示如何在实际项目中集成动态路由系统:

3.1 路由守卫配置

// router/guard.js - 路由守卫配置文件,处理路由跳转前后的逻辑

import { router } from "./index"; // 导入路由实例
import store from "../store"; // 导入Vuex存储
import { generateRoutes, resetRouter } from "./permission"; // 导入动态路由生成和重置函数
import NProgress from "nprogress"; // 导入进度条组件,用于提升用户体验

// 白名单:不需要登录即可访问的路由路径列表
// 这些路径可以在未登录状态下直接访问
const whiteList = ["/login", "/register", "/404", "/403"];

// 前置守卫 - 在路由跳转前执行
// to: 即将进入的目标路由对象
// from: 当前导航正要离开的路由对象
// next: 必须调用的函数,用于继续或中断导航
router.beforeEach(async (to, from, next) => {
  // 开始显示页面加载进度条,提升用户体验
  NProgress.start();

  // 设置页面标题,优先使用路由元数据中的标题,否则使用默认标题
  document.title = to.meta.title || "应用系统";

  // 从Vuex中获取用户令牌,判断用户是否已登录
  const hasToken = store.getters.getToken;

  // 根据用户登录状态进行不同处理
  if (hasToken) {
    // 用户已登录的情况
    if (to.path === "/login") {
      // 如果已登录用户尝试访问登录页,重定向到首页
      // 避免已登录用户重复登录
      next({ path: "/" });
      // 完成进度条
      NProgress.done();
    } else {
      // 检查用户是否已获取权限信息
      // 通过判断权限数组是否存在且不为空来确定
      const hasPermissions =
        store.getters.getPermissions && store.getters.getPermissions.length > 0;

      if (hasPermissions) {
        // 已有权限信息,允许正常访问请求的路由
        next();
      } else {
        // 没有权限信息,需要先获取权限再生成动态路由
        try {
          // 调用Vuex action获取用户信息和权限数据
          const { data } = await store.dispatch("user/getUserInfo");

          // 将获取到的权限和角色信息存储到Vuex中
          store.commit("user/SET_PERMISSIONS", data.permissions);
          store.commit("user/SET_ROLES", data.roles);

          // 根据用户权限生成可访问的动态路由
          await generateRoutes(data.permissions);

          // 确保路由添加完成后再跳转到目标路由
          // replace: true 表示替换当前历史记录,而不是新增
          next({ ...to, replace: true });
        } catch (error) {
          // 获取权限失败,需要重置用户状态并重定向到登录页
          // 重置令牌,清空用户状态
          await store.dispatch("user/resetToken");
          // 记录错误信息
          console.error("获取用户信息失败:", error);
          // 重定向到登录页,并携带原目标路径作为参数,便于登录后跳转
          next(`/login?redirect=${to.path}`);
          // 完成进度条
          NProgress.done();
        }
      }
    }
  } else {
    // 用户未登录的情况
    if (whiteList.includes(to.path)) {
      // 目标路径在白名单中,允许直接访问
      // 如登录页、注册页、错误页等
      next();
    } else {
      // 目标路径不在白名单中,需要先登录
      // 重定向到登录页,并携带原目标路径作为参数
      next(`/login?redirect=${to.path}`);
      // 完成进度条
      NProgress.done();
    }
  }
});

// 后置守卫 - 在路由跳转完成后执行
router.afterEach(() => {
  // 路由跳转完成后,结束进度条显示
  NProgress.done();
});

3.2 登录与权限获取流程

// store/modules/user.js - 用户模块Vuex存储,管理用户状态、权限和认证

import { login, getUserInfo, logout } from "@/api/user"; // 导入用户相关API
import { getToken, setToken, removeToken } from "@/utils/auth"; // 导入令牌操作工具函数
import { resetRouter } from "@/router/permission"; // 导入路由重置函数

// 状态定义 - 存储用户相关的数据
const state = {
  token: getToken(), // 用户令牌,从本地存储获取初始值
  userInfo: {}, // 用户基本信息
  permissions: [], // 用户权限列表
  roles: [], // 用户角色列表
  dynamicRoutes: [], // 记录动态添加的路由名称,用于登出时清理
};

// 修改状态的同步函数
const mutations = {
  // 设置用户令牌
  SET_TOKEN: (state, token) => {
    state.token = token; // 更新状态中的令牌
  },
  // 设置用户信息
  SET_USER_INFO: (state, info) => {
    state.userInfo = info; // 更新用户基本信息
  },
  // 设置用户权限
  SET_PERMISSIONS: (state, permissions) => {
    state.permissions = permissions; // 更新权限列表
  },
  // 设置用户角色
  SET_ROLES: (state, roles) => {
    state.roles = roles; // 更新角色列表
  },
  // 添加动态路由记录
  ADD_DYNAMIC_ROUTE: (state, routeName) => {
    // 避免重复添加同名路由
    if (!state.dynamicRoutes.includes(routeName)) {
      state.dynamicRoutes.push(routeName); // 将路由名称添加到列表
    }
  },
  // 重置动态路由记录
  RESET_DYNAMIC_ROUTES: (state) => {
    state.dynamicRoutes = []; // 清空动态路由列表
  },
};

// 包含异步操作的action函数
const actions = {
  // 用户登录操作
  async login({ commit }, loginData) {
    try {
      // 解构登录数据
      const { username, password } = loginData;
      // 调用登录API,发送用户名和密码
      // 注意:trim()去除用户名两端空格,避免用户误输入空格导致登录失败
      const response = await login({ username: username.trim(), password });

      // 检查响应状态
      if (response.code === 200) {
        // 登录成功,从响应中获取令牌
        const { token } = response.data;
        // 更新Vuex状态
        commit("SET_TOKEN", token);
        // 将令牌保存到本地存储,用于持久化登录状态
        setToken(token);
        // 返回完整响应,便于调用者进一步处理
        return response;
      }

      // 如果响应码不是200,抛出错误
      throw new Error(response.message || "登录失败");
    } catch (error) {
      // 捕获并记录登录过程中的错误
      console.error("登录失败:", error);
      // 将错误向上抛出,让调用者处理
      throw error;
    }
  },

  // 获取用户信息和权限数据
  async getUserInfo({ commit, state }) {
    try {
      // 使用当前令牌调用获取用户信息API
      const response = await getUserInfo(state.token);

      // 检查响应状态
      if (response.code === 200) {
        // 从响应中解构需要的数据
        const { userInfo, permissions, roles } = response.data;

        // 验证权限数据是否有效
        if (!permissions || permissions.length === 0) {
          throw new Error("用户权限不能为空");
        }

        // 更新Vuex状态
        commit("SET_USER_INFO", userInfo); // 设置用户基本信息
        commit("SET_PERMISSIONS", permissions); // 设置权限列表
        commit("SET_ROLES", roles); // 设置角色列表

        // 返回完整响应
        return response;
      }

      // 如果响应码不是200,抛出错误
      throw new Error(response.message || "获取用户信息失败");
    } catch (error) {
      // 捕获并记录获取用户信息过程中的错误
      console.error("获取用户信息失败:", error);
      // 将错误向上抛出
      throw error;
    }
  },

  // 用户登出操作
  async logout({ commit, state }) {
    try {
      // 调用登出API,发送当前令牌
      await logout(state.token);

      // 清空用户状态
      commit("SET_TOKEN", ""); // 清除令牌
      commit("SET_PERMISSIONS", []); // 清除权限
      commit("SET_ROLES", []); // 清除角色
      commit("SET_USER_INFO", {}); // 清除用户信息

      // 从本地存储中移除令牌
      removeToken();

      // 重置路由,清除动态添加的路由
      resetRouter();
    } catch (error) {
      // 捕获并记录登出过程中的错误
      console.error("登出失败:", error);
      // 将错误向上抛出
      throw error;
    }
  },

  // 重置用户令牌(通常在令牌失效或需要强制用户重新登录时调用)
  resetToken({ commit }) {
    // 清空用户状态
    commit("SET_TOKEN", ""); // 清除令牌
    commit("SET_PERMISSIONS", []); // 清除权限
    commit("SET_ROLES", []); // 清除角色

    // 从本地存储中移除令牌
    removeToken();
  },
};

// 导出Vuex模块
export default {
  namespaced: true, // 启用命名空间,避免与其他模块的action和mutation冲突
  state,
  mutations,
  actions,
};

3.3 菜单组件实现

<!-- components/Menu.vue - 侧边栏菜单组件,根据用户权限动态渲染导航菜单 -->
<template>
  <div class="menu-container">
    <!--
      使用Element Plus的菜单组件
      :default-active - 当前激活菜单的路径,与路由路径保持同步
      :collapse - 是否折叠菜单,用于响应式布局
      :unique-opened - 是否只保持一个子菜单展开
      :router - 启用vue-router模式,点击菜单项自动导航到对应路由
     -->
    <el-menu
      :default-active="activeMenu"
      :collapse="isCollapse"
      :unique-opened="true"
      :router="true"
      class="sidebar-menu"
    >
      <!--
        遍历路由配置,为每个路由生成对应的菜单项
        v-for - 循环渲染每个路由
        :key - 为每个菜单项提供唯一标识,优化渲染性能
        :item - 传递路由配置给子组件
       -->
      <menu-item v-for="route in routes" :key="route.path" :item="route" />
    </el-menu>
  </div>
</template>

<script>
// 导入Vue组合式API相关函数
import { computed, ref } from "vue";
// 导入Vue Router的useRoute钩子,用于获取当前路由信息
import { useRoute } from "vue-router";
// 导入Vuex的useStore钩子,用于访问状态管理
import { useStore } from "vuex";
// 导入菜单项子组件
import MenuItem from "./MenuItem.vue";

export default {
  // 注册子组件
  components: {
    MenuItem, // 菜单项组件,用于递归渲染菜单结构
  },
  // 使用组合式API
  setup() {
    // 获取当前路由实例
    const route = useRoute();
    // 获取Vuex存储实例
    const store = useStore();
    // 创建一个响应式引用,控制菜单是否折叠
    const isCollapse = ref(false);

    // 计算属性:当前激活的菜单路径
    // 根据当前路由路径自动高亮对应的菜单项
    const activeMenu = computed(() => {
      // 返回当前路由的完整路径
      return route.path;
    });

    // 计算属性:获取有权限的路由作为菜单数据源
    // 这里从Vuex中获取经过权限过滤后的路由配置
    const routes = computed(() => {
      // 过滤掉不需要在菜单中显示的路由
      // 例如登录页、错误页等通常不需要显示在导航菜单中
      return store.state.permission.routes.filter((route) => {
        // 检查路由元数据中是否设置了hideInMenu标志
        return !route.meta?.hideInMenu;
      });
    });

    // 返回模板中需要使用的响应式数据和计算属性
    return {
      activeMenu, // 当前激活的菜单路径
      isCollapse, // 菜单折叠状态
      routes, // 过滤后的路由列表
    };
  },
};
</script>

<style scoped>
/* 菜单容器样式 */
.menu-container {
  height: 100%;
  border-right: 1px solid #e6e6e6;
}

/* 侧边栏菜单样式 */
.sidebar-menu {
  height: 100%;
  /* 不设置固定宽度,由父容器控制 */
}

/* 折叠状态下的菜单样式 */
.sidebar-menu.el-menu--collapse {
  width: 64px;
}
</style>
<!-- components/MenuItem.vue - 菜单项组件,支持递归渲染多级菜单 -->
<template>
  <!--
    条件渲染:如果菜单项有子菜单,则渲染为子菜单组件
    hasChildren方法判断当前项是否有子菜单且允许显示
   -->
  <el-submenu v-if="hasChildren(item)" :index="item.path">
    <!-- 子菜单标题插槽 -->
    <template #title>
      <!-- 如果有图标,则显示图标 -->
      <i v-if="item.meta?.icon" :class="item.meta.icon"></i>
      <!-- 显示菜单标题文本 -->
      <span>{{ item.meta?.title }}</span>
    </template>
    <!--
      递归渲染子菜单项
      关键点:组件自身引用自身,实现任意层级的菜单结构
     -->
    <menu-item v-for="child in item.children" :key="child.path" :item="child" />
  </el-submenu>

  <!--
    如果没有子菜单,则渲染为普通菜单项
    点击后直接导航到对应路由
   -->
  <el-menu-item v-else :index="item.path">
    <!-- 如果有图标,则显示图标 -->
    <i v-if="item.meta?.icon" :class="item.meta.icon"></i>
    <!-- 显示菜单标题文本 -->
    <span>{{ item.meta?.title }}</span>
  </el-menu-item>
</template>

<script>
export default {
  // 组件名称,用于递归引用
  name: "MenuItem",
  // 组件属性定义
  props: {
    // item: 菜单项数据,即路由配置对象
    item: {
      type: Object, // 类型为对象
      required: true, // 必须提供
    },
  },
  // 组件方法
  methods: {
    // 判断菜单项是否有子菜单且允许显示
    hasChildren(item) {
      return (
        // 检查是否有children属性且不为空数组
        item.children &&
        item.children.length > 0 &&
        // 检查是否设置了hideChildrenInMenu标志
        // 某些情况下,虽然有子路由,但不希望在菜单中显示为子菜单
        !item.meta?.hideChildrenInMenu
      );
    },
  },
};
</script>

<style scoped>
/* 菜单项图标与文本的间距 */
i {
  margin-right: 8px;
  width: 24px;
  text-align: center;
}

/* 确保子菜单项有适当的缩进 */
.el-submenu .el-menu-item {
  padding-left: 50px !important;
}
</style>

注意事项与最佳实践

在实现动态路由时,需要注意以下关键点以确保系统的稳定性和可维护性:

1. 路由配置与注册

  • 确保父路由存在:在添加子路由前,必须确保父路由已经注册,否则添加会失败
  • 路由配置深拷贝:使用 JSON 序列化再解析的方式进行深拷贝,避免修改原始路由配置
  • 组件引用恢复:深拷贝后需要重新设置组件引用,因为 JSON 序列化会丢失函数引用
  • 唯一路由名称:确保每个路由的 name 属性唯一,避免路由覆盖问题

2. 性能与安全考量

  • 懒加载组件:对所有路由组件使用懒加载,减少首屏加载时间
  • 路由元数据设计:合理设计 meta 字段,包含权限、图标、标题等信息
  • 双重权限验证:前端路由权限控制与后端 API 权限验证结合,形成双重防护
  • 路由守卫优化:避免在路由守卫中执行过多逻辑,影响页面跳转性能

3. 用户体验优化

  • 路由切换过渡效果:添加页面切换动画,提升用户体验
  • 加载状态提示:使用进度条或加载指示器,提示用户路由加载状态
  • 路由参数保留:在权限验证后保留原始路由参数,确保用户体验连贯
  • 404 页面处理:确保未匹配的路由都能正确重定向到 404 页面

4. 内存与资源管理

  • 路由清理机制:用户登出时清理动态添加的路由,避免内存泄漏
  • 组件销毁处理:确保路由组件正确实现销毁逻辑,释放资源
  • 缓存策略:对频繁访问的路由组件使用 keep-alive,提升性能
  • 按需加载资源:确保只加载当前路由需要的 CSS 和 JS 资源

5. 可维护性与扩展性

  • 路由配置模块化:按业务模块拆分路由配置,提高可维护性
  • 权限编码规范:制定清晰的权限编码规范,便于管理和扩展
  • 路由命名规范:遵循一致的路由命名规范,提高代码可读性
  • 文档与注释:为路由配置和权限逻辑添加详细注释和文档