动态路由流程说明.md 15 KB

动态路由流程详解

📋 概述

动态路由是指根据用户权限从后端获取菜单数据,然后在前端动态生成和注册路由的过程。这样可以实现基于角色的权限控制(RBAC),不同角色的用户看到不同的菜单和页面。


🔄 完整流程

用户登录 
  ↓
路由守卫拦截 (permission.ts)
  ↓
检查是否有 Token
  ↓
检查用户信息是否已加载 (roles.length === 0?)
  ↓
调用后端接口获取菜单数据 (/system/menu/getRouters)
  ↓
后端返回菜单 JSON 数据
  ↓
前端转换路由格式 (filterAsyncRouter)
  ↓
动态加载 Vue 组件 (loadView)
  ↓
注册路由到 Vue Router (router.addRoute)
  ↓
更新侧边栏菜单 (setSidebarRouters)
  ↓
跳转到目标页面

📝 详细代码流程

1️⃣ 路由守卫拦截 (src/permission.ts)

作用:在每次路由跳转前进行检查,确保用户已登录且路由已加载。

// src/permission.ts
router.beforeEach(async (to, from, next) => {
  NProgress.start(); // 显示加载进度条
  
  if (getToken()) {
    // ✅ 有 Token,已登录
    if (to.path === '/login') {
      // 已登录用户访问登录页,重定向到首页
      next({ path: '/' });
    } else if (isWhiteList(to.path)) {
      // 白名单路径,直接放行
      next();
    } else {
      // 🔑 关键:检查用户信息是否已加载
      if (useUserStore().roles.length === 0) {
        // 用户信息未加载,需要获取用户信息和路由
        isRelogin.show = true;
        
        // 1. 获取用户信息
        const [err] = await tos(useUserStore().getInfo());
        if (err) {
          // 获取失败,退出登录
          await useUserStore().logout();
          next({ path: '/' });
        } else {
          isRelogin.show = false;
          
          // 2. 🚀 生成动态路由(核心步骤)
          const accessRoutes = await usePermissionStore().generateRoutes();
          
          // 3. 将路由添加到 Vue Router
          accessRoutes.forEach((route) => {
            if (!isHttp(route.path)) {
              router.addRoute(route); // 动态注册路由
            }
          });
          
          // 4. 重新跳转到目标页面(确保路由已注册)
          next({ 
            path: to.path, 
            replace: true,
            params: to.params,
            query: to.query,
            hash: to.hash,
            name: to.name as string
          });
        }
      } else {
        // 用户信息已加载,直接放行
        next();
      }
    }
  } else {
    // ❌ 没有 Token,未登录
    if (isWhiteList(to.path)) {
      next(); // 白名单路径
    } else {
      // 重定向到登录页
      const redirect = encodeURIComponent(to.fullPath || '/');
      next(`/login?redirect=${redirect}`);
    }
  }
});

2️⃣ 调用后端接口获取菜单 (src/api/menu.ts)

作用:向后端请求当前用户有权限的菜单数据。

// src/api/menu.ts
export function getRouters(): AxiosPromise<RouteRecordRaw[]> {
  return request({
    url: '/system/menu/getRouters',  // 后端接口地址
    method: 'get'
  });
}

后端返回的数据格式示例

[
  {
    "name": "System",
    "path": "/system",
    "component": "Layout",
    "redirect": "/system/user",
    "alwaysShow": true,
    "meta": {
      "title": "系统管理",
      "icon": "system"
    },
    "children": [
      {
        "name": "User",
        "path": "user",
        "component": "system/user/index",
        "meta": {
          "title": "用户管理",
          "icon": "user"
        }
      },
      {
        "name": "Role",
        "path": "role",
        "component": "system/role/index",
        "meta": {
          "title": "角色管理",
          "icon": "peoples"
        }
      }
    ]
  },
  {
    "name": "Platform",
    "path": "/platform",
    "component": "Layout",
    "redirect": "/platform/carousel",
    "alwaysShow": true,
    "meta": {
      "title": "平台装修",
      "icon": "system"
    },
    "children": [
      {
        "name": "Carousel",
        "path": "carousel",
        "component": "platform/decoration/carousel/index",
        "meta": {
          "title": "轮播广告",
          "icon": "component"
        }
      }
    ]
  }
]

3️⃣ 生成动态路由 (src/store/modules/permission.ts)

作用:将后端返回的菜单 JSON 转换为 Vue Router 可用的路由对象。

// src/store/modules/permission.ts

