☰
Vue3+Vite+Pinia 实现动态路由与菜单按钮权限体系
2026/10/3 14:46:29 网站建设 项目流程

在后台管理系统里,动态路由、菜单权限和按钮权限这组功能,可以说是几乎所有中后台项目的刚需。我基于 vue3 + vite + pinia 实现过一套比较完整的权限体系,这里把设计思路、核心代码和踩坑经历都整理出来。无论你是刚做完基础后台准备接入权限,还是已经写了一版但总被“刷新白屏”或“权限码无效”困扰,这篇内容都值得花几分钟看完。

这套方案最终实现了三个目标:前端登录后根据用户角色动态生成可访问的路由表,侧边菜单从路由表自动派生,页面内按钮通过指令或函数级判断控制显隐。

1. 整体方案设计与思路拆解

做权限最怕一开始就陷入“写代码”的兴奋感里。我见过不少项目,先把路由表写死,然后登录后拿一个角色字段硬过滤,最后菜单是显了又隐、隐了又显,刷新页面还直接崩掉。

在一套中后台系统里,权限数据量通常不会太大。角色一般就那么几个,路由也就在几十条量级,按钮权限码更是一眼就能数完。所以在设计选型上,我直接放弃了一些重量级方案,没有引入额外的状态管理插件,也没有搞复杂的transition动画缓冲,就靠 pinia 作为核心数据源,把路由、菜单、权限信息统一收敛到一个 store 里。

方案的核心思路用一句话概括:路由表由权限数据动态生成,菜单由路由表派生,按钮权限由全局指令统一控制。

具体拆开看有几个关键决策。

1.1 为什么用 pinia 而不是 localStorage

很多项目图省事,把用户信息和权限码直接塞进 localStorage。但你认真想一下 localStorage 的几个特性:不能响应式、需要手动序列化、多标签页不同步。一旦用户在另一个页面里更新了权限,当前页面是拿不到变化的。

pinia 的属性天然是 reactive 的,在组件里用storeToRefs取出来的数据,路由变化时可以自动联动组件状态。我最终的做法是:请求回来的数据先进 store,再从 store 同步到 sessionStorage 作持久化。这样既有内存的响应式,又能兜底刷新后重新拉取前的过渡期。

1.2 动态路由还是静态路由加拦截

纯静态路由 + 全局前置守卫也能实现页面访问控制。页面能不能进,可以在router.beforeEach里做判断。但这种做法有两个硬伤:

第一,路由表对所有人可见。前端把路由写死在代码里,相当于把菜单结构提前暴露了。就算你不在侧边栏显示,用户手动输入某个 URL 还是可能直达。

第二,菜单生成缺乏数据基础。静态路由本身带不了完整的角色、权限码信息,让每个菜单项和路由产生映射关系,越往后越难维护。

所以我在项目里选择先定义一个基础的公共路由,包含登录页、404页、首页重定向,然后在登录成功后先拼接出用户的路由表,再调用router.addRoute把它动态注册进去。这是目前行业里比较主流、也是被验证得最扎实的做法。

1.3 菜单、路由、按钮之间的关系模型

用一张表来梳理这层关系:

数据维度存储位置主要作用生成方式
完整路由表前端静态定义系统所有可能访问的页面信息手动维护
用户路由表pinia + sessionStorage控制当前用户能访问的页面登录后动态拼接
菜单结构由用户路由表派生侧边栏渲染的数据源前端递归转换
按钮权限码pinia 中单独维护页面内操作级权限控制后端接口返回

这个模型的核心就是把“系统有什么”和“用户能干什么”彻底分离。系统能访问哪些路由,写在前端常量里;用户能看哪些菜单、能点哪些按钮,完全由接口返回的用户信息决定。

2. 核心实现:动态路由与菜单权限

2.1 路由表拆分:公共路由与动态路由

先把路由模块从文件组织上拆开。我一般建一个router目录,里面分constantRoutes和asyncRoutes两部分。

