☰
Vue+Element-UI动态侧边栏导航:路由驱动与权限过滤实战
2026/9/30 8:56:47 网站建设 项目流程

动态侧边栏导航这个需求,基本是中后台项目里跑不掉的标配。不管前端技术栈是 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-setting
hiddenboolean否为 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-menu

4.1 SidebarItem 递归组件怎么封装

Element-UI 里,el-menu 是外层容器,el-submenu 和 el-menu-item 分别对应有子菜单、叶子菜单。因为菜单可能嵌套,最常见也是最优雅的写法是写一个递归组件,干脆叫 SidebarItem,自己调用自己。

模板大致如下:

<template> <el-submenu v-if="menu.children && menu.children.length" :index="menu.path" > <template slot="title"> <i v-if="menu.icon" :class="menu.icon"></i> <span>{{ menu.title }}</span> </template> <sidebar-item v-for="child in menu.children" :key="child.path" :menu="child" /> </el-submenu> <el-menu-item v-else :index="menu.path" > <i v-if="menu.icon" :class="menu.icon"></i> <span slot="title">{{ 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-active="activeMenu" :collapse="isCollapse" router unique-opened background-color="#304156" text-color="#bfcbd9" active-text-color="#409EFF" > <sidebar-item v-for="menu in menuData" :key="menu.path" :menu="menu" /> </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 } } } </script>

el-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> </template>

isCollapse 从父组件通过 provide/inject 或者 props 传下来。折叠状态下,el-menu 的 tooltip 会自己弹出来提示菜单名,这个体验是 Element-UI 内置的,不用额外处理。需要注意 tooltip 里期望的是纯文本,所以你要是放了 icon 弹出来的也是 icon,观感比较奇怪,这也是为什么折叠时建议只渲染 title。

5. 权限过滤:不同角色看到不同菜单

5.1 前端还是后端决定菜单

动态菜单的权限方案,在真实项目里分两种流派。一种是后端下发完整菜单树,前端直接渲染;另一种是前端维护路由表,根据登录用户的角色去过滤。两者不是互斥的,我参与过的较复杂项目是两者结合:基础路由前端维护,业务模块菜单由后端返回。

如果你问我的建议,纯内部管理后台、角色不超过五个,前端过滤就够用;一旦牵扯到多租户、运营后台、需要超管在界面上给不同商户分配菜单,那必须后端下发。前端过滤最大的优点是快、简单,不依赖网络;缺点是一旦打包,所有页面代码都已经在包里,懂行的人改改权限就能看到隐藏页面。真要安全,必须靠后端接口校验接口权限,前端过滤只解决“看不看得见”的问题,不解决“进不进得去”的问题。

5.2 前端路由过滤的具体实现

在前面 generateMenus 之前,先做一次路由层面的过滤。我通常写一个 filterRoutesByRoles:

export 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,哪怕只是几行字,都能帮后来的同事少走弯路。

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

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

立即咨询