引言
在后台管理系统中,不同角色的用户看到的菜单和可访问的页面往往不同。传统的静态路由配置无法满足这种按需加载的需求,因此 动态路由 成为了现代前端工程的标配。Vue Router 提供了 addRoute 方法,允许我们在应用运行时动态添加路由,结合路由守卫,可以优雅地实现基于用户权限的路由控制。
本文将从零开始,带你掌握 Vue3 + JavaScript 环境下动态路由的核心概念、完整实现步骤,并深入剖析刷新后路由丢失、重复添加等经典坑点的解决方案。所有代码均采用组合式 API,可直接用于实战项目。
模块一:动态路由概念与使用场景
概念解析
动态路由 是指在应用运行期间,根据某些条件(如用户权限、角色)动态添加或移除的路由。与传统的静态路由 (在 routes 配置中一次性定义所有路由)相比,动态路由有以下特点:
- 按需加载:只有具备权限的用户才能访问对应的页面,避免未授权访问。
- 灵活性:路由表可由后端返回,前端动态生成,实现权限与路由的完全解耦。
- 可扩展性:支持多角色、多租户等复杂场景。
在 Vue Router 4.x 中,动态添加路由主要通过两个方法实现:
router.addRoute(route: RouteRecordRaw):添加一条新路由。router.removeRoute(name: string | symbol):移除已添加的路由。
使用场景
- 权限控制:不同角色(管理员、普通用户)看到不同的菜单,访问不同的页面。
- 多级菜单动态生成:根据后端返回的菜单结构,递归生成嵌套路由。
- 功能模块按需加载:例如,某些模块仅在特定条件下启用(如节日活动页面)。
模块二:基于权限的动态路由实现
概念解析
实现权限动态路由的核心思路:
- 静态路由:所有用户都能访问的基础路由(如登录页、404、注册页等)。
- 异步路由:需要权限才能访问的路由,通常在后端定义,前端通过接口获取。
- 路由守卫:在路由跳转前判断用户是否登录、是否有权限,并动态添加异步路由。
- 状态管理:存储用户信息和权限路由表,防止刷新后丢失。
实战步骤
我们将实现一个简单的权限控制示例:管理员能看到"用户管理"和"仪表盘",普通用户只能看到"仪表盘"。
1. 定义静态路由和异步路由
javascript
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
// 静态路由(所有用户可访问)
export const constantRoutes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { hidden: true } // 不在菜单中显示
},
{
path: '/404',
name: '404',
component: () => import('@/views/404.vue'),
meta: { hidden: true }
},
{
path: '/',
redirect: '/dashboard'
}
]
// 异步路由(需要权限)
export const asyncRoutes = [
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '仪表盘', icon: 'dashboard', roles: ['admin', 'user'] } // 允许的角色
},
{
path: '/user',
name: 'User',
component: () => import('@/views/User.vue'),
meta: { title: '用户管理', icon: 'user', roles: ['admin'] }
}
]
const router = createRouter({
history: createWebHistory(),
routes: constantRoutes // 初始只挂载静态路由
})
export default router
2. 使用 Pinia 存储用户状态和权限路由
javascript
// stores/user.js
import { defineStore } from 'pinia'
import { constantRoutes, asyncRoutes } from '@/router'
import router from '@/router'
// 模拟后端返回的权限路由名称
const mockFetchUserRoutes = (role) => {
return asyncRoutes.filter(route => route.meta.roles.includes(role))
}
export const useUserStore = defineStore('user', {
state: () => ({
token: localStorage.getItem('token') || '',
role: '', // 当前用户角色
routes: [] // 当前用户拥有的路由(静态+异步)
}),
actions: {
// 登录
async login(role) {
// 模拟登录,存储 token 和 role
this.token = 'mock-token'
this.role = role
localStorage.setItem('token', this.token)
// 根据角色获取路由
const dynamicRoutes = mockFetchUserRoutes(role)
this.routes = [...constantRoutes, ...dynamicRoutes]
// 动态添加路由
dynamicRoutes.forEach(route => {
router.addRoute(route)
})
// 添加 404 通配路由(必须最后添加)
router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404', meta: { hidden: true } })
},
// 登出
logout() {
this.token = ''
this.role = ''
this.routes = []
localStorage.removeItem('token')
// 重置路由(移除所有动态添加的路由)
const dynamicRoutes = mockFetchUserRoutes(this.role) // 此时 role 为空,获取空数组
// 移除动态路由(需要遍历 remove)
// 但更简单的方法是重新创建 router 实例,或者使用 resetRouter 函数
resetRouter() // 自定义函数
router.push('/login')
}
}
})
// 重置路由工具函数
function resetRouter() {
// 获取所有动态路由的 name,并移除
const dynamicRouteNames = asyncRoutes.map(route => route.name)
dynamicRouteNames.forEach(name => {
if (router.hasRoute(name)) {
router.removeRoute(name)
}
})
// 移除 404 通配路由(如果有)
if (router.hasRoute('404')) {
router.removeRoute('404')
}
}
3. 路由守卫:判断权限并动态添加路由
javascript
// router/permission.js
import router from './index'
import { useUserStore } from '@/stores/user'
// 白名单:不需要登录就能访问的路由
const whiteList = ['/login', '/404']
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
const hasToken = userStore.token
if (hasToken) {
if (to.path === '/login') {
// 已登录,跳转到首页
next('/')
} else {
// 判断是否已有角色信息(防止刷新后路由丢失)
if (!userStore.role) {
try {
// 模拟从 token 中解析角色(实际应从后端获取用户信息)
const role = 'admin' // 假设当前用户是 admin
// 调用登录 action 动态添加路由
await userStore.login(role)
// 确保路由添加完成后再进入目标路由
next({ ...to, replace: true })
} catch (error) {
// 获取用户信息失败,重置 token 并跳转登录
userStore.logout()
next(`/login?redirect=${to.path}`)
}
} else {
// 已有角色,正常跳转
next()
}
}
} else {
// 未登录,检查白名单
if (whiteList.includes(to.path)) {
next()
} else {
next(`/login?redirect=${to.path}`)
}
}
})
4. 生成动态菜单(侧边栏组件)
xml
<!-- components/Sidebar.vue -->
<template>
<ul>
<li v-for="route in userRoutes" :key="route.path" v-if="!route.meta?.hidden">
<router-link :to="route.path">{{ route.meta?.title }}</router-link>
</li>
</ul>
</template>
<script setup>
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 只显示 meta.hidden 不为 true 的路由,且过滤掉重定向路由(如 '/')
const userRoutes = computed(() => {
return userStore.routes.filter(route => !route.meta?.hidden && route.path !== '/')
})
</script>
5. 在 main.js 中引入路由守卫
javascript
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './router/permission' // 引入守卫
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
代码示例说明
- 静态路由:包含登录页和404页,所有用户可访问。
- 异步路由 :通过
meta.roles标注允许的角色,模拟后端返回。 - 路由守卫 :在每次跳转前检查 token 和角色,若角色为空则调用
loginaction 动态添加路由。 - 菜单生成 :从 store 中获取
userRoutes并渲染,自动过滤隐藏项。
注意事项
- 动态添加 404 路由:必须在所有动态路由添加完成后最后添加,否则会匹配到 404。
- 防止重复添加 :在
loginaction 中,应先移除之前添加的动态路由(如resetRouter),或者判断router.hasRoute避免重复。 - 刷新路由丢失 :刷新页面会导致 store 中的
role和routes丢失,但 token 可能还存在。解决方案是在路由守卫中判断!userStore.role时重新获取用户信息并动态添加路由。 - 路由替换(replace: true) :动态添加路由后,需要使用
next({ ...to, replace: true })重走一遍导航,确保新路由生效。
模块三:常见坑点与解决方案
1. 路由重复添加
问题 :多次调用 router.addRoute 添加同名路由,会导致控制台警告,甚至路由混乱。
解决方案:
- 添加前使用
router.hasRoute(route.name)检查是否存在。 - 或在添加前统一移除所有动态路由(如上面的
resetRouter)。
javascript
scss
// 安全添加
if (!router.hasRoute(route.name)) {
router.addRoute(route)
}
2. 刷新后路由丢失
问题 :刷新页面后,store 中的 role 和 routes 被重置,但 token 可能还在,此时用户访问非静态路由会报错。
解决方案 :在路由守卫中判断若 token 存在但角色为空,则调用接口获取用户信息并重新添加路由。如上面的 beforeEach 实现。
3. 动态添加的路由在菜单中不显示
原因 :菜单组件直接使用 router.options.routes 获取路由表,但 addRoute 添加的路由不会自动合并到 options.routes 中。
解决方案:将动态路由保存在 store 中,菜单组件基于 store 中的路由渲染,而非直接从 router 实例获取。
4. 404 路由匹配问题
问题:如果在动态路由之前添加了 404 路由,所有未匹配的路由都会跳到 404,导致动态路由无法访问。
解决方案 :确保 404 路由在所有动态路由之后 添加,并且只添加一次。可以在登录成功后添加,并配合 resetRouter 在登出时移除。
5. 嵌套路由的动态添加
问题 :addRoute 支持添加嵌套路由,但需要指定父路由的 name。
示例:
php
router.addRoute('Parent', {
path: 'child',
name: 'Child',
component: () => import('...')
})
注意父路由必须已存在。
扩展思考
更细粒度的权限控制
除了路由级别的权限,实际项目中还常需要按钮级别 的权限控制。可以通过在 meta 中添加 permissions 数组,或在 store 中存储权限标识,然后在组件中使用自定义指令或函数判断。
javascript
// 自定义指令 v-permission
app.directive('permission', {
mounted(el, binding) {
const userStore = useUserStore()
const required = binding.value
if (!userStore.permissions.includes(required)) {
el.parentNode?.removeChild(el)
}
}
})
动态路由与菜单联动
当后端返回的菜单结构可能包含多级时,需要递归生成路由和菜单。可以定义一个递归函数,将后端返回的 JSON 转换为 Vue Router 支持的 RouteRecordRaw 数组。
结合路由元信息(meta)进行更多控制
在 meta 中可以存放标题、图标、缓存标识等,配合 router.beforeEach 实现页面标题动态更新、页面缓存控制等功能。
总结
本文详细讲解了 Vue3 动态路由的核心概念、实现步骤以及常见问题的解决方案。通过实战代码,你学会了如何根据用户权限动态添加路由,如何处理刷新后路由丢失,以及如何避免重复添加路由。动态路由是构建大型后台管理系统的基础,掌握它将使你的前端工程更具灵活性和可维护性。
在实际项目中,你可能还需要结合后端接口、WebSocket 通知等实时更新权限,但本文提供的模式已经足够应对绝大多数场景。希望你能将这些知识应用到自己的项目中,构建出健壮、安全的前端应用。