// router/routes.js // 所有角色都能访问的路由 export const constantRoutes = [ { path: '/login', name: 'Login', component: () => import('@/views/login/index.vue'), meta: { hidden: true } }, { path: '/404', name: 'NotFound', component: () => import('@/views/error/404.vue'), meta: { hidden: true } }, { path: '/', name: 'Home', component: () => import('@/layout/index.vue'), redirect: '/dashboard', children: [ { path: 'dashboard', name: 'Dashboard', component: () => import('@/views/dashboard/index.vue'), meta: { title: '首页', icon: 'home' } } ] } ] // 需要权限判断的路由 export const asyncRoutes = [ { path: '/system', name: 'System', component: () => import('@/layout/index.vue'), meta: { title: '系统管理', icon: 'setting', roles: ['admin'] }, children: [ { path: 'user', name: 'UserManage', component: () => import('@/views/system/user/index.vue'), meta: { title: '用户管理', icon: 'user', roles: ['admin'] } }, { path: 'role', name: 'RoleManage', component: () => import('@/views/system/role/index.vue'), meta: { title: '角色管理', icon: 'role', roles: ['admin'] } } ] }, { path: '/order', name: 'Order', component: () => import('@/layout/index.vue'), meta: { title: '订单管理', icon: 'order', roles: ['admin', 'operator'] }, children: [ { path: 'list', name: 'OrderList', component: () => import('@/views/order/list/index.vue'), meta: { title: '订单列表', icon: 'list', roles: ['admin', 'operator'] } }, { path: 'detail', name: 'OrderDetail', component: () => import('@/views/order/detail/index.vue'), meta: { title: '订单详情', icon: 'detail', roles: ['admin', 'operator'] } } ] } ]

这里要注意一个细节:component使用懒加载的箭头函数形式,同时路径必须对应实际的views目录结构。动态路由在注册时是根据文件名去解析组件的,一旦写错就是一整个页面白屏。

2.2 用户状态管理:pinia 中的权限数据

接下来定义用户 store,把用户信息、角色、权限码、动态路由全部集中放在这里。

// store/modules/user.js import { defineStore } from 'pinia' import { login, getUserInfo } from '@/api/user' import { constantRoutes, asyncRoutes } from '@/router/routes' function filterAsyncRoutes(routes, roles) { const res = [] routes.forEach(route => { const tmp = { ...route } if (hasPermission(roles, tmp)) { if (tmp.children) { tmp.children = filterAsyncRoutes(tmp.children, roles) } res.push(tmp) } }) return res } function hasPermission(roles, route) { if (route.meta && route.meta.roles) { return roles.some(role => route.meta.roles.includes(role)) } return true } export const useUserStore = defineStore('user', { state: () => ({ token: '', name: '', avatar: '', roles: [], buttons: [], asyncRoutes: [], loaded: false }), actions: { // 登录 async login(loginForm) { const { token } = await login(loginForm) this.token = token sessionStorage.setItem('token', token) }, // 获取用户信息 async fetchUserInfo() { const res = await getUserInfo(this.token) this.name = res.name this.avatar = res.avatar this.roles = res.roles this.buttons = res.buttons this.loaded = true }, // 生成动态路由 generateRoutes() { // 这里根据当前用户角色过滤出可访问路由 const accessedRoutes = filterAsyncRoutes(asyncRoutes, this.roles) this.asyncRoutes = accessedRoutes return accessedRoutes }, // 退出登录重置状态 resetState() { this.token = '' this.name = '' this.roles = [] this.buttons = [] this.asyncRoutes = [] this.loaded = false sessionStorage.removeItem('token') } } })

筛选逻辑里最容易被忽略的点是roles字段的匹配方式。很多人喜欢简单判断route.meta.roles.includes(user.role),但一个用户如果同时兼任多种角色,some和includes的写法差异就会直接导致权限判定错误。

2.3 路由守卫里的完整闭环

有了 store 和路由表,接下来是路由守卫的核心处理。这是整个方案里最容易出 bug 的地方,我直接贴出可运行的版本。

// router/index.js import { createRouter, createWebHistory } from 'vue-router' import { constantRoutes } from './routes' import { useUserStore } from '@/store/modules/user' const router = createRouter({ history: createWebHistory(), routes: constantRoutes }) // 免登录白名单 const whiteList = ['/login'] router.beforeEach(async (to, from, next) => { const userStore = useUserStore() const hasToken = sessionStorage.getItem('token') if (hasToken) { if (to.path === '/login') { next({ path: '/' }) } else { // 关键:防止刷新后再次加载路由 if (userStore.loaded) { next() } else { try { // 拉取用户信息 await userStore.fetchUserInfo() // 生成动态路由 const accessRoutes = userStore.generateRoutes() accessRoutes.forEach(route => router.addRoute(route)) // 以防万一,动态添加404兜底 router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404', meta: { hidden: true } }) // 重新进入一次当前路由,保证动态路由渲染完毕 next({ ...to, replace: true }) } catch (error) { // 拉取用户信息失败,说明 token 失效,强制重新登录 userStore.resetState() next(`/login?redirect=${to.path}`) } } } } else { if (whiteList.includes(to.path)) { next() } else { next(`/login?redirect=${to.path}`) } } })

