Vue Router 导航守卫的权限控制:多角色路由拦截方案
Vue Router 导航守卫的权限控制:多角色路由拦截方案
权限控制是后台系统的标配,但很多人写到最后,路由守卫里堆满了 if-else,角色判断散得到处都是,新增一个角色要把整个文件改一遍。
我经历过两个阶段:第一个阶段是什么都在组件里判断,导致每个页面都有重复的权限校验代码;第二个阶段是用动态路由,但注册时机混乱,白名单配置和路由表混在一起。
最后摸索出一套还算稳定的方案,核心思路是:路由表声明权限,守卫统一校验,角色匹配用位运算。
先说问题:常见的权限守卫写法有什么问题
问题一:守卫里塞满业务逻辑
router.beforeEach(async (to, from) => {
const userStore = useUserStore()
// 非登录页但没 token
if (!userStore.token && to.name !== 'Login') {
return { name: 'Login' }
}
// 普通用户不能访问 admin 页面
if (userStore.role === 'user' && to.path.startsWith('/admin')) {
return { name: 'Forbidden' }
}
// 编辑不能访问财务模块
if (userStore.role === 'editor' && to.path.startsWith('/finance')) {
return { name: 'Forbidden' }
}
// VIP 用户才能访问某些页面
if (to.meta.vipOnly && userStore.level < 1) {
return { name: 'Upgrade' }
}
// ... 越来越长
})
这种写法当角色和页面多了以后,守卫就变成了一坨难以维护的意大利面条。
问题二:路由表和权限定义分离
// 路由表里
{ path: '/admin/users', component: UserManage, meta: { role: 'admin' } }
// 守卫里又要判断一遍
if (to.meta.role && userStore.role !== to.meta.role) {
return { name: 'Forbidden' }
}
角色定义了两份,迟早会不一致。
问题三:动态路由注册时机混乱
// 有时候在登录后注册,有时候在守卫里注册
async function registerRoutes() {
const menus = await fetchMenus()
menus.forEach(menu => {
router.addRoute(menu) // 动态添加
})
}
刷新页面时,如果用户已登录,守卫先触发,但路由还没注册完,导致 404。
我的方案:路由表声明权限,守卫统一校验
第一步:定义权限标志
// permissions.ts
export const Permission = {
// 基础权限
VIEW: 1 << 0, // 1 - 查看
CREATE: 1 << 1, // 2 - 创建
EDIT: 1 << 2, // 4 - 编辑
DELETE: 1 << 3, // 8 - 删除
// 模块权限
FINANCE_VIEW: 1 << 4, // 16 - 财务模块查看
FINANCE_EDIT: 1 << 5, // 32 - 财务模块编辑
ADMIN_USER: 1 << 6, // 64 - 用户管理
ADMIN_ROLE: 1 << 7, // 128 - 角色管理
// 特殊权限
VIP_CONTENT: 1 << 8, // 256 - VIP 内容
ALL: -1, // 所有权限(用于 superadmin)
} as const
export type PermissionKey = keyof typeof Permission
export type PermissionValue = typeof Permission[PermissionKey]
用位掩码的好处是:权限可以叠加,可以做交集判断,性能也比字符串数组快。
第二步:定义角色
// roles.ts
import { Permission } from './permissions'
interface Role {
id: string
name: string
permissions: number
}
export const Roles = {
GUEST: {
id: 'guest',
name: '访客',
permissions: Permission.VIEW,
} as Role,
USER: {
id: 'user',
name: '普通用户',
permissions: Permission.VIEW | Permission.CREATE | Permission.EDIT,
} as Role,
EDITOR: {
id: 'editor',
name: '编辑',
permissions: Permission.VIEW | Permission.CREATE | Permission.EDIT | Permission.DELETE,
} as Role,
FINANCE: {
id: 'finance',
name: '财务',
permissions: Permission.VIEW | Permission.CREATE | Permission.EDIT | Permission.DELETE | Permission.FINANCE_VIEW | Permission.FINANCE_EDIT,
} as Role,
ADMIN: {
id: 'admin',
name: '管理员',
permissions: Permission.ALL,
} as Role,
}
export type RoleId = keyof typeof Roles
第三步:在路由表里声明所需权限
// router/routes.ts
import { Permission } from '@/permissions'
import { Roles } from '@/roles'
export const routes = [
{
path: '/',
name: 'Home',
component: Home,
meta: {
permissions: Permission.VIEW,
},
},
{
path: '/admin',
name: 'Admin',
component: AdminLayout,
meta: {
permissions: Permission.VIEW,
},
children: [
{
path: 'users',
name: 'AdminUsers',
component: UserManage,
meta: {
permissions: Permission.ADMIN_USER,
},
},
{
path: 'roles',
name: 'AdminRoles',
component: RoleManage,
meta: {
permissions: Permission.ADMIN_ROLE,
},
},
],
},
{
path: '/finance',
name: 'Finance',
component: FinanceLayout,
meta: {
permissions: Permission.FINANCE_VIEW,
},
children: [
{
path: 'overview',
name: 'FinanceOverview',
component: FinanceOverview,
meta: {
permissions: Permission.FINANCE_VIEW,
},
},
{
path: 'edit',
name: 'FinanceEdit',
component: FinanceEdit,
meta: {
permissions: Permission.FINANCE_EDIT,
},
},
],
},
{
path: '/vip',
name: 'VIP',
component: VIPLayout,
meta: {
permissions: Permission.VIP_CONTENT,
},
},
{
path: '/login',
name: 'Login',
component: Login,
meta: {
// 登录页不需要权限,且作为白名单
requiresAuth: false,
permissions: 0,
},
},
]
第四步:守卫统一校验
// router/guards.ts
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from './routes'
import { useAuthStore } from '@/stores/auth'
import { Permission } from '@/permissions'
export const router = createRouter({
history: createWebHistory(),
routes,
})
// 判断是否有权限
function hasPermission(userPermissions: number, requiredPermissions: number): boolean {
// superadmin
if (userPermissions === Permission.ALL) return true
// 需要的权限是 0 表示不需要权限(如登录页)
if (requiredPermissions === 0) return true
// 按位与判断
return (userPermissions & requiredPermissions) === requiredPermissions
}
// 递归检查路由及其父路由的权限
function checkRoutePermission(route: RouteLocationNormalized): boolean {
const authStore = useAuthStore()
const userRole = authStore.role
if (!userRole) return false
const userPermissions = Roles[userRole].permissions
const requiredPermissions = route.meta.permissions ?? Permission.VIEW
return hasPermission(userPermissions, requiredPermissions)
}
router.beforeEach(async (to, from) => {
const authStore = useAuthStore()
// 白名单路由直接放行
if (to.meta.requiresAuth === false) {
return true
}
// 未登录先尝试从 localStorage 恢复
if (!authStore.isLoggedIn) {
authStore.initFromStorage()
}
// 仍未登录,跳转登录页
if (!authStore.isLoggedIn) {
return {
name: 'Login',
query: { redirect: to.fullPath },
}
}
// 权限校验
if (!checkRoutePermission(to)) {
// 没有权限,看看有没有 403 页面
if (router.hasRoute('Forbidden')) {
return { name: 'Forbidden' }
}
// 没有 403 页面,返回首页
return from.fullPath ? false : { name: 'Home' }
}
return true
})
动态路由怎么处理
有些系统是登录后从后端获取菜单,然后动态注册路由。这种场景需要解决注册时机的问题。
// stores/permission.ts
export const usePermissionStore = defineStore('permission', () => {
const routesAdded = ref(false)
const dynamicRoutes = ref<RouteRecordRaw[]>([])
async function fetchAndAddRoutes() {
if (routesAdded.value) return
// 从后端获取菜单
const menus = await fetchUserMenus()
const processed = processMenus(menus) // 转换为自己系统的路由格式
dynamicRoutes.value = processed
processed.forEach(route => {
router.addRoute(route)
})
routesAdded.value = true
}
function reset() {
routesAdded.value = false
dynamicRoutes.value = []
}
return { routesAdded, dynamicRoutes, fetchAndAddRoutes, reset }
})
守卫里:
router.beforeEach(async (to, from) => {
const authStore = useAuthStore()
const permissionStore = usePermissionStore()
// 白名单
if (to.meta.requiresAuth === false) {
return true
}
// 确保已登录
if (!authStore.isLoggedIn) {
authStore.initFromStorage()
}
if (!authStore.isLoggedIn) {
return {
name: 'Login',
query: { redirect: to.fullPath },
}
}
// 动态注册路由(只执行一次)
if (!permissionStore.routesAdded) {
await permissionStore.fetchAndAddRoutes()
// 重新触发当前导航,让它走到新注册的路由
return to.fullPath
}
// 权限校验
if (!checkRoutePermission(to)) {
return { name: 'Forbidden' }
}
return true
})
关键是:守卫里执行动态注册后,要用 return to.fullPath 重新触发当前导航,这样新路由才能被匹配到。
按钮级别的权限指令
路由守卫只能控制页面级访问,按钮级别的权限控制需要另外实现:
// directives/permission.ts
import { Directive } from 'vue'
import { Permission } from '@/permissions'
import { useAuthStore } from '@/stores/auth'
import { Roles } from '@/roles'
export const vPermission: Directive = {
mounted(el, binding) {
const authStore = useAuthStore()
const requiredPermission = binding.value as number
const userRole = authStore.role
if (!userRole) {
el.parentNode?.removeChild(el)
return
}
const userPermissions = Roles[userRole].permissions
if (userPermissions === Permission.ALL) {
return // superadmin 什么都能看
}
const has = (userPermissions & requiredPermission) === requiredPermission
if (!has) {
el.parentNode?.removeChild(el)
}
},
}
使用方式:
<template>
<button v-permission="Permission.DELETE">删除</button>
<button v-permission="Permission.ADMIN_USER">用户管理</button>
</template>
总结
权限控制方案的核心要点:
- 权限用位掩码表示:便于叠加和判断,性能好
- 角色定义成常量对象:方便新增角色
- 路由表声明所需权限:权限定义在一处
- 守卫统一校验:不在业务组件里散落权限判断
- 动态路由注意时机:确保路由注册完成后再进行匹配
如果你现在的守卫已经有几百行了,可以考虑按这个思路重构一次。改动不小,但后续维护会轻松很多。
