news 2026/10/19 1:22:01

Vue权限控制实战:路由、菜单与按钮级三层方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue权限控制实战:路由、菜单与按钮级三层方案

简介:这份资源面向Vue前端开发者与后台管理系统搭建者,聚焦权限控制这一核心场景,讲解如何让不同角色用户仅访问被授权的功能。内容围绕两种限制方式展开:一是路由不可见,借助Vue Router的meta字段存储权限列表,并通过全局前置守卫beforeEach完成校验与重定向;二是元素不可见,通过自定义指令v-acl在元素插入时判断权限并隐藏无权按钮,避免无效点击引发接口报错。资源包为1个PDF文件,大小约55KB,内容紧凑,适合作为权限控制入门与速查参考。目前已有778人学习下载,读者可从中获得可直接套用的代码示例、路由与指令两套方案的实现思路,以及侧边栏导航过滤等扩展方向,便于在真实项目中快速落地并进一步定制更复杂的权限管理逻辑。

1. 从路由表到按钮级:一套能直接落地的 Vue 权限控制方案

很多团队做后台系统时,权限控制一开始都是“先能跑就行”,结果越往后越乱:菜单写死在模板里,按钮靠v-if硬编码角色字符串,接口权限又另写一套判断。等到要加一个“运营只读”角色时,前端得改十几个文件,测试还得把每个页面点一遍。这份 Vue 权限控制资源解决的正是这个痛点——它把权限拆成路由级、菜单级、按钮级三层,用统一的数据源驱动,新增角色只改配置不改组件。适合正在做中后台管理系统、需要区分管理员/运营/普通用户等角色的前端开发者,也适合想搞明白动态路由和指令鉴权到底怎么配合的人。下面按“先跑起来、再拆原理、最后避坑”的顺序讲,每一步都能直接抄。

2. 环境搭建与最小可运行骨架:先把登录态和路由守卫串起来

权限控制不是孤立功能,它依赖登录态、用户信息、路由表三者的配合。这一章先把最小骨架搭出来,让“未登录跳登录页、已登录按角色放行”这条链路先跑通,后面再往里面填菜单和按钮。

2.1 项目初始化与依赖选择

用 Vue CLI 或 Vite 建一个 Vue 3 项目都行,核心依赖只有两个:vue-router负责路由,pinia负责存用户角色和权限码。不引入额外权限库,是因为这类库往往把逻辑包成黑匣子,出问题时排查成本高,而权限控制本身逻辑并不复杂,自己写反而可控。

# 用 Vite 创建 Vue 3 项目,模板选 Vue npm create vite@latest vue-permission-demo -- --template vue cd vue-permission-demo npm install npm install vue-router@4 pinia

装完后目录结构建议这样组织,后面所有改动都围绕这几个文件:

src/ router/ index.js # 路由实例与全局守卫 asyncRoutes.js # 需要按权限动态挂载的路由 store/ user.js # 用户角色、权限码、登录动作 permission.js # 路由守卫逻辑(也可直接写在 router/index.js) views/ Login.vue Dashboard.vue Admin.vue

asyncRoutes.js和constantRoutes要分开:登录页、404 页属于所有人都能访问的常量路由,直接注册;管理页、财务页属于动态路由,登录后根据角色决定挂不挂。这个拆分是后面动态addRoute的前提,混在一起会导致刷新后路由重复添加。

2.2 登录态存储与路由守卫

用户登录成功后,后端一般返回角色标识(如admin、editor)和权限码数组(如['user:add', 'user:delete'])。前端把这两样存进 Pinia,同时把 token 存 localStorage,防止刷新丢失。

// store/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', roles: [], // 角色标识,如 ['admin'] permissions: [] // 按钮级权限码,如 ['user:add'] }), actions: { // 登录后写入用户信息 setUserInfo({ token, roles, permissions }) { this.token = token this.roles = roles this.permissions = permissions localStorage.setItem('token', token) }, // 退出时清空,避免残留 resetUser() { this.token = '' this.roles = [] this.permissions = [] localStorage.removeItem('token') } } })

路由守卫写在permission.js里,用router.beforeEach拦截。逻辑分三步:有 token 且去登录页就放行到首页;有 token 但没拉过用户信息就先拉;没 token 且目标不是登录页就踢回登录页。

