ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Vue3+Vite+Pinia后台权限控制:动态路由、菜单与按钮权限实现指南

Vue3+Vite+Pinia后台权限控制:动态路由、菜单与按钮权限实现指南 vue3 vite pinia 这套组合做后台管理系统最绕不开的就是权限控制。动态路由、菜单权限、按钮权限这三样东西听起来高大上其实本质上是同一套权限数据在三个不同层面的映射路由是权限决定你能不能进某个页面菜单是权限决定你侧边栏能看到哪些入口按钮是权限决定你页面上能不能点某个操作。很多人一上来就写码结果写着写着发现三个地方各搞一套逻辑越写越乱。这篇就把我从零实现的一套方案梳理清楚从权限模型设计到动态路由挂载、菜单递归渲染、按钮指令封装再到刷新白屏这类实战坑一次性说透适合正在做后台管理系统、或者准备面试时被问到“Vue3 权限怎么做”的朋友直接参考。1. 权限模型设计先分清三种权限再动手写代码1.1 前端权限的真实边界先泼一盆冷水前端做的所有权限都只是体验层面的控制不是真正的安全边界。真正的安全必须靠后端接口校验兜底。一个用户登录后能调哪些接口、能改哪些数据这是后端说了算前端做的动态路由、菜单隐藏、按钮禁用只是让不该看到的东西不显示、不该点的按钮点不了让界面干净、操作引导清晰。理解了这个边界之后你就知道为什么权限模型要分层设计。我见过不少团队把权限逻辑全堆在路由守卫里判断角色、过滤菜单、生成按钮码全在一个文件里做出了 bug 排查半天。正确做法是让数据源统一、展示层分离登录接口返回用户的角色和权限码前端把这些数据存到 Pinia 里路由守卫负责用它们过滤路由布局组件负责用它们渲染菜单按钮指令负责用它们控制操作入口。职责分开后面任何一层出问题都不会牵连其他部分。1.2 一个后台管理系统的权限字段设计实际项目里权限数据通常来自登录接口或单独的用户信息接口。以我自己用的字段为例接口返回大概是这样的结构{ userId: 10001, username: zhangsan, roles: [admin], permissions: [system:user:list, system:user:add, system:user:delete], menus: [ { path: /system, component: Layout, meta: { title: 系统管理, icon: Setting }, children: [ { path: user, component: system/user/index, meta: { title: 用户管理, icon: User } } ] } ] }roles是角色列表permissions是按钮级别的权限码menus是菜单树。菜单树由后端根据角色动态生成前端不自己拼菜单这是当前业界主流的做法。为什么因为菜单和路由的对应关系、菜单的层级结构、不同角色能看到哪些分支这些都是后端配置中心干的活。前端硬编码菜单树每次改权限都要重新发版在真实公司里是没法接受的。有一点要注意menus里的component是字符串因为 JSON 里不能直接放组件对象前端需要把这个字符串映射到真实的.vue组件。这一步是动态路由的核心难点后面我会专门讲。2. 动态路由从后端返回菜单到路由真正挂载2.1 静态路由与动态路由怎么拆分路由不能全走动态登录页、404 页、首页这些所有人都能访问的页面必须做成静态路由在项目初始化时就注册好。只有需要权限才能进入的业务页面才放到动态路由里。这样设计的好处是路由守卫能正常拦截未登录用户跳转到登录页不会出现“登录页都需要权限”这种尴尬情况。我习惯在router/routes.js里维护两个数组constantRoutes和asyncRoutes。其中asyncRoutes是一个基础的路由表模板里面每个路由的meta.roles标记了哪些角色能访问。前端根据用户角色去过滤这个模板过滤出来的部分再动态注册。这种方案的好处是不依赖后端返回复杂的组件字符串适合前端驱动权限的场景。// router/routes.js export const constantRoutes [ { path: /login, component: () import(/views/login/index.vue), meta: { title: 登录 } }, { path: /, component: () import(/layout/index.vue), redirect: /dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(/views/dashboard/index.vue), meta: { title: 首页, icon: HomeFilled } } ] } ] export const asyncRoutes [ { path: /system, component: () import(/layout/index.vue), redirect: /system/user, name: System, meta: { title: 系统管理, icon: Setting, roles: [admin, system] }, children: [ { path: user, name: SystemUser, component: () import(/views/system/user/index.vue), meta: { title: 用户管理, roles: [admin, system] } }, { path: role, name: SystemRole, component: () import(/views/system/role/index.vue), meta: { title: 角色管理, roles: [admin] } } ] } ]前端过滤asyncRoutes的逻辑虽然直观但也有个明显问题新增一个页面时前端要写页面、写路由、配角色三步少一步都不行。而且角色和路由的对应关系散落在代码里运营人员想调一个角色的可见范围必须找前端改代码。2.2 后端返回路由数据前端注册的完整链路我更推荐的做法是后端返回可访问路由树前端不负责过滤而是拿着菜单树直接注册。这样权限配置收敛到后端前端只做“翻译”和“挂载”两件事。后端返回的数据就是我在第一章节写的那种结构前端拿到后需要做一次转换把component字符串替换成真正的组件对象。整个链路是这样的登录 → 获取用户信息和菜单树 → 存进 Pinia → 在路由守卫里调用router.addRoute()逐条注册 → 刷新页面时先用store里的数据重新注册再放行。每一步看起来简单但细节都在坑里尤其这个component字符串的映射是很多新手卡住的地方。2.3 组件映射import.meta.glob 批量加载Vite 环境不能像 Webpack 那样用require动态引入组件import()又只支持静态路径。解决方案是用 Vite 提供的import.meta.glob一次性把views目录下所有组件都加载进来建立字符串到组件对象的映射表// utils/loadView.js const modules import.meta.glob(/views/**/*.vue) export function loadView(componentPath) { // componentPath 可能是 system/user/index 这种不带前缀的写法 return modules[/src/views/${componentPath}.vue] }这里有个容易踩的坑import.meta.glob默认是懒加载模式返回的映射表里每个 value 都是一个返回 Promise 的函数这正好和 Vue Router 懒加载路由的要求匹配所以直接用没问题。但如果后端返回的component可能是Layout这种特殊值要单独处理。因为 Layout 不是业务页面它通常直接从/layout/index.vue导入不走 glob 映射。我的做法是在转换函数里加个判断// utils/transformRoute.js import Layout from /layout/index.vue import { loadView } from ./loadView export function transformRouteTree(routes) { return routes.map(route { const newRoute { ...route } if (newRoute.component Layout) { newRoute.component Layout } else if (newRoute.component) { newRoute.component loadView(newRoute.component) } if (newRoute.children) { newRoute.children transformRouteTree(newRoute.children) } return newRoute }) }转换完成之后路由就是一个真正可用的RouteRecordRaw数组直接在守卫里addRoute即可。这一段是整个动态路由方案的“心脏”我建议你在自己的项目里单独抽一个transformRoute.js文件不要和别的逻辑混在一起。3. Pinia 状态管理与路由守卫的配合3.1 permission store 怎么设计才顺手Pinia 在这里的核心作用是把路由表、菜单树、权限码这些数据集中管理让路由守卫、侧边栏组件、按钮指令都能从同一个数据源读取。我实际项目里 permission store 的状态就三块routes完整路由表给侧边栏用、addRoutes动态添加的那部分退出登录时遍历移除、buttons按钮权限码数组给指令用。// stores/permission.js import { defineStore } from pinia import { constantRoutes } from /router/routes import { transformRouteTree } from /utils/transformRoute export const usePermissionStore defineStore(permission, { state: () ({ routes: constantRoutes, addRoutes: [], menus: [], buttons: [] }), actions: { setRoutes(menuTree, permissions) { const dynamicRoutes transformRouteTree(menuTree) this.addRoutes dynamicRoutes this.routes [...constantRoutes, ...dynamicRoutes] this.menus menuTree this.buttons permissions || [] return dynamicRoutes }, resetPermission() { this.addRoutes [] this.routes constantRoutes this.menus [] this.buttons [] } } })设计的时候注意一点routes只应该由constantRoutes和addRoutes拼出来不要手动往里面塞东西否则退出登录后resetPermission可能清不干净。这也是我踩过坑后才总结出来的规矩任何状态都通过 action 修改不要在组件里直接改 store 的 state。3.2 路由守卫里避免刷新白屏的关键写法动态路由最常见的坑就是刷新页面后白屏或者跳到 404原因很简单刷新后 Pinia 里的数据被清空了但路由已经注册过了页面在 store 恢复之前就开始匹配。解决的核心思路是检测到权限数据不存在时先获取数据、注册动态路由再“重放”这次导航。// router/index.js import { usePermissionStore } from /stores/permission import { useUserStore } from /stores/user const whiteList [/login] router.beforeEach(async (to, from, next) { const userStore useUserStore() const permissionStore usePermissionStore() if (userStore.token) { if (to.path /login) { next({ path: / }) } else { // 关键判断菜单数据为空说明首次进入或刚刷新 if (permissionStore.menus.length 0) { try { // 拉取用户信息和权限数据 const userInfo await userStore.fetchUserInfo() const dynamicRoutes permissionStore.setRoutes( userInfo.menus, userInfo.permissions ) dynamicRoutes.forEach(route { router.addRoute(route) }) // 重放导航确保新路由生效 next({ ...to, replace: true }) } catch (error) { userStore.resetToken() next(/login?redirect${to.path}) } } else { next() } } } else { if (whiteList.includes(to.path)) { next() } else { next(/login?redirect${to.path}) } } })很多人会问为什么next({ ...to, replace: true })而不是直接next()。原因是addRoute之后当前导航所匹配的路由记录可能还是旧的直接next()有时候会碰到“第一次进目标页是空白”的情况。重放导航可以让 Vue Router 重新匹配一遍完整路由表确保动态路由真正生效。这个写法我实测过稳。4. 菜单权限侧边栏如何跟着用户变化4.1 为什么菜单不能直接写死在前端菜单权限和动态路由是同一个数据源但渲染机制不一样。动态路由负责“能不能访问”菜单负责“看得到哪些入口”。如果你在前端把菜单结构写死比如在 Sidebar 组件里硬编码一堆el-menu-item那么权限控制就退化成“隐藏某些菜单项”代码里全是v-if判断角色改一次权限要翻半天的模板维护成本极高。正确做法是用permissionStore.menus渲染菜单用户能看到的菜单树是后端已经过滤好的。这样前端只有一个统一的渲染入口不管用户是管理员还是普通操作员流程都一样拿菜单树、递归渲染、点击跳转。菜单权限的调整完全在后端配置中心完成前端不用动。4.2 递归渲染菜单组件侧边栏菜单是一个树形结构可能有两层、三层甚至更多级所以必须用递归组件。Element Plus 的菜单组件配合递归是标准做法我分享一个精简但完整的写法。先定义递归子组件SidebarItem.vue它会根据自己的item.meta.title和item.children判断渲染成子菜单还是菜单项!-- components/Layout/SidebarItem.vue -- template el-sub-menu v-ifitem.children item.children.length 0 :indexresolvePath(item.path) template #title el-icon component :isitem.meta.icon || Menu / /el-icon span{{ item.meta.title }}/span /template sidebar-item v-forchild in item.children :keychild.path :itemchild :base-pathresolvePath(item.path) / /el-sub-menu el-menu-item v-else :indexresolvePath(item.path) el-icon component :isitem.meta.icon || Menu / /el-icon template #title{{ item.meta.title }}/template /el-menu-item /template script setup import { computed } from vue import { useRoute } from vue-router const props defineProps({ item: { type: Object, required: true }, basePath: { type: String, default: } }) const route useRoute() function resolvePath(childPath) { if (childPath.startsWith(/)) return childPath return ${props.basePath}/${childPath}.replace(/\//g, /) } const isActive computed(() { return route.path resolvePath(props.item.path) }) /script然后在Sidebar.vue里遍历permissionStore.menusel-menu :default-activeroute.path router unique-opened sidebar-item v-foritem in permissionStore.menus :keyitem.path :itemitem base-path / /el-menu一个容易忽略的细节是resolvePath里的路径拼接。后端返回的 children 里path通常是不带/前缀的相对路径比如user父级是/system那它的完整路径就是/system/user。如果后端返回的是绝对路径那直接返回原值。这个判断写不规范就会出现“菜单能显示但点击跳转地址不对”的怪问题。5. 按钮权限用自定义指令控制每一个操作5.1 v-permission 指令的最小实现按钮权限通常用自定义指令实现这样只需要在按钮上加一行v-permissionsystem:user:add就能自动控制显隐项目里用起来非常爽。核心思路是指令在元素挂载时检查当前用户的权限码没有权限就直接把 DOM 元素移除。// directives/permission.js import { usePermissionStore } from /stores/permission const permissionDirective { mounted(el, binding) { const { value } binding const permissionStore usePermissionStore() if (value Array.isArray(value) value.length 0) { const hasPermission value.some(code permissionStore.buttons.includes(code) ) if (!hasPermission) { el.parentNode el.parentNode.removeChild(el) } } else { throw new Error(v-permission 指令需要传权限码例如 v-permission\system:user:add\) } } } export function setupPermissionDirective(app) { app.directive(permission, permissionDirective) }在main.js里注册// main.js import { setupPermissionDirective } from /directives/permission const app createApp(App) setupPermissionDirective(app)使用方式el-button v-permissionsystem:user:add新增用户/el-button el-button v-permission[system:user:edit, system:user:delete]批量操作/el-button这里我故意把 value 设计成支持单个字符串和数组两种形式。单个字符串是最常用的情况数组则用于“满足其一即可显示”的按钮。比如“导出”按钮可能管理员和操作员都有权限那权限码就是两个不同的字符串只要包含任意一个就展示。5.2 绑定时机和指令的局限指令在mounted里执行意味着元素挂载时拿到的权限码必须是最终状态。如果你的页面权限码是在组件内部异步加载的那指令执行时permissionStore.buttons可能还是空的按钮就被移除掉了等权限码到了也不会自动恢复。这个坑我栽过一次。解决思路有两种一种是不用指令改用v-if 计算属性这样权限数据到达后 Vue 的响应式机制会重新渲染另一种是确保进入页面之前权限数据已经就绪。我推荐第二种因为权限数据在路由守卫里就拉取完成了按钮权限天然就绪指令方案完全够用。如果你确实需要在页面内异步获取权限那就老老实实用v-if。5.3 从按钮权限到接口权限的加固按钮权限是纯前端用户体验可别以为把按钮隐藏了就安全了。接口必须要做权限校验这是底线。前端隐藏只是不给用户入口恶意用户完全可以直接用 Postman 调接口。所以我在项目里做了两层按钮用指令控制显隐接口统一封装请求拦截器后端返回 403 时统一跳转无权限页面。这么一配合前端体验好后端安全也不漏。还有一个小细节权限码的命名规范必须统一。我用的是模块:操作的格式比如system:user:add这样指令、接口权限注解、后端数据库里的权限点能一一对应。如果命名混乱前端写一个user_add后端写一个USER_ADD两边对不上排查起来非常痛苦。6. 实操过程一个可复现的最小实现6.1 项目目录结构为了让你能直接抄作业我把最小可运行项目的结构列出来。这里假设你用的是 Vite 创建的 Vue3 项目安装了vue-router4、pinia、element-plus。src/ ├── main.js ├── App.vue ├── router/ │ ├── index.js # 路由实例 全局守卫 │ └── routes.js # constantRoutes ├── stores/ │ ├── user.js # token、用户信息 │ └── permission.js # routes、menus、buttons ├── utils/ │ ├── transformRoute.js # 后端路由转换 │ └── loadView.js # import.meta.glob 映射 ├── directives/ │ └── permission.js # v-permission 指令 ├── layout/ │ ├── index.vue │ └── Sidebar/ │ ├── Sidebar.vue │ └── SidebarItem.vue └── views/ ├── login/index.vue ├── dashboard/index.vue └── system/ ├── user/index.vue └── role/index.vue这里我把menus直接用作asyncRoutes的数据源所以transformRoute和loadView是核心工具。如果你需要前端驱动型权限也可以保留routes.js里的asyncRoutes两者选其一即可混用会把自己绕晕。6.2 登录后权限初始化的完整时序整个流程至少要串通四个环节登录页提交表单、存 token、拉用户信息、注册动态路由。我按顺序拆开讲一下。登录页拿到 token 后先存到 user store 和 cookie 里然后直接router.push(/)。此时因为还没有用户信息路由守卫会走permissionStore.menus.length 0这个分支自动去拉用户信息、注册动态路由、重放导航。所以登录页不需要手动调fetchUserInfo这两步在守卫里做更统一。// stores/user.js节选 export const useUserStore defineStore(user, { state: () ({ token: getToken() || , userInfo: null }), actions: { async login(loginForm) { const { token } await loginApi(loginForm) this.token token setToken(token) }, async fetchUserInfo() { const data await getUserInfoApi() this.userInfo data return data }, resetToken() { this.token removeToken() this.userInfo null } } })6.3 退出登录时怎么清理干净退出登录要清理的不只是 token 和用户信息还有动态注册的路由。因为router.addRoute注册的路由是全局的不清理的话下次另一个账号登录时旧账号的路由可能还在路由表里出现“明明没权限还能访问”的 bug。清理方法有两个一个是用router.removeRoute(name)逐个移除这要求你记录所有动态注册路由的name另一个更省事的方法是直接location.reload()刷新后整个应用状态归零。我的建议是中小型后台项目直接reload()最省心虽然体验上会闪一下页面但它能保证所有状态彻底复位。如果你对体验要求高就在resetPermission里遍历addRoutes调用removeRoute。// 退出登录完整逻辑 function handleLogout() { const userStore useUserStore() const permissionStore usePermissionStore() // 移除动态注册的路由 permissionStore.addRoutes.forEach(route { router.removeRoute(route.name) }) permissionStore.resetPermission() userStore.resetToken() router.push(/login) }这里有个细节addRoute返回值是一个移除函数但我个人更推荐记录name然后用removeRoute(name)因为addRoute返回的函数只能移除它注册的那一条嵌套路由的子路由可能不会被一起处理。保险的做法是递归记录所有路由的name。7. 常见问题与排查技巧实录7.1 刷新之后变成 404 或者白屏这是动态路由方案里出现频率最高的问题原因几乎都是刷新后路由先匹配、动态路由还没注册完成。我的排查顺序一般是确认permissionStore.menus.length 0的判断有没有生效刷新后 store 确实被重置了。确认守卫里有没有next({ ...to, replace: true })这行重放逻辑如果没有加上。确认transformRouteTree转换后的component不是undefined。一旦loadView拼错了路径Vue Router 会警告“No match found for location”看起来就像 404。如果你有/:pathMatch(.*)*的兜底 404 路由必须保证它是在所有动态路由注册完之后再 addRoute否则可能被动态路由匹配干扰。// 404 动态添加示例 router.addRoute({ path: /:pathMatch(.*)*, name: NotFound, component: () import(/views/error/404.vue) })这个 404 路由要放在dynamicRoutes.forEach(...)的后面。如果你一开始就把它放进constantRoutes那动态路由还没注册时访问一个合法路径会先被 404 捕获有些版本会直接卡住。我建议始终把 404 路由单独动态添加。7.2 动态路由与 keep-alive 缓存不生效后台管理系统基本都会用keep-alive缓存列表页避免每次切换菜单都重新请求数据。但动态路由配keep-alive有一个经典问题缓存的include是根据组件name匹配的而动态路由里组件name经常没设置。Vue 3 里script setup单文件组件默认不会自动推断出name除非你用defineOptions显式声明。script setup defineOptions({ name: SystemUser }) /script然后 keep-alive 的 include 才有效router-view v-slot{ Component } keep-alive :includecachedViews component :isComponent / /keep-alive /router-view另一个容易忽略的点是路由记录的name要保持唯一并且和组件name尽量一致。我在项目里遇到过两个页面都叫List结果其中一个页面的缓存总是串到另一个页面的数据。排查了很久才发现是路由name重复了。用import.meta.glob批量映射时特别容易犯这个错给后端返回的菜单树里name字段做一次唯一性校验是最省事的预防手段。7.3 菜单层级复杂时递归组件的目录路径拼接后端返回的菜单有时候是system.user这种用点分隔的字符串有时候是带/的数组路径各家后端风格都不一样。我最开始遇到的是点分隔那时候天真地想“直接 split 一下拼成数组不就行了”结果发现 menu 组件的index是不能重复的两个子菜单的 path 相同会导致 Element Plus 菜单高亮错乱。现在的统一处理规则是前端组件里只认/system/user这种最终路径无论后端返回什么格式在前端转换时先用resolvePath拼好完整路径存到meta.fullPath里菜单渲染和路由跳转都只用这个字段。这样即使后端换了格式前端也只改转换函数一处不动递归组件。7.4 权限码维护和调试技巧权限码经常出现“页面里明明写了v-permission按钮还是消失了”的问题。我自己的排查套路是先在浏览器控制台里打印permissionStore.buttons看用户到底有哪些权限码再对照按钮上的权限码字符串90% 的情况是后端少返回了一个 permission 点或者前端权限码打错了一个字母。为了快速定位我在开发环境会做一个全局调试面板用window.__permissionStore把 store 暴露出来这样不用打开 Vue DevTools 也能看到实时权限数据。还有一个调试技巧很多项目按钮权限由指令控制移除但元素一旦被移除就看不到了你不知道这个位置原本有没有按钮。所以我在开发模式里给指令加了一个辅助逻辑把没有权限的按钮渲染成禁用状态而不是移除并且在控制台打印一条警告“按钮 xxx 因权限不足被禁用”。上线时才真正改成移除。这样开发时不会误以为模板写错了。最后一点心得这套 vue3 vite pinia 的权限方案我前前后后重构过三次最大的体会是权限不是写出来的是设计出来的。前端能做的事只是把后端给的权限数据翻译成界面表现所以从一开始就要把数据源想清楚定好接口返回结构再谈路由、菜单、按钮的实现。另外动态路由加 keep-alive、刷新重放导航、退出登录清理路由这三个点是所有后台系统都会踩的共性问题强烈建议你在写代码前就把这几处打好补丁别等测试报 bug 了再救火。如果这篇文章帮到你或者你想看我单独聊聊后端菜单树和按钮权限点怎么设计评论区告诉我我后续再拆开细讲。
返回列表