一、为什么需要动态路由?
在后台管理系统开发中,动态路由是权限体系的核心刚需。
如果前端写死路由,会存在致命问题:
- 不同角色权限不同,无法做到菜单按需展示
- 新增菜单、改权限需要改代码、重新打包部署
- 权限粒度不可控,极易出现越权访问页面的安全漏洞
企业级标准方案:前端只写静态基础路由 + 后端返回权限路由 + 前端递归解析动态挂载。
二、整体实现思路(核心原理)
标准生产流程,99%公司都是这套方案:
- 用户登录,获取 Token
- 携带 Token 请求后端 权限菜单路由接口
- 前端拿到后端路由数组,递归筛选、格式化路由
- 调用
addRoute动态挂载路由 - 存储路由到 Pinia,解决 页面刷新路由丢失 问题
- 路由守卫拦截,无权限跳转 404
三、环境与前置准备
技术栈:Vue3 + Vite + TypeScript + Vue-Router4 + Pinia
安装路由:
css
npm install vue-router@4
四、路由类型 TS 定义(规范路由结构)
新建 types/router.d.ts,严格约束后端返回路由格式,杜绝类型混乱。
typescript
/** 后端返回原始路由结构 */
export interface BackendRoute {
id: number
parentId: number
path: string
name: string
component: string
redirect?: string
meta: {
title: string
icon?: string
hidden?: boolean
}
children?: BackendRoute[]
}
/** 系统标准路由结构 */
export interface CustomRoute {
path: string
name?: string
component: any
redirect?: string
meta: {
title: string
icon?: string
hidden?: boolean
}
children?: CustomRoute[]
}
五、初始化静态路由(固定基础路由)
新建 router/index.ts,配置所有人都能访问的静态路由(登录、404、首页)。
typescript
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
// 静态路由(无需权限、所有用户可访问)
export const staticRoutes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录' }
},
{
path: '/',
name: 'Layout',
component: () => import('@/layout/index.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '首页', icon: 'HomeFilled' }
}
]
}
]
// 单独抽取404路由,禁止放入静态路由,需动态后置挂载
export const NotFoundRoute: RouteRecordRaw = {
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
meta: { title: '404' }
}
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: staticRoutes
})
// 重置路由方法(解决退出登录路由残留问题)
export const resetRouter = () => {
router.getRoutes().forEach(route => {
const { name } = route
if (name && !staticRoutes.find(item => item.name === name)) {
router.removeRoute(name)
}
})
}
export default router
六、核心:后端路由解析工具函数
后端返回的 component 是字符串路径,Vue-Router 无法直接识别,需要动态批量导入组件并递归格式化。
新建 utils/route.ts
typescript
import type { BackendRoute, CustomRoute } from '@/types/router'
/**
* 动态导入页面组件
* @param componentStr 组件路径字符串
*/
export const loadComponent = (componentStr: string) => {
return () => import(`@/views/${componentStr}.vue`)
}
/**
* 递归格式化后端路由为前端可用路由
* @param routes 后端原始路由数组
*/
export const formatBackendRoutes = (routes: BackendRoute[]): CustomRoute[] => {
const res: CustomRoute[] = []
routes.forEach(item => {
const route: CustomRoute = {
path: item.path,
name: item.name,
redirect: item.redirect,
meta: item.meta,
component: loadComponent(item.component)
}
// 递归处理子路由
if (item.children && item.children.length > 0) {
route.children = formatBackendRoutes(item.children)
}
res.push(route)
})
return res
}
七、Pinia 存储动态路由(解决刷新丢失)
默认 addRoute 添加的路由页面刷新会丢失,必须用 Pinia 持久存储路由状态。
新建 stores/route.ts
typescript
import { defineStore } from 'pinia'
import type { CustomRoute } from '@/types/router'
export const useRouteStore = defineStore(
'route',
{
state: () => {
return {
// 动态权限路由
dynamicRoutes: [] as CustomRoute[]
}
},
actions: {
// 保存动态路由
setDynamicRoutes(routes: CustomRoute[]) {
this.dynamicRoutes = routes
}
},
persist: true // 开启持久化
}
)
八、登录后获取路由 + 动态挂载核心逻辑
登录成功后请求权限菜单,格式化路由、批量添加路由、存入 Pinia。
typescript
import { useRouteStore } from '@/stores/route'
import { formatBackendRoutes } from '@/utils/route'
import router, { NotFoundRoute } from '@/router'
import type { BackendRoute } from '@/types/router'
// 模拟你的权限接口
import { getMenuApi } from '@/api/user'
export const initDynamicRoute = async () => {
const routeStore = useRouteStore()
// 1. 请求后端菜单路由
const res = await getMenuApi()
// 假设 res.data 为后端返回的路由数组
const backendRoutes: BackendRoute[] = res.data
// 2. 格式化路由
const formatRoutes = formatBackendRoutes(backendRoutes)
// 3. 批量动态添加路由
formatRoutes.forEach(route => {
router.addRoute('Layout', route)
})
// 4. 【核心修复】动态路由挂载完毕后,最后挂载404路由,杜绝刷新404
router.addRoute(NotFoundRoute)
// 5. 存储到pinia持久化
routeStore.setDynamicRoutes(formatRoutes)
}
九、路由守卫恢复动态路由(刷新不丢失终极方案)
页面刷新后 Vue 实例重新加载,addRoute 挂载记录清空,需要在守卫中读取 Pinia 路由重新挂载。
在 router/index.ts 末尾添加守卫:
javascript
import { useRouteStore } from '@/stores/route'
import router, { NotFoundRoute } from '@/router'
// 全局路由守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
const routeStore = useRouteStore()
// 未登录跳转登录页
if (!token) {
if (to.path === '/login') {
next()
} else {
next('/login')
}
return
}
// 已登录访问登录页,跳转首页
if (token && to.path === '/login') {
next('/')
return
}
// 刷新页面重新挂载动态路由 + 后置404
if (token && routeStore.dynamicRoutes.length > 0) {
// 重新挂载动态权限路由
routeStore.dynamicRoutes.forEach(route => {
router.addRoute('Layout', route)
})
// 重新挂载404兜底路由
router.addRoute(NotFoundRoute)
// 防止无限循环刷新
next({ ...to, replace: true })
} else if (token && routeStore.dynamicRoutes.length === 0) {
// 有token但无路由,初始化动态路由后再放行
next({ ...to, replace: true })
} else {
next()
}
})
export default router
十、侧边栏菜单动态渲染(读取 Pinia 路由)
菜单不需要请求接口,直接读取 Pinia 中存储的动态路由渲染,不同角色自动展示不同菜单。
xml
<script setup lang="ts">
import { useRouteStore } from '@/stores/route'
const routeStore = useRouteStore()
// 动态权限菜单
const menuList = routeStore.dynamicRoutes
</script>
十一、生产高频坑点 & 解决方案(必看)
11.1 刷新页面路由丢失、菜单消失
原因:addRoute 是运行时挂载,刷新重置。
解决方案:Pinia 持久化 + 路由守卫重新挂载路由。
11.2 动态路由 404 报错
原因:404 路由写在静态最前面,动态路由还没挂载就匹配 404。
终极解决方案 :抽取独立404路由,不写入静态路由,在所有动态路由挂载完成后再后置挂载404,同时刷新页面时重新挂载404,彻底解决动态路由未渲染完成就匹配404的BUG。
11.3 Vite 动态导入路径报错
Vite 不支持完全变量导入,必须固定前缀。
正确写法:import(`@/views/${str}.vue`)
11.4 退出登录路由残留、权限错乱
解决方案:退出登录清空 Pinia 路由、刷新页面或重置路由实例。
javascript
import router, { resetRouter } from '@/router'
import { useRouteStore } from '@/stores/route'
// 退出登录清空路由、清除残留权限
const logout = () => {
const routeStore = useRouteStore()
// 1. 清空pinia路由缓存
routeStore.setDynamicRoutes([])
// 2. 重置路由实例,清除所有动态路由残留
resetRouter()
// 3. 清除token
localStorage.removeItem('token')
// 4. 跳转登录页
router.push('/login')
}
十二、按钮级权限控制(完整企业级方案)
动态路由仅控制页面级访问权限 ,实际项目中还需要精细化的按钮级权限(新增、编辑、删除、导出等按钮显隐控制)。下面封装一套TS标准、可全局复用的按钮权限方案,适配所有业务场景。
12.1 扩展TS权限类型
在 types/router.d.ts 中新增按钮权限类型,约束后端返回权限标识格式。
typescript
// 单个按钮权限标识
export interface PermissionBtn {
permissionKey: string // 权限唯一标识:system:user:add
}
// 扩展后端路由类型,支持携带按钮权限
export interface BackendRoute {
id: number
parentId: number
path: string
name: string
component: string
redirect?: string
meta: {
title: string
icon?: string
hidden?: boolean
}
children?: BackendRoute[]
// 新增:当前页面按钮权限集合
btnPermissionList?: PermissionBtn[]
}
// 全局权限列表类型
export interface PermissionState {
permissionKeys: string[]
}
12.2 Pinia全局存储所有权限标识
修改 stores/route.ts,新增全局权限标识存储,统一管理所有页面按钮权限,持久化防止刷新失效。
typescript
import { defineStore } from 'pinia'
import type { CustomRoute } from '@/types/router'
export const useRouteStore = defineStore(
'route',
{
state: () => {
return {
// 动态权限路由
dynamicRoutes: [] as CustomRoute[],
// 全局所有按钮权限标识
permissionKeys: [] as string[]
}
},
actions: {
// 保存动态路由
setDynamicRoutes(routes: CustomRoute[]) {
this.dynamicRoutes = routes
},
// 批量设置全局权限标识
setPermissionKeys(keys: string[]) {
this.permissionKeys = keys
},
// 清空所有权限
clearPermission() {
this.dynamicRoutes = []
this.permissionKeys = []
}
},
persist: true // 开启持久化
}
)
12.3 递归提取全局权限标识
在 utils/route.ts 新增工具函数,递归遍历所有路由,提取页面中所有按钮权限标识。
typescript
import type { BackendRoute, CustomRoute } from '@/types/router'
/**
* 动态导入页面组件
* @param componentStr 组件路径字符串
*/
export const loadComponent = (componentStr: string) => {
return () => import(`@/views/${componentStr}.vue`)
}
/**
* 递归格式化后端路由为前端可用路由
* @param routes 后端原始路由数组
*/
export const formatBackendRoutes = (routes: BackendRoute[]): CustomRoute[] => {
const res: CustomRoute[] = []
routes.forEach(item => {
const route: CustomRoute = {
path: item.path,
name: item.name,
redirect: item.redirect,
meta: item.meta,
component: loadComponent(item.component)
}
// 递归处理子路由
if (item.children && item.children.length > 0) {
route.children = formatBackendRoutes(item.children)
}
res.push(route)
})
return res
}
/**
* 递归提取所有按钮权限标识
* @param routes 格式化后的路由数组
* @returns 权限标识数组
*/
export const getAllPermissionKeys = (routes: BackendRoute[]): string[] => {
let keys: string[] = []
routes.forEach(item => {
// 收集当前页面按钮权限
if (item.btnPermissionList && item.btnPermissionList.length > 0) {
const currentKeys = item.btnPermissionList.map(btn => btn.permissionKey)
keys = [...keys, ...currentKeys]
}
// 递归遍历子路由权限
if (item.children && item.children.length > 0) {
const childKeys = getAllPermissionKeys(item.children)
keys = [...keys, ...childKeys]
}
})
// 去重返回
return [...new Set(keys)]
}
12.4 挂载路由时同步加载权限
修改动态路由初始化方法,解析路由的同时提取权限标识,存入Pinia全局状态。
typescript
import { useRouteStore } from '@/stores/route'
import { formatBackendRoutes, getAllPermissionKeys } from '@/utils/route'
import router, { NotFoundRoute } from '@/router'
import type { BackendRoute } from '@/types/router'
// 模拟你的权限接口
import { getMenuApi } from '@/api/user'
export const initDynamicRoute = async () => {
const routeStore = useRouteStore()
// 1. 请求后端菜单路由
const res = await getMenuApi()
const backendRoutes: BackendRoute[] = res.data
// 2. 格式化路由 + 提取所有权限标识
const formatRoutes = formatBackendRoutes(backendRoutes)
const permissionKeys = getAllPermissionKeys(backendRoutes)
// 3. 批量动态添加路由
formatRoutes.forEach(route => {
router.addRoute('Layout', route)
})
// 4. 后置挂载404路由
router.addRoute(NotFoundRoute)
// 5. 存储路由和权限到pinia
routeStore.setDynamicRoutes(formatRoutes)
routeStore.setPermissionKeys(permissionKeys)
}
12.5 封装全局权限自定义指令(核心)
新建 directive/permission.ts 全局权限指令,实现无权限自动移除DOM元素。
typescript
import type { Directive } from 'vue'
import { useRouteStore } from '@/stores/route'
// 权限指令 v-permission="['system:user:add']"
export const permission: Directive = {
mounted(el, binding) {
const routeStore = useRouteStore()
// 获取传入的权限标识
const checkKeys: string[] = binding.value
// 无传入权限标识直接放行
if (!checkKeys || !checkKeys.length) return
// 判断是否包含对应权限
const hasPermission = checkKeys.some(key => routeStore.permissionKeys.includes(key))
// 无权限则移除按钮
if (!hasPermission) {
el.parentNode?.removeChild(el)
}
}
}
在 main.ts 全局注册指令
javascript
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import pinia from './stores'
import { permission } from '@/directive/permission'
const app = createApp(App)
// 全局注册权限指令
app.directive('permission', permission)
app.use(pinia).use(router).mount('#app')
12.6 页面业务使用案例
页面中直接使用 v-permission 指令控制按钮显隐,精准匹配后端权限,无需手动判断。
xml
<template>
<div class="user-page">
<!-- 有 system:user:add 权限才显示新增按钮 -->
<button v-permission="['system:user:add']">新增用户</button>
<!-- 有 system:user:edit 权限才显示编辑按钮 -->
<button v-permission="['system:user:edit']">编辑用户</button>
<!-- 有 system:user:delete 权限才显示删除按钮 -->
<button v-permission="['system:user:delete']">删除用户</button>
</div>
</template>
12.7 退出登录清空权限
优化退出登录逻辑,清空权限状态,防止切换账号权限残留。
javascript
import router, { resetRouter } from '@/router'
import { useRouteStore } from '@/stores/route'
// 退出登录清空路由、清除所有权限
const logout = () => {
const routeStore = useRouteStore()
// 1. 清空pinia路由和权限缓存
routeStore.clearPermission()
// 2. 重置路由实例,清除所有动态路由残留
resetRouter()
// 3. 清除token
localStorage.removeItem('token')
// 4. 跳转登录页
router.push('/login')
}
十三、完整权限体系总结(面试满分答案)
Vue3企业级完整权限体系分为两级权限控制:
- 页面级权限(动态路由) :后端返回角色可访问菜单路由,前端递归格式化、动态挂载、Pinia持久化,控制页面是否可访问、侧边栏菜单是否展示,解决越权访问问题。
- 按钮级权限(自定义指令) :后端返回页面操作权限标识,前端全局收集权限,通过自定义
v-permission指令精准控制按钮显隐,实现精细化权限管控。
整套方案解决了:路由刷新丢失、404错位、路由残留、越权访问、按钮权限混乱等所有生产BUG,完全适配企业后台管理系统,可直接上线部署。