在现代前端应用中,基于角色的权限控制是必不可少的功能。本文将详细讲解如何在 Vue 项目中实现动态路由权限控制,并解决刷新页面后路由丢失的常见问题。我们会逐一分析核心函数的作用和逻辑,同时提供两种刷新问题的解决方案,帮助你根据项目需求选择合适的实现方式
一、核心功能概述
动态路由权限控制的核心目标是:根据用户角色动态加载可访问的路由,并在页面刷新后保持路由状态。整个系统主要由以下几部分组成:
- 路由配置(固定路由 + 动态路由)
- 权限过滤工具函数
- 状态管理(用户信息 + 路由加载状态)
- 全局路由守卫(控制路由跳转和加载逻辑)
- 侧边栏组件(动态渲染菜单)
下面我们将逐一解析每个部分的核心函数和实现逻辑,并重点讲解刷新问题的解决方案。
二、核心代码解析
1. 路由配置:区分固定路由与动态路由
路由配置是权限控制的基础,我们需要将路由分为 “无需权限的固定路由” 和 “需要权限的动态路由”。
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
// 固定路由(constantRoutes):所有用户都能访问的路由
export const constantRoutes = [
{
path: '/login',
***ponent: () => import('@/views/login.vue'),
hidden: true // 登录页不在侧边栏显示
},
{
path: '/',
redirect: '/dashboard',
hidden: true // 重定向路由不显示
},
{
path: '/dashboard',
name: 'Dashboard',
***ponent: () => import('@/views/dashboard.vue'),
meta: { title: '仪表盘' } // 侧边栏显示的标题
},
{
path: '/404',
***ponent: () => import('@/views/404.vue'),
hidden: true // 404页不在侧边栏显示
}
]
// 动态路由(asyncRoutes):需要权限控制的路由
export const asyncRoutes = [
{
path: '/admin',
name: 'Admin',
***ponent: () => import('@/views/admin.vue'),
meta: {
title: '管理员页面', // 侧边栏显示的标题
roles: ['admin'], // 仅管理员可访问
hidden: false // 在侧边栏显示
}
},
{
path: '/user',
name: 'User',
***ponent: () => import('@/views/user.vue'),
meta: {
title: '用户页面', // 侧边栏显示的标题
roles: ['admin', 'user'], // 管理员和普通用户均可访问
hidden: false // 在侧边栏显示
}
}
]
// 创建路由实例(初始只加载固定路由)
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: constantRoutes
})
export default router
核心逻辑:
- 固定路由:任何用户(包括未登录用户)都能访问,如登录页、首页等。
- 动态路由:通过
meta.roles定义访问权限,只有符合角色要求的用户才能访问。 -
hidden属性:控制路由是否在侧边栏菜单中显示。
2. 状态管理:存储用户信息与路由状态
使用 Pinia 管理用户登录状态和路由加载状态,确保刷新页面后状态不丢失。
// src/store/index.js
import { defineStore } from 'pinia'
// 用户状态存储(useUserStore)
export const useUserStore = defineStore('user', {
state: () => ({
// 从本地存储获取token(刷新后仍能保持登录状态)
token: localStorage.getItem('token') || null,
// 从本地存储获取角色列表(刷新后仍能保持权限信息)
roles: localStorage.getItem('roles') ? JSON.parse(localStorage.getItem('roles')) : [],
}),
actions: {
/**
* 登录方法:存储用户信息并持久化到本地
* @param {string} role - 用户选择的角色(如 'admin' 或 'user')
*/
login(role) {
this.token = 'fake-token-' + role // 生成模拟token
this.roles = [role] // 存储用户角色
localStorage.setItem('token', this.token) // 持久化token
localStorage.setItem('roles', JSON.stringify(this.roles)) // 持久化角色
console.log('登录成功,角色:', this.roles)
},
/**
* 退出登录方法:清除用户信息
*/
logout() {
this.token = null // 清空token
this.roles = [] // 清空角色
localStorage.removeItem('token') // 移除本地token
localStorage.removeItem('roles') // 移除本地角色
},
},
})
// 应用状态存储(useAppStore)
export const useAppStore = defineStore('app', {
state: () => ({
hasRoutes: false, // 标记动态路由是否已加载
}),
actions: {
/**
* 更新路由加载状态
* @param {boolean} status - 路由加载状态(true=已加载,false=未加载)
*/
setHasRoutes(status) {
this.hasRoutes = status
},
},
})
核心逻辑:
-
useUserStore:存储用户的token和roles,并通过localStorage持久化,确保刷新后状态不丢失。 -
login方法:登录时保存用户角色并持久化,实现 “记住登录状态”。 -
logout方法:退出时清除所有用户信息,确保安全退出。 -
useAppStore:通过hasRoutes标记动态路由是否已加载,避免重复加载。
3. 权限过滤工具:筛选用户可访问的路由
这部分是权限控制的核心,通过两个函数实现 “根据用户角色筛选路由” 的功能。
// src/utils/route.js
/**
* 根据用户角色筛选动态路由(核心权限过滤函数)
* @param {Array} routes - 待筛选的动态路由列表(asyncRoutes)
* @param {Array} roles - 用户角色列表(如 ['admin'] 或 ['user'])
* @returns {Array} 筛选后用户可访问的路由列表
*/
export function filterAsyncRoutes(routes, roles) {
const validRoutes = [] // 存储符合条件的路由
// 遍历所有动态路由
routes.forEach((route) => {
const tempRoute = { ...route } // 复制路由对象(避免修改原数组)
// 检查用户是否有权访问当前路由
if (hasPermission(roles, tempRoute)) {
// 如果路由有子路由,递归筛选子路由(支持多级路由)
if (tempRoute.children && tempRoute.children.length) {
tempRoute.children = filterAsyncRoutes(tempRoute.children, roles)
}
// 将有权访问的路由添加到结果数组
validRoutes.push(tempRoute)
}
})
console.log('筛选后的路由:', validRoutes) // 调试日志
return validRoutes // 返回筛选结果
}
/**
* 检查用户是否有权访问单个路由(辅助函数)
* @param {Array} roles - 用户角色列表
* @param {Object} route - 单个路由配置对象
* @returns {boolean} true=有权访问,false=无权访问
*/
export function hasPermission(roles, route) {
// 如果路由配置了 meta.roles,则需要检查权限
if (route.meta && route.meta.roles) {
// 检查用户角色中是否有任意一个角色在路由允许的角色列表中
// some() 方法:只要有一个元素满足条件就返回 true
return roles.some((role) => route.meta.roles.includes(role))
} else {
// 路由未配置 meta.roles,默认允许所有用户访问
return true
}
}
核心逻辑:
-
filterAsyncRoutes:递归遍历动态路由,通过hasPermission检查每个路由的访问权限,最终返回用户可访问的路由列表。 -
hasPermission:判断单个路由是否允许用户访问,核心逻辑是 “用户角色与路由要求的角色是否有交集”。 - 支持多级路由:通过递归处理
children子路由,确保子路由也受权限控制。
4. 全局路由守卫:控制路由加载与跳转逻辑
路由守卫是动态路由加载的 “触发器”,在每次路由跳转前检查权限并加载动态路由。
// src/permission.js
import router from './router'
import { useUserStore, useAppStore } from './store'
import { filterAsyncRoutes } from './utils/route'
import { asyncRoutes } from './router'
/**
* 全局前置守卫:在路由跳转前执行
* 作用:检查登录状态、加载动态路由、控制页面访问权限
*/
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore() // 获取用户状态
const appStore = useAppStore() // 获取应用状态
const hasToken = userStore.token // 检查用户是否已登录
if (hasToken) { // 已登录状态
if (to.path === '/login') {
// 已登录用户访问登录页时,重定向到首页
next('/')
} else {
// 访问非登录页时,检查动态路由是否已加载
if (!appStore.hasRoutes) {
// 动态路由未加载:执行加载逻辑
try {
const roles = userStore.roles || [] // 获取用户角色
// 筛选用户可访问的动态路由
const a***essRoutes = filterAsyncRoutes(asyncRoutes, roles)
// 动态添加路由到路由实例
a***essRoutes.forEach((route) => {
// 避免重复添加路由
if (!router.hasRoute(route.name)) {
router.addRoute(route)
}
})
// 添加404路由(放在最后,确保所有路由都匹配失败后才触发)
if (!router.hasRoute('404')) {
router.addRoute({
path: '/:pathMatch(.*)*', // 匹配所有未定义的路由
redirect: '/404', // 重定向到404页
hidden: true,
name: '404',
})
}
// 标记动态路由已加载(避免重复加载)
appStore.setHasRoutes(true)
// 重新导航到目标路由(确保动态路由生效)
next(to.path)
} catch (error) {
// 加载失败:清除登录状态并跳转到登录页
console.error('加载动态路由失败:', error)
userStore.logout()
next('/login')
}
} else {
// 动态路由已加载:直接放行
next()
}
}
} else { // 未登录状态
if (to.path === '/login') {
// 未登录用户访问登录页:直接放行
next()
} else {
// 未登录用户访问其他页:重定向到登录页
next('/login')
}
}
})
核心逻辑:
- 区分 “已登录” 和 “未登录” 状态,未登录用户只能访问登录页。
- 已登录用户首次访问或刷新页面时,
appStore.hasRoutes为false,触发动态路由加载。 - 动态路由加载流程:筛选路由 → 添加到路由实例 → 标记加载状态 → 重新导航。
- 404 路由最后添加:确保所有有效路由都匹配失败后才跳转 404 页。
5. 侧边栏组件:动态渲染菜单,解决刷新路由丢失问题
侧边栏组件负责根据用户权限动态渲染可访问的菜单,核心是 “实时响应路由变化”。
<template>
<el-container style="min-height: 100vh">
<!-- 侧边栏:仅当用户登录(有token)时显示 -->
<el-aside width="200px" v-if="userStore.token">
<el-menu
default-active="Dashboard"
class="el-menu-vertical-demo"
@select="handleMenuSelect"
>
<!-- 遍历去重后的路由列表生成菜单 -->
<template v-for="route in uniqueRoutes" :key="route.path">
<el-menu-item
v-if="!route.hidden && route.name"
:index="route.name"
>
{{ route.meta.title || route.name }}
</el-menu-item>
</template>
</el-menu>
</el-aside>
<el-main>
<router-view /> <!-- 显示当前路由对应的页面 -->
</el-main>
</el-container>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useUserStore } from './store'
import { ***puted, onMounted, ref } from 'vue'
import { constantRoutes } from './router'
const router = useRouter()
const userStore = useUserStore()
// 用于强制刷新的响应式变量(解决路由变化不触发更新问题)
const refreshKey = ref(0)
/**
* 检查用户是否有权访问单个路由(与 utils/route.js 逻辑一致)
* @param {Object} route - 路由配置对象
* @returns {boolean} 权限检查结果
*/
const hasPermission = (route) => {
const roles = userStore.roles || []
if (route.meta && route.meta.roles) {
return roles.some(role => route.meta.roles.includes(role))
}
return true
}
/**
* 过滤出符合条件的路由(计算属性)
* 作用:合并固定路由和动态路由,只保留需要显示的路由
*/
const filteredRoutes = ***puted(() => {
// 引用refreshKey,当它变化时强制重新计算
refreshKey.value
// 合并固定路由和当前所有已加载的路由
const allRoutes = [...constantRoutes, ...router.getRoutes()]
// 过滤条件:不隐藏 + 有标题 + 有权限
return allRoutes.filter(route => {
return !route.hidden && route.meta && route.meta.title && hasPermission(route)
})
})
/**
* 去重后的路由列表(计算属性)
* 作用:避免路由重复(合并时可能出现重复路由)
*/
const uniqueRoutes = ***puted(() => {
const routesMap = {}
filteredRoutes.value.forEach(route => {
routesMap[route.name] = route // 用name去重
})
console.log('当前侧边栏显示的路由:', Object.values(routesMap))
return Object.values(routesMap)
})
/**
* 页面挂载后执行:监听路由变化,确保侧边栏实时更新
*/
onMounted(() => {
// 路由跳转完成后触发(替代不可靠的watch监听)
router.afterEach(() => {
console.log('路由跳转完成,更新侧边栏')
refreshKey.value++ // 改变refreshKey,强制路由列表重新计算
})
})
/**
* 处理菜单点击事件
* @param {string} index - 路由名称(对应路由的name属性)
*/
const handleMenuSelect = (index) => {
router.push({ name: index }) // 跳转到对应的路由
}
</script>
<style>
.el-menu-vertical-demo:not(.el-menu--collapse) {
width: 200px;
min-height: 400px;
}
</style>
核心逻辑:
-
filteredRoutes:合并固定路由和动态路由,过滤出 “不隐藏、有标题、有权限” 的路由。 -
uniqueRoutes:通过路由name去重,避免菜单重复显示。 -
router.afterEach:监听路由跳转完成事件,通过refreshKey强制更新菜单,解决 “动态路由添加后菜单不更新” 的问题。 - 响应式更新:通过计算属性和
refreshKey确保路由变化后菜单实时刷新。