const generateRoutes = async (): Promise<RouteRecordRaw[]> => {
  // 1. 📡 调用后端接口获取菜单数据
  const res = await getRouters();
  const { data } = res;
  
  // 2. 📋 深拷贝数据(用于不同用途)
  const sdata = JSON.parse(JSON.stringify(data));  // 侧边栏路由
  const rdata = JSON.parse(JSON.stringify(data));   // 重写路由(扁平化)
  const defaultData = JSON.parse(JSON.stringify(data)); // 默认路由
  
  // 3. 🔄 转换路由格式
  const sidebarRoutes = filterAsyncRouter(sdata);        // 侧边栏菜单
  const rewriteRoutes = filterAsyncRouter(rdata, undefined, true); // 扁平化路由
  const defaultRoutes = filterAsyncRouter(defaultData);  // 默认路由
  
  // 4. 🔐 处理动态路由权限(如果有)
  const asyncRoutes = filterDynamicRoutes(dynamicRoutes);
  asyncRoutes.forEach((route) => {
    router.addRoute(route);
  });
  
  // 5. 💾 保存路由到 Store
  setRoutes(rewriteRoutes);                              // 保存所有路由
  setSidebarRouters(constantRoutes.concat(sidebarRoutes)); // 侧边栏路由
  setDefaultRoutes(sidebarRoutes);                       // 默认路由
  setTopbarRoutes(defaultRoutes);                        // 顶部栏路由
  
  // 6. ✅ 检查路由名称是否重复
  duplicateRouteChecker(asyncRoutes, sidebarRoutes);
  
  return rewriteRoutes;
};

4️⃣ 转换路由格式 (filterAsyncRouter)

作用:将后端返回的路由字符串转换为 Vue Router 组件对象。

// src/store/modules/permission.ts

const filterAsyncRouter = (
  asyncRouterMap: RouteRecordRaw[], 
  lastRouter?: RouteRecordRaw, 
  type = false
): RouteRecordRaw[] => {
  return asyncRouterMap.filter((route) => {
    // 1. 🔄 如果是重写路由,扁平化子路由
    if (type && route.children) {
      route.children = filterChildren(route.children, undefined);
    }
    
    // 2. 🎨 处理特殊组件
    if (route.component?.toString() === 'Layout') {
      // Layout 组件:主布局容器
      route.component = Layout;
    } else if (route.component?.toString() === 'ParentView') {
      // ParentView 组件:父级视图容器(用于嵌套路由)
      route.component = ParentView;
    } else if (route.component?.toString() === 'InnerLink') {
      // InnerLink 组件:内部链接
      route.component = InnerLink;
    } else {
      // 3. 📦 动态加载 Vue 组件
      // 例如:'system/user/index' → 加载 '@/views/system/user/index.vue'
      route.component = loadView(route.component, route.name as string);
    }
    
    // 4. 🔁 递归处理子路由
    if (route.children != null && route.children && route.children.length) {
      route.children = filterAsyncRouter(route.children, route, type);
    } else {
      // 没有子路由,删除空属性
      delete route.children;
      delete route.redirect;
    }
    
    return true;
  });
};

转换示例

转换前(后端返回)

{
  "name": "User",
  "path": "user",
  "component": "system/user/index",
  "meta": {
    "title": "用户管理",
    "icon": "user"
  }
}

转换后(Vue Router 格式)

{
  name: "User",
  path: "user",
  component: () => import('@/views/system/user/index.vue'), // 动态导入组件
  meta: {
    title: "用户管理",
    icon: "user"
  }
}

5️⃣ 动态加载 Vue 组件 (loadView)

作用:根据组件路径字符串,动态加载对应的 Vue 组件文件。

// src/store/modules/permission.ts

// 匹配 views 目录下所有的 .vue 文件
const modules = import.meta.glob('./../../views/**/*.vue');

export const loadView = (view: any, name: string) => {
  let res;
  
  // 遍历所有已匹配的组件文件
  for (const path in modules) {
    // 例如:path = './../../views/system/user/index.vue'
    const viewsIndex = path.indexOf('/views/');
    let dir = path.substring(viewsIndex + 7); // 'system/user/index.vue'
    dir = dir.substring(0, dir.lastIndexOf('.vue')); // 'system/user/index'
    
    // 匹配组件路径
    if (dir === view) {
      // 创建自定义名称组件
      res = createCustomNameComponent(modules[path], { name });
      return res;
    }
  }
  
  return res;
};

工作原理

  1. import.meta.glob() 在构建时扫描所有 .vue 文件,生成一个映射对象
  2. 根据后端返回的组件路径(如 'system/user/index'),在映射中查找对应的文件
  3. 返回一个动态导入函数,Vue Router 会在需要时加载该组件

modules 对象示例

{
  './../../views/system/user/index.vue': () => import('./../../views/system/user/index.vue'),
  './../../views/system/role/index.vue': () => import('./../../views/system/role/index.vue'),
  './../../views/platform/decoration/carousel/index.vue': () => import('./../../views/platform/decoration/carousel/index.vue'),
  // ... 更多组件
}

6️⃣ 扁平化子路由 (filterChildren)

作用:将嵌套的子路由扁平化,用于路由重写。

// src/store/modules/permission.ts