这个守卫里最重要的魔法其实在这两行:

accessRoutes.forEach(route => router.addRoute(route)) next({ ...to, replace: true })

动态路由加载后当前跳转目标其实还没注册,直接next()会匹配到 404。所以必须重新触发一次导航,让新注册的路由生效。这是我第一次实现动态路由时踩过最大的坑,回想起来都是泪。

2.4 菜单组件:递归渲染与自定义指令

有了路由表,菜单组件就简单了,但要注意它必须支持无限层级递归,并且要过滤掉meta.hidden的隐藏路由。

<!-- layout/components/SidebarItem.vue --> <template> <template v-if="!item.meta || !item.meta.hidden"> <el-sub-menu v-if="item.children && item.children.length" :index="item.path"> <template #title> <el-icon v-if="item.meta && item.meta.icon"> <component :is="item.meta.icon" /> </el-icon> <span>{{ item.meta && item.meta.title }}</span> </template> <SidebarItem v-for="child in item.children" :key="child.path" :item="child" :base-path="item.path" /> </el-sub-menu> <el-menu-item v-else :index="resolvePath(item.path)"> <el-icon v-if="item.meta && item.meta.icon"> <component :is="item.meta.icon" /> </el-icon> <template #title>{{ item.meta && item.meta.title }}</template> </el-menu-item> </template> </template>

菜单的核心依据不是单独维护的菜单数据,而是上面的动态路由表。路由里有多少可访问的节点,菜单就有多少项,天然保证一致性,不需要再费心同步。

3. 按钮权限的实现与细节

路由和菜单权限解决了页面可见性问题,但用户进入页面后能干什么,这就是按钮权限的工作范围。

3.1 按钮权限的两种主流实现

按钮权限一般有两条路:一是用 Vue 的v-if指令;二是封装自定义指令v-permission。

v-if方案直观,但它的致命缺点是逻辑散落在每个组件里,权限一变就要改代码。比如下面这样:

<template> <el-button v-if="userStore.buttons.includes('user:add')">新增</el-button> <el-button v-if="userStore.buttons.includes('user:delete')">删除</el-button> </template>

按钮一多,模板里全是判断条件,后边看到这种代码就头大。所以我明确选择指令方案:把权限校验集中放到一个指令里,模板只写v-permission,既清爽又统一。

3.2 自定义权限指令的完整封装

在项目src/directives/permission.js里定义指令:

// directives/permission.js import { useUserStore } from '@/store/modules/user' export const permission = { mounted(el, binding) { const userStore = useUserStore() const { value } = binding // 权限码可能是字符串或数组 const requiredPermissions = Array.isArray(value) ? value : [value] const hasPermission = userStore.buttons.some(btn => requiredPermissions.includes(btn) ) if (!hasPermission) { el.parentNode && el.parentNode.removeChild(el) } } }

然后在入口文件注册指令:

// main.js import { permission } from '@/directives/permission' app.directive('permission', permission)

这样在组件里使用的时候只需写上权限码:

<template> <el-button v-permission="'user:add'" type="primary">新增用户</el-button> <el-button v-permission="['user:edit', 'user:delete']">批量操作</el-button> </template>

我用下来的经验是:当某个操作需要多个权限码中的任意一个时,传数组非常方便。很多项目这里实现成“全部满足才显示”,反而导致真实业务需求没法覆盖。

3.3 权限码的约定与管理

按钮权限经常出问题,并不是指令写错了,而是权限码本身命名混乱。我列一下我在项目中落地时固定的规则:

  • 权限码格式统一为模块:操作,例如user:add、order:export。
  • 后端返回的按钮权限列表必须和前端定义的权限码完全一致,一个字符都不能差。
  • 所有权限码集中到一个常量文件里维护,避免页面直接写字符串。
// constants/permission.js export const PERMISSION = { USER_ADD: 'user:add', USER_EDIT: 'user:edit', USER_DELETE: 'user:delete', ORDER_EXPORT: 'order:export', ORDER_IMPORT: 'order:import' }

使用的时候引入常量,而不是写死字符串,这样将来权限码调整时全局搜索替换就行,也方便做代码审查。

3.4 函数式按钮权限(配合禁用态)

