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-if="item.children && item.children.length > 0" :index="resolvePath(item.path)" > <template #title> <el-icon> <component :is="item.meta.icon || 'Menu'" /> </el-icon> <span>{{ item.meta.title }}</span> </template> <sidebar-item v-for="child in item.children" :key="child.path" :item="child" :base-path="resolvePath(item.path)" /> </el-sub-menu> <el-menu-item v-else :index="resolvePath(item.path)" > <el-icon> <component :is="item.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.menus:
<el-menu :default-active="route.path" router unique-opened > <sidebar-item v-for="item in permissionStore.menus" :key="item.path" :item="item" base-path="" /> </el-menu>一个容易忽略的细节是resolvePath里的路径拼接。后端返回的 children 里path通常是不带/前缀的相对路径,比如user,父级是/system,那它的完整路径就是/system/user。如果后端返回的是绝对路径,那直接返回原值。这个判断写不规范,就会出现“菜单能显示但点击跳转地址不对”的怪问题。
5. 按钮权限:用自定义指令控制每一个操作
5.1 v-permission 指令的最小实现
按钮权限通常用自定义指令实现,这样只需要在按钮上加一行v-permission="'system: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-permission="'system: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-router@4、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 :include="cachedViews"> <component :is="Component" /> </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 了再救火。如果这篇文章帮到你,或者你想看我单独聊聊后端菜单树和按钮权限点怎么设计,评论区告诉我,我后续再拆开细讲。