人人都会AI编程

12.7 动态路由添加:addRoute 实现权限菜单

更新时间:2026-07-09

在后台管理系统中,不同角色的用户看到不同的菜单是基本需求。前端如果一次性注册所有路由,权限控制就只能靠路由守卫,但菜单仍然会暴露在代码中,并且路由配置臃肿。更合理的方案是:初始化时只注册公共路由(如登录页、404),用户登录后根据后端返回的权限数据,动态生成并注册路由

Vue Router 4 提供了 addRoute() 方法来实现这一机制。

12.7.1 addRoute 的核心用法

// 添加一条新的路由记录
router.addRoute({ name: 'dashboard', path: '/dashboard', component: Dashboard })

// 也可以通过 parentName 将路由嵌套到已有路由下
router.addRoute('layout', {
  path: 'settings',
  name: 'settings',
  component: () => import('@/views/settings/index.vue')
})

注意:

  • 如果新路由的 name 已经存在,addRoute 不会覆盖,而是会在控制台警告。建议先检查路由是否已注册(router.hasRoute(name))或先调用 removeRoute(name) 再添加。
  • 使用 addRoute 添加的路由同样会立即生效,无需手动刷新。

12.7.2 典型实现流程

1. 路由初始配置只包含静态部分

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { staticRoutes } from './staticRoutes'

const router = createRouter({
  history: createWebHistory(),
  routes: staticRoutes   // 只包含登录、404等公共路由
})

export default router

2. 后端接口返回的菜单数据结构

通常是一个树状结构,包含路径、名称、组件标识等:

[
  { "id": 1, "path": "/dashboard", "name": "Dashboard", "component": "dashboard", "meta": { "title": "首页" } },
  { "id": 2, "path": "/system", "name": "System", "component": "Layout",
    "children": [
      { "id": 21, "path": "user", "name": "UserList", "component": "user/list", "meta": { "title": "用户管理" } },
      { "id": 22, "path": "role", "name": "RoleList", "component": "role/list", "meta": { "title": "角色管理" } }
    ]
  }
]

3. 前端映射组件

由于动态路由在打包时无法确定组件路径,需要维护一个“组件标识 → 组件懒加载函数”的映射表:

// router/dynamicRoutesMap.js
const modules = import.meta.glob('@/views/**/*.vue')   // Vite 方式,自动匹配所有 .vue

export function getComponent(componentPath) {
  // componentPath 如 'dashboard' 对应 '@/views/dashboard/index.vue'
  const key = `/src/views/${componentPath}/index.vue`
  return modules[key]  // 返回一个 () => import(...) 的函数
}

兼容 Webpack 的写法可以用 require.context 或直接定义一个对象映射。

4. 将菜单数据转为 Vue Router 路由配置

// utils/generateRoutes.js
import { getComponent } from '@/router/dynamicRoutesMap'

export function filterAsyncRoutes(menuList) {
  const routes = []
  menuList.forEach(menu => {
    const route = {
      path: menu.path,
      name: menu.name,
      meta: menu.meta,
      redirect: menu.redirect
    }
    if (menu.component) {
      route.component = getComponent(menu.component)
    }
    if (menu.children && menu.children.length) {
      route.children = filterAsyncRoutes(menu.children)
    }
    routes.push(route)
  })
  return routes
}

5. 在路由守卫中触发动态注册

// router/permission.js
import router from './index'
import store from '@/store'   // 假设用 Pinia 管理用户状态和路由状态
import { filterAsyncRoutes } from '@/utils/generateRoutes'

const whiteList = ['/login']   // 不需要权限的路由

router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore()
  
  if (userStore.token) {
    // 已登录
    if (to.path === '/login') {
      next({ path: '/' })
    } else {
      const dynamicRoutesAdded = sessionStorage.getItem('dynamicRoutesAdded')
      if (!dynamicRoutesAdded) {
        try {
          // 1. 请求菜单数据
          const menus = await getMenuList()
          // 2. 生成路由
          const routes = filterAsyncRoutes(menus)
          // 3. 动态添加路由,通常挂到某个父路由(如 Layout)下
          routes.forEach(route => router.addRoute('Layout', route))
          // 4. 标记已注册,避免重复请求
          sessionStorage.setItem('dynamicRoutesAdded', '1')
          // 5. 放行时重新进入目标路由(因为路由刚添加)
          next({ ...to, replace: true })
        } catch (error) {
          // 清除token,跳转登录
          next('/login')
        }
      } else {
        next()
      }
    }
  } else {
    // 未登录时,只能访问白名单
    if (whiteList.includes(to.path)) {
      next()
    } else {
      next('/login')
    }
  }
})

刷新页面时 dynamicRoutesAdded 标记可放在 sessionStorage,这样关闭浏览器后会重新拉取,保留一定灵活性。如果服务端菜单有变化,可以强制用户重新登录或前端定期同步。

6. 用添加后的路由生成菜单

菜单通常使用 router.getRoutes() 过滤出需要展示的路由(例如带 meta.titlehidden !== true 的记录),再渲染为侧边栏。这样菜单和数据路由始终一致,不会出现“权限不对但还能看到菜单项”的问题。

// Sidebar.vue
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const menuRoutes = computed(() => {
  // 只展示元数据中 hidden 不为 true 的子路由
  return router.options.routes.find(r => r.name === 'Layout').children
    .filter(r => r.meta?.title && !r.meta.hidden)
})
</script>

12.7.3 实际开发中的踩坑与建议

  • 不要忘记在退出登录时重置动态路由。否则上一个用户的权限可能会残留:
  function resetRouter() {
    const router = useRouter()
    router.getRoutes().forEach(route => {
      if (route.name && !['Login', '404', 'Layout'].includes(route.name)) {
        router.removeRoute(route.name)
      }
    })
    sessionStorage.removeItem('dynamicRoutesAdded')
  }
  
  • 路由重复添加问题:每次守卫执行时先检查 router.hasRoute(name),如果已存在可以跳过,但务必与其他逻辑(如退出重登的场景)配合好。
  • 组件失效:如果使用 import.meta.glob 但路径匹配不正确,会导致组件加载失败,建议在开发时打印一次映射表跟后端返回的 component 值做比对。
  • 与静态路由共存:静态路由(如 Layout)必须在创建 Router 时就定义好,否则 addRoute(parentName) 会因为找不到父路由而报错。
  • 类型安全:如果是 TypeScript 项目,给菜单数据和路由配置定义清晰的 Interface,可以避免很多运行时错误。

动态路由配合 addRoute 实现了真正意义上的“按权限加载菜单”,既保证了安全(未授权页面连路由记录都不存在),又让菜单渲染与路由彻底解耦,是后台管理系统中最实用的权限控制方案之一。