const filterChildren = (
  childrenMap: RouteRecordRaw[], 
  lastRouter?: RouteRecordRaw
): RouteRecordRaw[] => {
  let children: RouteRecordRaw[] = [];
  
  childrenMap.forEach((el) => {
    // 拼接完整路径
    // 例如:父路由 '/platform' + 子路由 'carousel' = '/platform/carousel'
    el.path = lastRouter ? lastRouter.path + '/' + el.path : el.path;
    
    // 如果子路由还有子路由,且使用 ParentView,继续递归扁平化
    if (el.children && el.children.length && el.component?.toString() === 'ParentView') {
      children = children.concat(filterChildren(el.children, el));
    } else {
      children.push(el);
    }
  });
  
  return children;
};

示例

扁平化前

{
  path: '/platform',
  children: [
    {
      path: 'decoration',
      children: [
        { path: 'carousel' },
        { path: 'solution' }
      ]
    }
  ]
}

扁平化后

[
  { path: '/platform/decoration/carousel' },
  { path: '/platform/decoration/solution' }
]

7️⃣ 注册路由到 Vue Router

作用:将转换后的路由添加到 Vue Router 实例中。

// src/permission.ts (在路由守卫中)

const accessRoutes = await usePermissionStore().generateRoutes();

// 遍历所有路由,添加到 Vue Router
accessRoutes.forEach((route) => {
  if (!isHttp(route.path)) {
    router.addRoute(route); // 动态注册路由
  }
});

router.addRoute() 的作用

  • 将路由添加到路由表中
  • 支持嵌套路由
  • 支持命名路由
  • 支持路由元信息(meta)

8️⃣ 更新侧边栏菜单

作用:将生成的路由保存到 Store,供侧边栏组件使用。

// src/store/modules/permission.ts

setSidebarRouters(constantRoutes.concat(sidebarRoutes));

侧边栏组件使用

<!-- src/layout/components/Sidebar/index.vue -->
<template>
  <el-menu :default-active="activeMenu">
    <sidebar-item 
      v-for="route in sidebarRouters" 
      :key="route.path" 
      :item="route"
    />
  </el-menu>
</template>

<script setup>
import { usePermissionStore } from '@/store/modules/permission';

const permissionStore = usePermissionStore();
const sidebarRouters = computed(() => permissionStore.getSidebarRoutes());
</script>

🎯 完整示例:平台装修菜单

后端返回的数据:

{
  "name": "Platform",
  "path": "/platform",
  "component": "Layout",
  "redirect": "/platform/carousel",
  "alwaysShow": true,
  "meta": {
    "title": "平台装修",
    "icon": "system"
  },
  "children": [
    {
      "name": "Carousel",
      "path": "carousel",
      "component": "platform/decoration/carousel/index",
      "meta": {
        "title": "轮播广告",
        "icon": "component"
      }
    },
    {
      "name": "Solution",
      "path": "solution",
      "component": "platform/decoration/solution/index",
      "meta": {
        "title": "方案管理",
        "icon": "documentation"
      }
    }
  ]
}

转换后的路由对象:

{
  name: "Platform",
  path: "/platform",
  component: Layout, // 已转换为组件对象
  redirect: "/platform/carousel",
  alwaysShow: true,
  meta: {
    title: "平台装修",
    icon: "system"
  },
  children: [
    {
      name: "Carousel",
      path: "carousel",
      component: () => import('@/views/platform/decoration/carousel/index.vue'),
      meta: {
        title: "轮播广告",
        icon: "component"
      }
    },
    {
      name: "Solution",
      path: "solution",
      component: () => import('@/views/platform/decoration/solution/index.vue'),
      meta: {
        title: "方案管理",
        icon: "documentation"
      }
    }
  ]
}

扁平化后的路由(用于路由重写):

[
  {
    name: "Carousel",
    path: "/platform/carousel",
    component: () => import('@/views/platform/decoration/carousel/index.vue'),
    meta: { title: "轮播广告", icon: "component" }
  },
  {
    name: "Solution",
    path: "/platform/solution",
    component: () => import('@/views/platform/decoration/solution/index.vue'),
    meta: { title: "方案管理", icon: "documentation" }
  }
]

🔍 关键点总结

  1. 路由守卫:在每次路由跳转前检查并加载动态路由
  2. 后端接口:获取用户有权限的菜单数据
  3. 路由转换:将 JSON 数据转换为 Vue Router 格式
  4. 组件加载:动态导入 Vue 组件文件
  5. 路由注册:使用 router.addRoute() 添加到路由表
  6. 菜单更新:更新侧边栏菜单显示

⚠️ 注意事项

  1. 路由名称不能重复duplicateRouteChecker 会检查并报错
  2. 组件路径必须正确:后端返回的 component 路径必须对应 views 目录下的文件
  3. 首次加载:只在用户信息未加载时(roles.length === 0)才生成路由
  4. 路由重定向:生成路由后需要重新跳转,确保路由已注册
  5. 平台标识:后端会根据 X-Platform-Code 头过滤菜单

🚀 优势

  1. 权限控制:不同角色看到不同的菜单和页面
  2. 灵活配置:菜单配置在数据库中,无需修改代码
  3. 代码分割:使用动态导入,实现按需加载
  4. 易于维护:菜单结构清晰,易于管理