删除按钮可以用指令直接移除,但有些按钮不能直接消失,比如“提交”按钮,在无权限时需要置灰并给提示。这时候指令就不够用了,需要配一个函数判断。

// utils/permission.js import { useUserStore } from '@/store/modules/user' export function hasPermission(permissionCode) { const userStore = useUserStore() return userStore.buttons.includes(permissionCode) }

在模板中这样使用:

<el-button :disabled="!hasPermission('order:submit')" @click="handleSubmit"> 提交订单 </el-button>

指令方案和函数方案一起用,基本可以覆盖所有按钮级的权限场景。指令解决“能不能看到”,函数解决“能不能操作”,各司其职。

4. 常见问题与排查技巧实录

这个权限模块改过很多版,我把实际项目中采到的高频问题都整理出来了,每一个都是真金白银的教训。

4.1 刷新后白屏,路由守卫死循环

这是动态路由实现后最常见的首坑。

现象:登录后一切正常,按 F5 一刷新页面全白,控制台报错找不到路由,甚至有时浏览器一直转圈。

原因:刷新后整个 Vue 应用重新初始化,动态路由虽然被 persist 到 sessionStorage,但router实例里的路由注册信息已经清空。此时导航到/system/user,路由匹配不到任何记录,自然白屏。

解决:在路由守卫里加已加载标识,刷新后重新走一遍fetchUserInfo + generateRoutes + addRoute流程。核心就是我在上面守卫代码里写的userStore.loaded判断。

还有一个隐藏细节:如果你是用sessionStorage直接持久化了用户信息,千万不要用“从 storage 里恢复数据然后跳过重新拉取”的方式,因为权限可能已过期。刷新时宁可多请求一次接口,也不要因为图省事造成权限漏洞。

4.2 动态路由注册前就跳转导致404

现象:登录成功后跳转首页正常,但直接访问一个二级菜单 URL 就 404。

原因:注册动态路由的addRoute是同步代码,但它之后next()的时机可能早于路由匹配。

解决:路由守卫里必须用next({ ...to, replace: true })而不是直接next(),确保动态路由已生效后重新进入目标路由。

排查看这里:如果addRoute之后还是 404,大概率是路由路径写错了。检查动态路由的path是否以/开头,以及children中是不是漏了接父级路径。嵌套路由的路径子级不要写/开头,否则会变成绝对路径。

4.3 权限码明明有,按钮却显示不出来

现象:后台配置好了权限码,接口也返回了,但按钮就是不出现。

排查步骤:

第一步,先在fetchUserInfo请求后打日志,看res.buttons里到底是什么格式。经常有后端接口返回的是[{ name: 'user:add' }]这种对象数组,你includes直接匹配字符串自然匹配不上。

第二步,检查权限码是否有多余空格。很多接口数据经过 JSON 解析后,两端的空格看起来一样但实际不一致。建议前端在fetchUserInfo里做一层数据清洗。

this.buttons = res.buttons.map(item => item.trim())

第三步,确认指令绑定值是数组时逻辑是否正确。比如v-permission="['user:add', 'user:edit']",一定要想清楚用的是some还是every。

4.4 菜单闪烁:加载时先出全部菜单再消失

现象:登录后侧边栏菜单全部闪现一下,然后才刷新成当前用户该看到的菜单。

原因:布局组件渲染时使用了constantRoutes渲染菜单,动态路由还没生成;用户信息返回后动态菜单才顶上来。

解决:菜单渲染前加v-if="userStore.loaded"判断,未加载完成时压根不渲染侧边栏。

<template> <div v-if="userStore.loaded" class="sidebar"> <SidebarItem v-for="item in userStore.asyncRoutes" :key="item.path" :item="item" /> </div> </template>

4.5 404页被动态路由误拦截

现象:登录后访问任意未定义 URL,404 页面一闪而过,随后被重定向到首页。

原因:动态路由最后添加的通配匹配路由/:pathMatch(.*)*和router.addRoute的注入顺序有关。这个通配路由应该放在所有动态路由注册之后,一旦注册顺序不对,通配路由会先匹配到所有 URL。

解决:把通配路由放在generateRoutes的返回值里,跟随用户动态路由一起 add,保证它是最后一个注册的路由。

4.6 退出登录后路由没清理干净

现象:admin 登录 -> 退出 -> visitor 登录,visitor 竟然还能看到 admin 的页面。

原因:router.addRoute是全局注册,退出登录时如果不主动移除,路由一直存在。