// permission.js import router from './router' import { useUserStore } from './store/user' const whiteList = ['/login'] // 免登录白名单 router.beforeEach(async (to, from, next) => { const userStore = useUserStore() if (userStore.token) { if (to.path === '/login') { next({ path: '/' }) } else { // 已有角色信息直接放行,否则先拉取 if (userStore.roles.length > 0) { next() } else { try { await userStore.fetchUserInfo() // 调接口拿角色和权限码 next({ ...to, replace: true }) // replace 避免历史记录多一条 } catch (e) { userStore.resetUser() next(`/login?redirect=${to.path}`) } } } } else { if (whiteList.includes(to.path)) { next() } else { next(`/login?redirect=${to.path}`) } } })

这里有个容易翻车的点:next({ ...to, replace: true })里的replace必须加,否则用户点浏览器后退会回到一个“正在加载”的中间态。另外fetchUserInfo要保证只调一次,可以在 store 里加个loaded标志位,避免多个路由并发触发时重复请求。

3. 动态路由与菜单渲染:让不同角色看到不同的导航

骨架跑通后,核心问题来了:管理员能看到“用户管理”,普通编辑看不到。这一章讲怎么用router.addRoute动态挂载,以及菜单怎么从同一份路由表里自动生成,避免菜单和路由两处维护。

3.1 路由元信息设计与动态挂载

每条动态路由的meta里放两个关键字段:roles表示哪些角色可访问,title和icon给菜单用。这样一份配置同时驱动路由和菜单,改一处两处都生效。

// router/asyncRoutes.js export const asyncRoutes = [ { path: '/admin', component: () => import('../views/Admin.vue'), meta: { title: '用户管理', icon: 'user', roles: ['admin'] } }, { path: '/editor', component: () => import('../views/Editor.vue'), meta: { title: '内容编辑', icon: 'edit', roles: ['admin', 'editor'] } } ]

登录后根据角色过滤出可访问的路由,再逐条addRoute。注意addRoute是增量添加,刷新页面后路由会重置,所以守卫里每次拉完用户信息都要重新挂载一遍。

// 在 store 或 permission.js 中 import { asyncRoutes } from './router/asyncRoutes' function filterRoutes(routes, roles) { return routes.filter(route => { if (route.meta && route.meta.roles) { // 角色有交集才保留 return roles.some(role => route.meta.roles.includes(role)) } return true }) } // 挂载动态路由 const accessibleRoutes = filterRoutes(asyncRoutes, userStore.roles) accessibleRoutes.forEach(route => router.addRoute(route))

filterRoutes只做了一层过滤,如果路由有children,需要递归处理。常见做法是写一个递归函数,对children也跑一遍同样的逻辑,否则嵌套路由的子页面会漏掉权限判断。

3.2 菜单组件与路由表联动

菜单不要手写<el-menu-item>,而是遍历router.options.routes或 store 里存的可访问路由,用v-for渲染。这样新增页面只要加路由配置,菜单自动出现。

<!-- components/SideMenu.vue --> <template> <el-menu :default-active="$route.path" router> <template v-for="route in menuRoutes" :key="route.path"> <el-menu-item :index="route.path"> <span>{{ route.meta.title }}</span> </el-menu-item> </template> </el-menu> </template> <script setup> import { computed } from 'vue' import { useRouter } from 'vue-router' const router = useRouter() // 只取有 meta.title 的路由作为菜单项 const menuRoutes = computed(() => router.options.routes.filter(r => r.meta && r.meta.title) ) </script>

router.options.routes在动态addRoute后不会自动更新,所以更稳妥的做法是把可访问路由存进 Pinia,菜单从 store 读。这个细节不注意,会出现“路由能访问但菜单不显示”的玄学问题。

提示:动态路由挂载后,如果当前正停留在某个动态路由上,刷新会先匹配到 404。解决办法是在守卫里挂载完路由后,用next({ ...to, replace: true })重新触发一次匹配。

4. 按钮级权限与自定义指令:把 v-if 从模板里赶出去

路由和菜单解决的是“能不能进这个页面”,按钮级解决的是“进了页面能不能点这个按钮”。这一章讲怎么用自定义指令v-permission统一处理,避免每个按钮都写一长串v-if。

4.1 自定义指令实现与权限码校验

