摘要 :在 B 端/SaaS 系统中,权限控制是核心命脉。传统的 Vue 项目常常面临"刷新页面路由丢失"、"静态路由与动态菜单不匹配"、"按钮级权限依赖繁琐的 v-if"等痛点。本文基于 Vue3 + Pinia + Vue Router 4,深度剖析一套"动态路由表"与"自定义指令"相结合的企业级权限架构。我们将从底层源码出发,解决路由死循环、Token 失效拦截、以及菜单树形渲染等极端场景问题。
一、 引言:前端权限控制的"三座大山"
在开发企业级应用时,权限控制绝非简单的"登录后跳转"。它通常包含以下三个维度的复杂诉求:
- 路由级(页面级)权限 :用户不能通过在浏览器输入 URL 的方式,访问他没有权限的页面。
- 按钮级(操作级)权限 :同一个页面,管理员能看到"删除"按钮,普通员工看不到。
- 菜单级(展示级)权限 :左侧导航栏必须根据用户的真实权限动态渲染,而不是把无权访问的菜单隐藏或置灰。
为了实现这三层控制,我们抛弃了传统的"前端写死全量路由"的做法,采用了基于 RBAC(基于角色的访问控制)模型的前后端分离动态路由方案 。
二、 架构设计:动态路由的拆解与合并
Vue Router 4 提供了极其强大的 addRoute API。我们的核心设计思想是:静态路由包含基础页面,动态路由由后端下发。
1. 路由的分类
在 router/index.ts 中,我们将路由分为两部分:
-
静态路由(Static Routes):如登录页、404、403 以及主 Layout(框架外壳)。任何人都可以访问。
-
动态路由(Dynamic Routes):如工作台、项目管理、系统配置。这些路由并没有直接写进
createRouter的routes数组中,而是需要通过接口向后台请求。
2. 权限数据结构设计
为了配合前端渲染,后端需要返回两类数据:菜单树(用于渲染侧边栏) 和 权限编码列表(用于按钮级控制) 。
// 后端返回的权限数据格式
interface PermissionResponse {
// 1. 菜单树:包含 path, component, meta 等信息
menus: MenuTreeItem[];
// 2. 权限编码集合:如 ['api:create', 'project:edit']
permissions: string[];
}
三、 核心实现一:全局路由守卫(Router Guards)与动态注册
很多新手写路由守卫,最容易出现两个致命 Bug:刷新页面动态路由丢失 和 死循环重定向。
我们的解决方案是在 guards.ts 中实现极致的状态管理:
// src/router/guards.ts
import type { Router } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'
export function setupRouterGuards(router: Router) {
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
const permissionStore = usePermissionStore()
// 1. 白名单放行(无需登录的页面)
if (to.meta.requiresAuth === false) {
return next()
}
// 2. 判断是否已登录(是否有 Token)
if (!userStore.token) {
return next({ path: '/login', query: { redirect: to.fullPath } })
}
// 3. 核心:判断是否已拉取过权限
// 第一次进入,或者刷新页面时,permissionStore.loaded 为 false
if (!permissionStore.loaded) {
try {
// 拉取用户信息和权限路由,并动态 addRoute
await permissionStore.fetchPermissions()
// 关键操作:路由注册完成后,必须使用 replace 重新进入当前路由
// 否则会出现白屏或者 404
return next({ ...to, replace: true })
} catch (error) {
// 如果获取权限失败(如 Token 过期),清除凭证并踢回登录页
userStore.clearToken()
return next('/login')
}
}
// 4. 按钮级权限的细粒度路由校验(可选)
const requiredPermission = to.meta?.permission as string
if (requiredPermission && !permissionStore.hasPermission(requiredPermission)) {
return next('/403')
}
next()
})
}
关键点:为什么不直接把路由 next() 过去?
因为首次访问时,路由尚未 addRoute 完成,此时直接 next() 依然会匹配到 404。使用 next({ ...to, replace: true }) 可以中断当前导航,等注册完毕后,基于新的路由表重新发起一次导航。
踩坑实录一:被 next() 搞崩溃的下午
刚上动态路由时,本地开发极其丝滑,一放测试环境,只要一刷新页面,浏览器就直接卡死,甚至提示 Maximum call stack size exceeded(栈溢出)。
我扒开 router.beforeEach 的日志一看,发现它一直在 login 和 dashboard 之间疯狂反复横跳。
原因分析:
当刷新页面时,permissionStore.loaded 初始是 false。路由拦截后去拉接口,拉完把 loaded 置为 true,然后 next() 放行。但我漏了一个致命逻辑:如果此时 token 过期了,接口返回 401。
我的 Axios 拦截器捕获到 401,把 token 清空,并跳转 /login。这时候路由守卫再次触发,发现没有 token,又拦截跳 /login;但由于路由匹配混乱,或者 next({...to}) 的写法不对,导致它在死胡同里疯狂打转。
真实解决方案:
在守卫里,拉取权限的动作必须加 try...catch 并配合强制跳出。
if (!permissionStore.loaded) {
try {
await permissionStore.fetchPermissions()
// 坑点:千万别写 next(),必须重新触发一次导航,让新挂载的路由生效
return next({ ...to, replace: true })
} catch (err) {
// 坑点:这里如果不手动清理 token,死循环绝对跑不掉
userStore.clearToken()
return next({ path: '/login' })
}
}
四、 核心实现二:Pinia 状态管理与动态路由解析
在 permission.ts 中,我们需要处理后端传来的菜单树,将其转换为 Vue Router 能够识别的RouteRecordRaw 格式。
// src/stores/permission.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import router from '@/router'
import type { RouteRecordRaw } from 'vue-router'
export const usePermissionStore = defineStore('permission', () => {
// 状态
const loaded = ref(false)
const permissions = ref<string[]>([])
const dynamicRoutes = ref<RouteRecordRaw[]>([])
// 动态导入组件的黑魔法
const modules = import.meta.glob('@/views/**/*.vue')
// 将后端菜单转换为 Vue Router 格式
const transformMenuToRoute = (menus: any[]): RouteRecordRaw[] => {
return menus.map(menu => {
const route: RouteRecordRaw = {
path: menu.path,
name: menu.name,
// 核心:通过 glob 映射,避免后端返回字符串导致无法加载组件
component: modules[`/src/views/${menu.component}.vue`],
meta: {
title: menu.meta?.title || menu.name,
icon: menu.meta?.icon,
permission: menu.meta?.permission
},
children: menu.children ? transformMenuToRoute(menu.children) : []
}
return route
})
}
// 拉取权限并动态挂载
const fetchPermissions = async () => {
// 模拟 API 请求
const res = await api.getPermissions()
permissions.value = res.permissions
dynamicRoutes.value = transformMenuToRoute(res.menus)
// 关键:将解析后的路由,动态添加到主 Layout 路由下
dynamicRoutes.value.forEach(route => {
router.addRoute('Layout', route)
})
loaded.value = true
}
const hasPermission = (code: string) => {
return permissions.value.includes(code)
}
return { loaded, permissions, dynamicRoutes, fetchPermissions, hasPermission }
})
关键点:后端返回的 component 只能是字符串(如 "system/user/index")。千万不要用 import(menu.component) 这种动态字符串拼接,Vite 在打包时会将其作为变量处理,导致组件丢失。必须使用 import.meta.glob 预加载所有视图文件。
踩坑实录二:Vite 动态导入的"死局"
在刚写完动态路由解析,我兴冲冲地加了一个新菜单测试。结果一点击刷新,页面直接白屏,控制台报错:Failed to fetch dynamically imported module: /src/views/system/user/index.vue。
我当时排查了整整 4 个小时,差点以为是 Vite 的 Bug。后来才发现,Vue Router 原生的 component: () => import(XXX) 只支持静态字符串。由于后端返回的 menu.component 是一个变量(如 'system/user/index'),Vite 在打包时根本无法分析这个变量到底指向哪个文件,导致打包后这个组件压根就没被包含进去。
真实解决方案(绝杀版):
必须使用 Vite 提供的 import.meta.glob。但这里还有一个极易踩的坑:glob 的路径拼接。如果在 Windows 系统下,路径会变成反斜杠。我最终的代码是这样的:
// 解决前: modules[`/src/views/${menu.component}.vue`] 容易因为大小写或斜杠报错
// 解决后:加入容错处理,并在路径中强制替换
const modules = import.meta.glob('../../views/**/*.vue')
const getComponent = (componentPath: string) => {
const path = `../../views/${componentPath}.vue`
// 如果找不到,就给个 404 占位组件,防止整个应用崩溃
return modules[path] || (() => import('../../views/error/404.vue'))
}
(当时因为少写了一个 ../../,我对着屏幕蒙了。)
五、 核心实现三:按钮级权限指令(自定义 Directive)
对于按钮级别的权限控制,如果使用 v-if="hasPermission('xxx')",页面代码会变得非常丑陋,且不易维护。
我们通过封装自定义指令 v-permission 来实现优雅的控制。这个指令不仅要在挂载时判断,在权限变化时(虽然极少发生)也要能响应。
// src/directives/permission.ts
import { type Directive, type DirectiveBinding } from 'vue'
import { usePermissionStore } from '@/stores/permission'
export const permission: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const { value } = binding
const permissionStore = usePermissionStore()
// 支持传入数组,如 v-permission="['user:add', 'user:edit']" (满足其一即可)
const requiredPermissions = Array.isArray(value) ? value : [value]
if (requiredPermissions.length > 0) {
const hasPerm = requiredPermissions.some(perm => permissionStore.hasPermission(perm))
if (!hasPerm) {
// 如果没有权限,直接从 DOM 树中移除该元素
el.parentNode?.removeChild(el)
}
} else {
throw new Error('v-permission 指令需要指定权限编码,如 v-permission="\'user:add\'"')
}
}
}
在主入口(main.ts)中注册该指令:
app.directive('permission', permission)
在业务代码中,使用极其简洁:
<el-button v-permission="'api:create'" type="primary">新建接口</el-button>
踩坑实录三:仅靠前端指令是不够的
我写完 v-permission 指令后,兴冲冲地拿去演示:管理员能看到"删除"按钮,普通测试人员登录后,按钮直接消失了。
但我的前端 Leader(或者自我反思)直接打开 F12 开发者工具,在元素审查里,把被移除的 <button> 标签手动改了回去,或者直接在控制台调用了删除接口 fetch('/api/v1/delete'),依然能把数据删掉!
原因分析 :
v-permission 只是掩耳盗铃,它只是把 DOM 元素从树上拔掉了,但现代浏览器的 SPA(单页应用),用户可以轻易修改内存和 DOM。
真实解决方案 :
前端的权限指令只是用户体验(UX)优化,真正的安全拦截必须在后端(就是之前发的 RBAC 文章里的 Depends)。
但我这里优化了一下指令:为了防止用户误操作报错,我加上了 display: none 而不是 removeChild。
// 优化后的指令挂载
mounted(el: HTMLElement, binding: DirectiveBinding) {
const { value } = binding
const permissionStore = usePermissionStore()
const requiredPermissions = Array.isArray(value) ? value : [value]
const hasPerm = requiredPermissions.some(perm => permissionStore.hasPermission(perm))
if (!hasPerm) {
// 不用 removeChild,直接隐藏,避免 Vue 在 Diff 时找不到节点报错
el.style.display = 'none'
}
}
六、 性能优化与极端场景分析
-
多标签页(Tab)重复添加路由:
如果用户点击刷新,或者多次调用fetchPermissions,router.addRoute可能会重复添加。
解决方案:在permission.ts中引入router.hasRoute(name)判断,或者使用router.removeRoute(name)清理旧路由。 -
401/403 状态码拦截:
前端 Axios 拦截器必须与路由守卫联动。当接口返回 401 时,清空userStore.token和permissionStore.loaded,并使用window.location.href = '/login'强刷页面,彻底销毁前端残留状态。 -
深拷贝导致的性能问题:
在对菜单树进行递归转换时,尽量使用生成新对象的方式,避免直接修改后端返回的原始数据。
七、 总结
前端权限控制不仅仅是几行代码,它是对前端架构能力的综合考量。一套优雅的权限体系必须具备"静若处子(静态路由兜底)、动如脱兔(动态路由加载)、无感切换(无缝刷新不丢状态)"的特性。
以上方案已在我们独立开发的全栈智测平台(AITest V1.0)中稳定运行,处理数百个菜单节点与数十种角色分配时,依然丝滑流畅。希望这篇深度实践能帮你在 Vue3 的进阶之路上,绕开权限控制的那些坑。
**我是 Jason,独立开发者。专注全栈开发与架构设计,AI 智能落地。个人主页有我连载的 AITest 实战复盘,欢迎关注,随时交流。**