解决:方式一,在退出登录时重置整个 router,换一个思路,点击退出时直接location.href = '/login'做整页强制刷新,一劳永逸。

如果不想整页刷新,可以在退出时获取所有已注册的动态路由名,然后手动移除:

// 在 resetState 里收集 const removeRoutes = [] accessRoutes.forEach(route => { removeRoutes.push(route.name) }) removeRoutes.forEach(name => { if (router.hasRoute(name)) { router.removeRoute(name) } })

不过实测下来最省心的还是整页刷新方案。后台系统对刷新开销并不敏感,安全性却提升了不少。

5. 权限模块的边界问题与设计思考

写到这儿,再把几个容易被忽略的边界点展开说一下。

5.1 后端接口鉴权是底线,前端只是体验优化

前端路由和按钮权限本质上是“体验层”控制,它不能让用户看到不该看的页面、点不该点的按钮。但这绝不意味着后端可以不做接口校验。我一直在团队里强调这句话:

页面能跳转、按钮能点击,不代表请求会成功。真正的地基是后端每个接口都要有权限校验,前端权限只是把体验做得更顺畅。

前端权限本质上是优化体验,真正的地基是后端判断,前端只是把交互收起来。设计权限系统时,如果后端还没做,赶紧回头推动后端把shiro、spring security之类的鉴权框架配上。

5.2 动态路由和菜单的先后顺序

我梳理一下整个流程,确保你离手写代码时不会乱:

  1. 用户在登录页输入账号密码,拿到 token。
  2. 前端把 token 存到 pinia 和 sessionStorage。
  3. 进入路由守卫,判断 token 存在但用户信息未拉取。
  4. 调用fetchUserInfo()拿到用户基本信息。
  5. 调用generateRoutes(),根据角色筛选出可访问路由表。
  6. addRoute动态注册路由,同时next({ ...to, replace: true })重新进入页面。
  7. 菜单组件从路由表派生渲染出侧边栏。
  8. 按钮级别的操作通过指令或函数判断显隐。

这套流程就是权限模块的主干道。所有的坑、所以的修改,都是在这个主干道某个环节出了问题。

5.3 如何处理角色和权限码同时存在权限问题

有一部分系统既有角色、也有权限码,菜单按角色过滤,按钮按权限码判断,这很常见。但要注意防止角色和权限码互相冲突。

举例来说,一个用户挂了“运营”角色,但按钮权限码里没有给他order:export,这就导致他看得到订单导出按钮,点了之后被后端拒绝。这种问题表面上难以排查,其实根源在于后端返回的数据口径不统一。

我的建议是:菜单和按钮都统一基于权限码做判断,因为后端权限体系最细的粒度永远是权限码,角色只是一种权限码的组合。前端传过来的角色其实是冗余信息,真正要用的只有一个buttons列表。基于这套统一的权限码机制,整个权限体系会更自洽。

6. 一些可以做得更细的收尾小技巧

最后再补充三个我实际项目中用过、颇有效果的小细节。

第一点是页面标题联动。动态路由里每个meta.title都可以在afterEach守卫中设置成浏览器标签页的标题。用户权限不同、看到的页面不同,标题也随之变化,效果整齐。

第二个是操作日志记录。当按钮权限被触发时,比如用户点击了某个受限按钮,可以在指令里统一上报全局日志,不管是“越权点击”还是正常点击都记录下来,方便后续审计。

第三个是可以做的更核心的一点,就是动态路由的守卫顺序。一定要把动态路由生成放在路由守卫里做,而不是放在登录页登录成功后做。这样刷新时不管用户从哪个 URL 直接进入,均有兜底机会,不会因为登录成功时没生成路由而直接漏掉。

7. 我的个人体会

写权限这套东西最容易犯的错误,是一开始就想把菜单、按钮、路由全都做到位。其实先跑通路由和菜单权限,有一点可用度后,再去补按钮权限,这种递进式开发要稳妥得多。

被 404 白屏折磨过几次之后,我现在的习惯是在路由守卫里把加载状态打满日志,每一步都确认 token、用户信息、动态路由、进页面这四步各自都执行到位。权限系统就是典型的“差一个步骤就全盘崩掉”的功能,调试的时候耐心区分“数据问题”和“时序问题”,才能迅速定位到根因。

最后再提醒一句:前端权限再复杂,也只是权限链路里的一环。架构设计时,一定要同步让后端把接口鉴权做扎实。别再让“前端隐藏了按钮就等于权限安全”这种认知在团队里继续蔓延了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询