Vue+Element-UI动态侧边栏导航:路由驱动与权限过滤实战
发布时间:2026/9/30 8:56:53来源:尧图网络
动态侧边栏导航这个需求基本是中后台项目里跑不掉的标配。不管前端技术栈是 Vue 还是 React只要页面一多、角色一复杂菜单就不可能老老实实写死在一个组件里。我近几年经手的后台项目十个里有八个会在中期把侧边栏改成动态渲染原因无非这几个维护成本低、权限好控制、新增页面不用人肉去改菜单文件。这篇就用 Vue Element-UI 这套组合把动态侧边栏从设计到落地完整拆一遍按“路由表定义规则 → 菜单数据生成 → 递归组件渲染 → 角色权限过滤 → 常见坑位排查”这条线走看完你可以直接在自己的后台项目里抄作业。如果你现在用的是 Vue 3 Element Plus思路完全一样只有组件 API 名称上的差别照着对应关系修改即可。注意文中的代码示例基于 Vue 2.7 Element-UI 2.x vue-router 3.x组合式 API 写法。还在用 Options API 的项目逻辑部分也能对照实现不冲突。1. 先搞清楚动态侧边栏到底“动”在哪里1.1 从一次改菜单改到怀疑人生的经历说起大概两年前我接手过一个管理后台项目。页面三四十个侧边栏菜单硬编码在一个 Sidebar.vue 组件里一长串 el-submenu 包着若干 el-menu-item菜单文字、图标、跳转路径全部写死。当时看着没啥问题直到产品提了个需求根据用户角色显示不同菜单。于是我开始写 v-if一个角色一个角色地判断写到第三个角色的时候人已经麻了。后来更离谱页面要调整层级把一个二级页面挪到另一个模块底下就为了菜单顺序正确我在模板里切了一整段代码重新整理父子关系又因为某几个页面入口藏得深菜单和数据权限对不上测试提了 Bug 回来。那一次之后我就决定侧边栏必须改成动态的菜单数据不能再用手写模板维护必须从统一的数据源自动生成。这就是动态侧边栏解决的第一层问题菜单结构和页面路由不再分离增删改菜单不再是去翻模板而是改配置。只要约定好路由表的 meta 信息再写一次通用渲染组件后续模块只要往路由数组里加页面侧边栏会自动长出来不用额外维护第二份菜单文件。1.2 动态菜单本质上是“数据驱动”所谓的动态核心是把“展示哪些菜单”从组件模板的写死状态解放成由数据决定。数据源可以是后端接口返回的菜单树也可以是前端路由表本身。这样带来的好处非常明显。第一角色权限过滤容易做。菜单数据里带有 roles 等权限标识用户登录后把角色传进去进行一次过滤再交给侧边栏渲染。页面权限路由也可以和菜单过滤共用一个数据源做到“看不到的菜单进不去”。这一点对中后台项目尤其重要总不能把没有权限的入口明晃晃挂在侧边栏点进去才报错。第二菜单层级可以收放自如。真实项目里经常出现三级菜单、四级菜单嵌套太深时侧边栏本身就不好看。动态方案里只要写一个递归组件不管多少层它都能渲染不会为了加一个层级去复制一层模板。配合 Element-UI 的 el-submenu 和 el-menu-item折叠、展开、选中高亮这些交互都交给组件本身处理。第三菜单和路由天然一致避免“菜单写得和路由对不上”这种诡异问题。只要菜单生成逻辑是基于路由表推导的路由里有这个页面菜单才可能出现路由里删掉了菜单也会自动消失。这个“天然一致”能减少很多低级 Bug尤其是团队协作时不同人的代码合并后路由冲突、菜单覆盖那种事。1.3 先想清楚要动态还是“伪动态”有朋友看完会说我用 v-for 把一个数组循环渲染成菜单这算不算动态。算但这只是半动态。如果你的菜单只有一个固定数组写在 js 文件里角色判断只负责显隐那它解决不了后端动态下发菜单的场景。我这边通常把动态分成两档档位数据来源适用场景维护方式半动态前端路由/静态菜单数组页面固定、角色有限的小后台改前端配置全动态后端接口返回菜单树多角色、多租户、需要运营配置改后台管理界面文章先讲“前端路由驱动”这一档因为它最常用、最容易落地全动态方案在第四节展开说。两档的核心都一样菜单是数据算出来的不是写死模板。2. 路由表就是菜单表先把数据模型定好2.1 给路由表定制 meta 字段动态侧边栏最顺手的方式是让路由表兼任菜单配置表。vue-router 本身给每个路由对象预留了 meta 字段我们只需要约定好里面的字段含义后续所有逻辑都能统一读取。我习惯在 meta 里放这些字段字段类型必填说明titlestring是菜单名称也用于面包屑和浏览器标题iconstring否Element-UI 图标类名比如 el-icon-settinghiddenboolean否为 true 时不在侧边栏显示如详情页rolesarray否允许访问的角色列表如 [‘admin’, ‘editor’]affixboolean否是否固定出现在标签页栏不随菜单隐藏比如一个带二级菜单的模块路由是这样定义的const routes [ { path: /system, component: Layout, meta: { title: 系统管理, icon: el-icon-setting }, children: [ { path: user, name: SystemUser, component: () import(/views/system/user.vue), meta: { title: 用户管理 } }, { path: role, name: SystemRole, component: () import(/views/system/role.vue), meta: { title: 角色管理, roles: [admin] } } ] } ]这里有个细节顶级路由的 component 通常是 Layout 布局组件children 里的 path 不写开头的斜杠是相对子路径。菜单生成的时候需要用父级 path 拼出完整路径比如/system/user。如果自己写递归生成函数时没有做路径拼接菜单点击会跳到一个不存在的地址。2.2 统一的菜单数据结构不管数据来自前端路由表还是后端接口最后给侧边栏组件消费的菜单数据我建议统一成下面这种结构{ path: /system, title: 系统管理, icon: el-icon-setting, children: [ { path: /system/user, title: 用户管理, icon: , children: [] } ] }有几个要注意的点。第一children 为空数组不要为 null递归组件里判断children children.length时更安全。第二path 一定是完整路径不是相对路径避免组件里反复做拼接。第三title 和 icon 的兜底值都写空字符串渲染的时候判断if (menu.title)才不会报 undefined。这个结构也是后端接口返回菜单树时最常见的格式所以前端路由驱动和后端下发两种模式在渲染层可以完全复用同一套组件很省事。2.3 为什么不用异步组件直接把菜单写死在后端有人会问既然后端能返回菜单树那直接把 el-menu-item 的数据拼接好返回给前端不就行了。可以但我见过不少项目死在这一步——后端返回的往往是一棵“菜单配置树”里面塞满了数据库字段 id、parentId、sort、status前端拿过来还得自己映射成框架需要的字段。如果后端对前端菜单数据结构完全不关心每次前端升级组件、改字段名后端接口就要跟着改联调成本很高。所以更合理的分工是后端只下发“菜单权限数据”比如角色拥有的路由 path 集合前端根据这个集合过滤自己的路由表再生成侧边栏。这样后端不需要知道 Element-UI不需要知道 el-menu前端也不用被后端字段绑架。3. 动手实现菜单数据是怎么算出来的3.1 写一个路由表转菜单的函数路由表转菜单本质上是一次递归遍历。对每条路由读取 meta 信息处理 hidden、roles拼接完整路径把符合条件的结果追加到数组里。我贴一段可以直接用的实现function resolvePath(basePath, routePath) { if (!routePath) return basePath if (routePath.startsWith(/)) return routePath return ${basePath}/${routePath}.replace(/\//g, /) } export function generateMenus(routes, basePath ) { const menus [] routes.forEach((route) { if (route.meta route.meta.hidden) return const item { path: resolvePath(basePath, route.path), title: (route.meta route.meta.title) || , icon: (route.meta route.meta.icon) || , children: [] } if (route.children route.children.length) { item.children generateMenus(route.children, item.path) } menus.push(item) }) return menus }这个函数有两个地方容易踩坑。一个是我们刚说的路径拼接basePath 是上一层的完整路径route.path 是相对路径所以把两者用斜杠拼起来再正则处理掉双斜杠。另一个是位置先给 item.children 赋值再把 item 放进 menus 数组顺序不能反否则递归回来后 children 已经变了但由于引用关系其实也能生效不过代码可读性差很多。3.2 调用时机登录后还是路由初始化时前端路由驱动模式下菜单生成函数通常在两个时机调用一是在路由初始化后直接调二是用户登录后拿到角色再调。如果项目不需要按钮级权限角色信息可以提前在静态路由里写好那我通常直接在入口文件里调用const menuData generateMenus(routes) // 交给 store 或者直接 prop 传入 Sidebar 组件如果角色会影响菜单树那就得等登录接口返回角色后再调。更严谨的做法是先初始化只包含登录页、404 页等基础路由登录成功后再用 addRoute 动态注册权限路由然后用同样的 routes 数据去 generateMenus。这个流程在后面讲“动态路由”时一起说。3.3 菜单数据放哪里Store 还是模块内变量菜单数据不需要全局响应式。它只会在登录、退出、刷新页面时重新生成平时不变因此直接用模块级变量或者 Vuex store 都没有本质区别。我自己的习惯是放 Pinia/Vuex原因不是响应式而是方便权限校验组件、面包屑组件、菜单组件共用同一份数据不用各个文件各自 import 后各自处理。如果项目里暂时没有引入状态管理你完全可以把这个数据放在src/menu.js里export 一个生成函数Sidebar 组件在 created 时调用。缺点就是当用户退出切换角色后侧边栏数据不会自动更新需要手动重新调用并 clear 旧值这是真实项目里经常漏掉的场景。4. 渲染侧边栏递归组件 el-menu4.1 SidebarItem 递归组件怎么封装Element-UI 里el-menu 是外层容器el-submenu 和 el-menu-item 分别对应有子菜单、叶子菜单。因为菜单可能嵌套最常见也是最优雅的写法是写一个递归组件干脆叫 SidebarItem自己调用自己。模板大致如下template el-submenu v-ifmenu.children menu.children.length :indexmenu.path template slottitle i v-ifmenu.icon :classmenu.icon/i span{{ menu.title }}/span /template sidebar-item v-forchild in menu.children :keychild.path :menuchild / /el-submenu el-menu-item v-else :indexmenu.path i v-ifmenu.icon :classmenu.icon/i span slottitle{{ menu.title }}/span /el-menu-item /template script export default { name: SidebarItem, props: { menu: { type: Object, required: true } } } /script这里有个重要细节递归组件一定要有 name否则组件自己引用自己时无法工作。在 el-submenu 里再套 sidebar-item组件就能递归渲染不管菜单有几层都能自动展开。4.2 父组件里挂 el-menu 和 router 模式侧边栏的入口组件 Sidebar.vue 会引入递归组件并用 el-menu 包一层template el-menu :default-activeactiveMenu :collapseisCollapse router unique-opened background-color#304156 text-color#bfcbd9 active-text-color#409EFF sidebar-item v-formenu in menuData :keymenu.path :menumenu / /el-menu /template script import SidebarItem from ./SidebarItem.vue export default { name: Sidebar, components: { SidebarItem }, computed: { activeMenu() { const route this.$route return route.meta route.meta.activeMenu ? route.meta.activeMenu : route.path } } } /scriptel-menu 的 router 属性是核心它让菜单项点击后自动调用$router.push跳转到 index 对应的路径。如果不开这个属性点击菜单只会切换 el-menu-item 的选中态页面不会跳转——这是新手最容易踩的坑。unique-opened 属性控制“每次只展开一个一级菜单”体验上更干净但有些项目希望保留多个展开这个看产品需求。如果菜单层级很深不推荐 unique-opened用户从三级菜单切到另一个模块时中间层菜单会被强制收起视觉上会闪一下。4.3 收起侧边栏时图标和文字不能丢中后台侧边栏一般支持折叠Element-UI 里 el-menu 的 collapse 属性一开菜单就收窄成只剩图标。这里有个需要提前处理的点一级菜单如果只有文字没有图标折叠后会空荡荡的特别丑。所以给菜单配置 icon 字段不是可选建议而是必须。另外element-ui 的 el-menu 折叠后如果子菜单里有文字会出现“竖排文字”的奇怪效果。常规做法是折叠模式下只显示菜单 title不显示 icon 文字块。我的 SidebarItem 里简单判断一下template v-if!isCollapse span{{ menu.title }}/span /templateisCollapse 从父组件通过 provide/inject 或者 props 传下来。折叠状态下el-menu 的 tooltip 会自己弹出来提示菜单名这个体验是 Element-UI 内置的不用额外处理。需要注意 tooltip 里期望的是纯文本所以你要是放了 icon 弹出来的也是 icon观感比较奇怪这也是为什么折叠时建议只渲染 title。5. 权限过滤不同角色看到不同菜单5.1 前端还是后端决定菜单动态菜单的权限方案在真实项目里分两种流派。一种是后端下发完整菜单树前端直接渲染另一种是前端维护路由表根据登录用户的角色去过滤。两者不是互斥的我参与过的较复杂项目是两者结合基础路由前端维护业务模块菜单由后端返回。如果你问我的建议纯内部管理后台、角色不超过五个前端过滤就够用一旦牵扯到多租户、运营后台、需要超管在界面上给不同商户分配菜单那必须后端下发。前端过滤最大的优点是快、简单不依赖网络缺点是一旦打包所有页面代码都已经在包里懂行的人改改权限就能看到隐藏页面。真要安全必须靠后端接口校验接口权限前端过滤只解决“看不看得见”的问题不解决“进不进得去”的问题。5.2 前端路由过滤的具体实现在前面 generateMenus 之前先做一次路由层面的过滤。我通常写一个 filterRoutesByRolesexport function filterRoutesByRoles(routes, roles) { return routes.filter((route) { if (route.meta route.meta.roles) { if (!roles.some((role) route.meta.roles.includes(role))) { return false } } if (route.children route.children.length) { route.children filterRoutesByRoles(route.children, roles) } return true }) }过滤后的 routes 再传入 generateMenus得到的就是当前角色可见的菜单。要注意这里直接修改了原路由对象的 children可能影响全局路由配置。如果不想改原对象需要先深拷贝一份。我在项目中用的是 lodash 的 cloneDeep简单粗暴过滤完再交给路由实例。真实场景里还有个容易漏掉的点role 为超管或者 admin 时常见做法是直接放行所有路由不需要挨个匹配 roles。可以加一个判断if (roles roles.includes(admin)) { return routes }当然 admin 是不是硬编码在业务里看你们权限模型怎么定。有些项目超管角色是存在后端配置里的前端不知道那就不能写死。5.3 登录状态清理防止串角色菜单和权限最隐蔽的坑不是过滤写错而是“角色切换后旧菜单没清干净”。用户用 admin 登录侧边栏一堆菜单退出后同一个浏览器里换普通用户登录发现菜单还在甚至点进去页面直接白屏或者 404。这个问题的根源在于菜单数据、动态路由都是在登录时注册的退出时没有重置。我习惯在退出登录的方法里统一清理function resetRouter() { // 遍历动态注册的路由 name逐个 removeRoute // 再恢复成只保留基础路由 }如果用的是 vue-router 3.x动态 addRoute 之后没有直接的 removeRoute 方法常见做法是刷新页面让整个应用重新初始化路由自然恢复。这个方案不优雅但很稳很多后台框架就是这么处理的。Vue Router 4.x 提供了 removeRoute可以精确移除代码会干净一些。6. 几个绕不开的坑和排查手册6.1 刷新页面后菜单展开状态丢失中后台项目刷新后期望是停留在当前路由侧边栏能定位并展开对应的一级菜单同时高亮当前菜单项。Element-UI 里el-menu 的 default-active 控制高亮项但展开状态靠的是 open 方法或者 default-openeds 属性。如果你没设置 default-openeds刷新后当前选中项所在的一级菜单可能是收着的项还高亮着却看不到路径很尴尬。解决方案有两种。第一种监听 activeMenu 变化手动调用 el-menu 实例的 open 方法把当前激活项的所有父级菜单 path 塞进去。第二种用 default-openeds 配合 computed根据当前路由逐级向上找父级 path生成一个数组。我一般用第二种因为它更声明式不会受生命周期影响。6.2 点击菜单没有跳转这个前面提过了el-menu 的 router 属性没开。但还有一种情况router 开了index 路径却不对点击之后地址栏变了内容区就是没反应绝大多数是路径拼接问题。检查一下菜单项的索引是不是完整路径。举个例子子路由 path 写成 user父级是 /system那菜单索引应该是 /system/user不能是 user。你可以在浏览器里打开 Vue DevTools检查生成的 menuData 里 path 最终是什么。6.3 动态 addRoute 后菜单渲染却不对全动态方案里登录后要用后端返回的菜单数据做两件事addRoute 注册路由同时生成侧边栏菜单。如果顺序反了先渲染菜单再注册路由第一次登录后侧边栏可能空白刷新后才正常。正确做法是把“动态注册路由”和“生成菜单”放在一个同步流程里都完成后设置 loading 为 false再展示侧边栏。另外动态注册的路由组件路径要能正确匹配到真实文件不要为了灵活让它全部指向同一个空白组件否则菜单有了访问却全是白屏。6.4 表格场景页面能打开菜单却不高亮有一种情况是列表页和详情页其实属于同一个菜单模块但详情页路径不在菜单数据里。比如用户管理菜单 path 是/system/user点进详情页/system/user/detail/123侧边栏高亮就丢了。解决办法就是借助 meta.activeMenu。在详情页的路由 meta 里写meta: { title: 用户详情, hidden: true, activeMenu: /system/user }然后在 Sidebar 组件的 activeMenu computed 里优先读 meta.activeMenu再回退到 route.path。这个做法可以让详情页侧边栏依然高亮在“用户管理”菜单上体验上更符合直觉。6.5 多层嵌套导致 el-submenu 出现多余边框Element-UI 的菜单在多层嵌套下偶尔会有内联样式和边框的视觉问题尤其是你自定义了背景色和文字色以后。出现这种问题第一反应不是改组件源码而是检查子菜单样式作用域。很多后台项目为了全局布景色给 el-menu 强行覆写了样式嵌套层级加深后子菜单继承了不该继承的背景色。我的习惯是把菜单背景相关的样式固定写在一个全局样式文件里不以 scoped 形式放在 Sidebar 组件里因为 scoped 会生成 data 属性选择器对子组件内部元素覆盖不到最终就得写 ::v-deep越写越乱。菜单这种全局性的组件直接在全局样式统一维护是最省心的。6.6 动态菜单和面包屑如何保持同步如果项目里还有面包屑它也可以基于同一份路由数据生成。面包屑的逻辑不是渲染菜单树而是根据当前路由的 matched 数组找到每一层对应的 title。在 meta.title 字段齐全的前提下面包屑组件可以写成const breadcrumbs this.$route.matched.filter( (item) item.meta item.meta.title )同时也要注意如果某个中间层路由没有 meta.title面包屑会断层。我的建议是给 Layout 下的父级路由都配上 title这样既有菜单名字面包屑也能正常显示。这里和菜单数据共用 meta 字段就是一个典型的“一套数据多处消费”维护成本比各写各的低很多。7. 动态侧边栏的进一步扩展7.1 多级菜单组件性能和递归深度递归组件在菜单层级非常多的时候比如四级、五级渲染性能 V8 下完全扛得住真正的瓶颈在于 el-submenu 展开/收起的过渡动画。Element-UI 2.x 在这个场景下偶尔有动画闪烁我会直接把 popper 相关属性关掉或者把 transition 的 duration 缩短视觉上更利落。7.2 菜单搜索菜单超过一屏后找个菜单得翻半天。我后来在侧边栏顶部加了一个简单的菜单过滤输入框输入关键词实时过滤菜单树把匹配到的叶子节点展示成平铺列表点击直接跳转。这个功能看似额外其实对二三十个菜单页面的后台项目非常实用能显著减少用户寻找入口的时间。菜单过滤也不用重写组件给菜单树加一个 filter 函数就行。我用的是类似 generateMenus 的递归思路只是多一层 title 包含匹配命中就保留叶子没命中就跳过。如果你也想做留意中文大小写问题统一 toLowerCase 再比较。7.3 与标签页联动中后台的另一个标配是标签页。标签页的数据也可以从路由 meta 里来在路由切换时 push 当前路由信息配合 affix 字段固定首页标签。这些功能可以顺手一起做但不要为了炫技一次塞太多先把菜单动态化做稳定后续再逐步加。8. 最后我踩过坑之后的一些体会动态侧边栏看着是个小组件其实牵扯到路由、权限、状态管理、布局联动好几块内容。我做过最顺的一个项目是把菜单、面包屑、标签页全部纳入同一套路由 meta 数据体系改一个页面配置三个模块自动跟着变。反过来我踩过最深的坑是权限过滤的时候只顾着菜单没管动态路由导致菜单隐藏了但页面还能被直接访问后来补了路由守卫才堵上。如果你正准备改造自己的侧边栏我建议不要一上来就上后端下发菜单。先把前端路由驱动做通让菜单和路由一致再考虑权限过滤最后才是后端动态下发。一步一步来每次改动边界清楚出了问题也好定位。真正要花心思的不是侧边栏本身而是怎么把“路由配置”这个单一数据源用充分。title、icon、hidden、roles、activeMenu这些 meta 字段约定清楚动态侧边栏只是这套体系里最表面的收益后面做标签页、面包屑、权限守卫都会顺手很多。个人建议动手前先在项目里把 meta 字段的规范写进 README哪怕只是几行字都能帮后来的同事少走弯路。
网站建设高端定制企业官网