思路很简单:指令绑定的值是一个权限码,指令在mounted时检查当前用户权限码数组里有没有这个值,没有就把元素从 DOM 里移除。

// directives/permission.js import { useUserStore } from '../store/user' export const permission = { mounted(el, binding) { const userStore = useUserStore() const need = binding.value // 如 'user:add' const has = userStore.permissions.includes(need) if (!has) { // 直接移除,比 display:none 更彻底 el.parentNode && el.parentNode.removeChild(el) } } }

在main.js里注册:

import { createApp } from 'vue' import App from './App.vue' import { permission } from './directives/permission' const app = createApp(App) app.directive('permission', permission) app.mount('#app')

模板里这样用:

<template> <button v-permission="'user:add'">新增用户</button> <button v-permission="'user:delete'">删除用户</button> </template>

binding.value传的是权限码字符串,如果某个按钮需要满足多个权限之一,可以传数组,指令里用some判断。注意指令只在mounted执行一次,如果用户权限在页面运行中动态变化(比如切换角色),需要配合updated钩子重新判断,或者直接刷新页面。

4.2 权限码与后端接口的对应关系

前端权限码不是随便定的,要和后端接口权限保持一致。常见做法是后端在用户信息接口里返回权限码列表,前端只做展示层控制,真正的安全边界仍在后端。前端移除按钮只是防误操作,不能替代接口鉴权。

层级控制点数据来源失效后果
路由级router.addRoute角色 roles页面无法访问
菜单级菜单渲染过滤可访问路由菜单不显示
按钮级v-permission权限码 permissions按钮被移除
接口级后端鉴权后端独立校验请求被拒绝

这张表建议贴在团队文档里,新人一看就知道每层该改哪里。前端做三层,后端做一层,四层对齐才不会出现“按钮藏了但接口还能调”的漏洞。

5. 避坑与排查:权限控制里最容易翻车的五个地方

权限控制代码量不大,但涉及路由、状态、渲染时序,踩坑点很集中。下面五条都是实际项目里反复出现的,按“现象 → 原因 → 解决”写,遇到时直接对号入座。

5.1 刷新后动态路由丢失,页面白屏或跳 404

现象:登录后能正常访问管理页,一按 F5 刷新就跳到 404 或空白。 原因:动态路由是运行时addRoute加的,刷新后路由实例重置,守卫还没重新挂载就先去匹配了。 解决:在守卫里拉完用户信息、挂载完动态路由后,用next({ ...to, replace: true })重新触发导航,让路由重新匹配一次。同时确保fetchUserInfo在 roles 为空时才调用,避免死循环。

5.2 菜单不显示但路由能访问

现象:手动输 URL 能进管理页,但左侧菜单里没有这一项。 原因:菜单从router.options.routes读,而addRoute不会更新这个数组。 解决:把可访问路由存进 Pinia,菜单从 store 读;或者在挂载路由后手动把accessibleRoutes推入菜单数据源。不要依赖router.options.routes做菜单渲染。

5.3 按钮指令不生效,按钮仍然可见

现象:加了v-permission,但没权限的按钮还是显示。 原因:指令在mounted时执行,如果此时用户权限码还没拉回来(异步),permissions是空数组,判断自然失败。 解决:确保进入页面前用户信息已加载完成,守卫里await fetchUserInfo()就是这个作用。如果页面内动态改权限,给指令加updated钩子重新判断,或改用计算属性配合v-if。

5.4 角色判断用字符串硬编码,新增角色要改多处

现象:模板里到处是v-if="role === 'admin'",加一个“运营”角色要改十几个文件。 原因:没有把角色和权限码抽象成配置,判断逻辑散落在组件里。 解决:统一用权限码,角色到权限码的映射由后端返回。前端只判断permissions.includes('xxx'),新增角色只改后端配置,前端零改动。

5.5 退出登录后路由未重置,换账号登录串权限

现象:A 账号退出,B 账号登录,B 看到了 A 的菜单。 原因:退出时只清了 token,没有移除已挂载的动态路由,也没有重置 store 里的 roles 和 permissions。 解决:退出时调用resetUser()清空状态,同时记录已添加的路由并在退出时用router.removeRoute移除,或者直接location.reload()强制刷新,用最简单的方式保证干净。

注意:router.removeRoute需要路由有name才能移除,动态路由配置里最好给每条路由加name,否则移除不掉。

6. 进阶技巧:用路由 meta 做页面级按钮权限的批量校验

前面按钮级权限是逐个按钮加指令,页面多了以后,每个按钮都要想权限码,容易漏。我后来习惯在路由meta里声明这个页面需要的权限码集合,进入页面时统一校验一次,缺权限的按钮直接不渲染,配合指令做双重保险。

具体做法是在asyncRoutes的meta里加permissions字段:

{ path: '/admin', component: () => import('../views/Admin.vue'), meta: { title: '用户管理', roles: ['admin'], permissions: ['user:add', 'user:delete', 'user:export'] } }

然后在页面组件里用computed生成一个can函数,模板里用v-if="can('user:add')"代替指令。这样权限码集中声明在路由配置里,页面里只引用,改权限时只动一处。

// composables/usePermission.js import { computed } from 'vue' import { useRoute } from 'vue-router' import { useUserStore } from '../store/user' export function usePermission() { const route = useRoute() const userStore = useUserStore() // 当前页面声明的权限码 const pagePermissions = computed(() => route.meta.permissions || []) // 判断某个权限码是否同时满足:页面声明 + 用户拥有 const can = (code) => { if (!pagePermissions.value.includes(code)) return false return userStore.permissions.includes(code) } return { can } }

页面里这样用:

<script setup> import { usePermission } from '../composables/usePermission' const { can } = usePermission() </script> <template> <button v-if="can('user:add')">新增用户</button> <button v-if="can('user:delete')">删除用户</button> </template>

这个方案的好处是权限码有两道校验:路由 meta 里没声明的,页面里写了也不生效;用户没拥有的,声明了也不显示。验证方法很简单,用不同角色账号登录,打开控制台看按钮是否按预期增减,再手动改一下 store 里的permissions数组,观察页面是否实时响应。如果用了v-if配合can,响应是实时的;如果用了指令,需要刷新。

从那以后我每次接权限需求,都强制先画一张“角色-权限码-页面”对照表,再动手写代码,避免写到一半发现角色和权限码对不上。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 1:21:53

UART、I2C、SPI三大串行协议区别与面试高频考点全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/19 1:21:11

.NET网上商城源码实战:从架构拆解到高并发秒杀扩展

简介&#xff1a;基于微软.NET框架的网上商城完整源码&#xff0c;是一份适合兼具ASP.NET Web开发基础的学习者与电商系统二次开发人员研读的工程实践。项目以VB.NET为后台语言&#xff0c;围绕商品展示、在线购物、订单处理等业务组织代码&#xff0c;并带有文章管理、折扣推荐…

作者头像 李华
网站建设 2026/10/19 1:18:28

Snap.Hutao 免费原神工具箱:5 步装完并登录

Snap.Hutao 免费原神工具箱&#xff1a;5 步装完并登录 【免费下载链接】Snap.Hutao 实用的开源多功能原神工具箱 &#x1f9f0; / Multifunctional Open-Source Genshin Impact Toolkit &#x1f9f0; 项目地址: https://gitcode.com/GitHub_Trending/sn/Snap.Hutao 这…

作者头像 李华
网站建设 2026/10/19 1:17:51

一句话查完 11GB 生物医学数据:Biomni 数据湖的完整使用清单

一句话查完 11GB 生物医学数据&#xff1a;Biomni 数据湖的完整使用清单 【免费下载链接】Biomni Biomni: a general-purpose biomedical AI agent 项目地址: https://gitcode.com/GitHub_Trending/bi/Biomni Biomni 是一个生物医学 AI 智能体&#xff0c;它内置了约 11…

作者头像 李华
网站建设 2026/10/19 1:17:43

Spring Boot打包部署实战:jar包、配置文件与systemd托管指南

这周有同事把一个 Spring 项目打包部署到生产环境&#xff0c;结果从早上十点折腾到下午三点&#xff0c;光是启动不起来就排查了两轮。我们坐在一块儿复盘的时候&#xff0c;我突然意识到一个问题&#xff1a;Spring 项目打包部署这件事看着简单——mvn package一下&#xff0…

作者头像 李华
网站建设 2026/10/19 1:13:51

游戏行业图谱|买断制、免费加内购、订阅制:主流商业模式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华