Vue+ElementUI后台双菜单联动:路由驱动与权限控制实践
发布时间:2026/10/2 11:21:34来源:尧图网络
做了几年后台管理系统菜单栏这块一直是「看着简单、写着啰嗦」的活儿。Vue ElementUI 的组合在国内后台项目中非常常见但菜单栏布局往往不是直接套一个el-menu就能完事尤其是「顶部一级菜单 左侧二级菜单」这种双菜单联动的结构牵扯到路由设计、菜单数据来源、激活态同步、刷新恢复等一系列问题。这篇内容就想把我实际封装这套菜单的方案、踩过的坑和排查思路一次性说清楚给正在做同类后台系统的朋友一个可以直接拿来改的参考。这套方案的核心思路其实不复杂菜单不单独维护一份静态数组而是让路由表成为唯一数据源顶部菜单渲染一级路由左侧菜单根据当前一级路由动态渲染对应的二级路由。好处很明显新增页面时只要注册路由菜单就自动出来了不会出现路由和菜单两边维护导致对不上的尴尬情况。无论你是刚接触 Vue 后台项目的新手还是已经在项目里被菜单联动问题折磨过的开发者下面这些内容应该都能帮到你。1. 整体设计与思路拆解1.1 为什么选「顶部一级 左侧二级」而不是其他结构后台管理系统的导航结构我见过的无非三种纯左侧多级菜单、顶部一级加左侧二级、左侧全部展开再加面包屑。纯左侧多级适合功能模块不多、层级也不深的系统一个侧边栏就能装下所有东西。但一旦系统业务域变多比如有工作台、订单中心、用户中心、营销中心、设置中心等大块功能全部塞进左侧就会变得非常拥挤用户找功能要滚很久。顶部一级加左侧二级的结构本质上是把「业务域」和「业务域下的具体功能页」做了解耦。顶部菜单代表你当前处于哪个大模块左侧菜单代表这个模块里你能做什么。这种信息架构比较符合人脑的认知习惯也适合那些一级模块数量不多一般五到八个以内但每个模块下功能页较多的系统。如果你的系统一级模块超过十个顶部就会塞不下这时候反而要重新考虑信息架构或者把部分一级模块收成分组下拉。另一个天然好处是权限模型的映射更直观。很多后台系统的权限设计就是「按模块划分角色权限」顶部菜单对应模块列表左侧菜单对应模块内的操作权限。这样前端做菜单权限过滤时只需要按模块维度去控制一级菜单的显隐控制逻辑会清晰很多。如果你做的是纯左侧多级菜单权限控制往往要递归处理多层数据复杂度会明显上升。从 ElementUI 组件层面来说顶部菜单用水平模式的el-menu左侧菜单用垂直模式的el-menu都是组件库本身就支持好的形态不需要额外开发特殊布局对团队来说上手成本低。这也是我最终采用这个方案而非自己写导航组件的原因之一——稳定、通用、社区踩坑资料多。1.2 用路由驱动菜单而不是维护一份静态菜单数组很多后台项目会把菜单写死在一个menu.js文件里路由单独维护一份。短期看没问题但项目迭代到中期就会暴露两个问题一是新增页面时容易忘记同步菜单导致页面能访问但菜单里找不到入口二是菜单和路由的标题、图标、路径等信息分散在两处改一处漏一处测试起来很头疼。所以我推荐的做法是让路由表成为唯一数据源菜单组件通过遍历路由配置来渲染。ElementUI 的el-menu配合router属性可以让index直接作为路由路径跳转这样菜单项和路由路径天然绑定在一起不会出现「菜单点了没反应」的问题。路由驱动菜单还有一个好处就是刷新页面时状态可以恢复。因为路由本身就是全局的当前地址栏是什么 path就能通过$route反推出当前应该高亮哪个一级菜单、展开哪个二级菜单。如果菜单是独立维护的静态数组刷新后往往需要额外写逻辑去同步稍不注意就会出现菜单选中态丢失的情况。当然路由驱动并不代表路由配置就是最终的展示菜单。我用meta字段来存放菜单需要的附加信息比如title、icon、hidden、roles等渲染菜单时做一次过滤把hidden: true的隐藏掉把无权限的过滤掉最终展示的才是当前用户能看到的菜单。这样设计之后菜单的展示逻辑就变成了「路由配置 - 权限过滤 - 菜单渲染」一条清晰的链路。2. 路由与菜单数据模型设计2.1 目录结构与路由拆分示例我习惯把路由拆成两个文件router/index.js负责创建路由实例和基础路由router/menu.js专门导出需要参与菜单渲染的业务路由。这样基础路由比如登录页、404 页和菜单路由分开管理逻辑上不会互相干扰。一个典型的业务路由配置大概是这样的// router/menu.js import Layout from /layout/index.vue const menuRoutes [ { path: /dashboard, name: Dashboard, component: Layout, redirect: /dashboard/index, meta: { title: 工作台, icon: el-icon-odometer }, children: [ { path: /dashboard/index, name: DashboardIndex, component: () import(/views/dashboard/index.vue), meta: { title: 工作台首页 } } ] }, { path: /system, name: System, component: Layout, redirect: /system/user, meta: { title: 系统管理, icon: el-icon-setting }, children: [ { path: /system/user, name: UserManage, component: () import(/views/system/user.vue), meta: { title: 用户管理 } }, { path: /system/role, name: RoleManage, component: () import(/views/system/role.vue), meta: { title: 角色管理 } }, { path: /system/menu, name: MenuManage, component: () import(/views/system/menu.vue), meta: { title: 菜单管理 } } ] }, { path: /order, name: Order, component: Layout, redirect: /order/list, meta: { title: 订单中心, icon: el-icon-s-order }, children: [ { path: /order/list, name: OrderList, component: () import(/views/order/list.vue), meta: { title: 订单列表 } }, { path: /order/refund, name: OrderRefund, component: () import(/views/order/refund.vue), meta: { title: 退款管理 } } ] } ] export default menuRoutes这里有几个关键设计需要注意一级路由的component统一指向Layout布局组件左侧菜单、顶部导航、面包屑这些公共区域都在Layout里。一级路由的path不直接对应一个页面而是对应一个模块入口通过redirect指向该模块的默认二级页面。二级路由的path建议写成全路径比如/system/user而不是相对的user。这样在el-menu的index里可以直接绑定全路径作为路由跳转目标省去路径拼接的麻烦。children里不一定要嵌套更多层级如果你系统有三级菜单我会在后面的进阶部分讲递归渲染方案。2.2 菜单元数据字段约定路由的meta字段是菜单渲染的关键我把几个常用字段统一约定一下字段类型作用titlestring菜单显示名称同时也是页面标题的一部分iconstringElementUI 图标类名或 SVG 图标名称hiddenboolean是否在菜单中隐藏该路由默认falserolesarray允许访问该路由的角色列表用于权限过滤alwaysShowboolean如果为true即使只有一个子路由也显示为一级菜单activeMenustring当前路由需要高亮某个菜单时指定对应的菜单路径breadcrumbboolean是否需要出现在面包屑中默认truetitle和icon不用多说直接对应菜单上的文字和图标。hidden很实用有些页面比如详情页、编辑页虽然需要注册路由但不应该出现在菜单里。roles主要用于权限控制在过滤菜单时使用。activeMenu是处理「当前页面属于某个二级菜单但路由 path 和菜单 path 不一致」的情况比如编辑用户页是/system/user/edit/1但菜单里高亮的应该是/system/user此时通过activeMenu指定高亮目标即可。我自己实际用下来字段约定一定要在项目早期定下来并且写进团队文档里。不然每个人往meta里塞自己需要的字段时间久了菜单渲染逻辑会被各种判断淹没维护成本直线上升。3. 布局容器与组件联动实现3.1 使用 el-container 搭建整体布局整个页面的外层布局我用 ElementUI 的el-container组合完成结构上分为三块顶部 Header放一级菜单、左侧 Aside放二级菜单、右侧 Main放页面内容。Layout组件的主体结构长这样template el-container classlayout-wrapper el-header classlayout-header div classlogo-area click$router.push(/dashboard) 管理系统 /div top-menu / /el-header el-container classlayout-body el-aside classlayout-aside widthauto side-menu / /el-aside el-main classlayout-main router-view / /el-main /el-container /el-container /templateel-header默认高度 60px水平方向默认占满整行。这里要注意的是el-header内部如果需要放 logo 和菜单可以用 flex 布局左边 logo右边菜单避免额外写浮动样式。el-aside的宽度我在项目中是动态控制的支持展开和折叠。展开时宽度固定 220px 左右折叠时变成 64px只显示图标。宽度逻辑通过计算属性绑定到el-aside的width属性上。折叠按钮一般放在 Header 里点击切换isCollapse状态然后传给SideMenu组件。el-main作为内容区域需要注意两点一是padding建议统一设为 0具体内容间距由业务页面自己控制二是如果用了keep-alive缓存页面router-view外层需要包一层keep-alive并且把需要缓存的组件name匹配好不然缓存不生效。3.2 顶部一级菜单组件实现TopMenu组件的核心逻辑是从路由配置中过滤出需要展示的一级菜单渲染成水平模式的el-menu并通过计算属性实时高亮当前路由对应的一级菜单。template el-menu classtop-menu modehorizontal background-color#001529 text-color#ffffff active-text-color#409eff :default-activeactiveTopMenu router el-menu-item v-foritem in topMenuList :keyitem.path :indexitem.path i :classitem.meta.icon v-ifitem.meta.icon/i span{{ item.meta.title }}/span /el-menu-item /el-menu /template script import menuRoutes from /router/menu export default { name: TopMenu, computed: { topMenuList() { return menuRoutes.filter(route !route.meta.hidden) }, activeTopMenu() { return this.$route.matched[0] ? this.$route.matched[0].path : } } } /script这里最关键的是activeTopMenu的计算。$route.matched会返回当前路由匹配到的所有路由记录从父级到子级排列。比如访问/system/usermatched[0]就是一级路由/system取它的path就能得到当前应该高亮的顶部菜单。这比通过$route.path.split(/)[1]再拼路径可靠得多因为matched是 Vue Router 解析好的结果不会因为路由嵌套层级变化而出错。el-menu加上router属性之后点击el-menu-item会直接调用router.push跳转到index指定的路径。所以点击顶部菜单/system时路由会跳转到/system然后在redirect配置的引导下自动进入/system/user页面。这个过程看起来很顺滑但有一个细节要注意redirect是异步跳转顶部菜单的高亮依赖$route的更新所以快速连续点击顶部菜单时可能会出现高亮短暂错乱项目里我通过watch路由变化后统一更新菜单状态来避免。3.3 左侧二级菜单组件实现SideMenu组件的职责是根据当前顶部菜单选中的模块渲染该模块下的二级菜单。它需要从父组件或者直接从路由中拿到当前一级路由的children然后渲染成el-submenu或者el-menu-item。如果二级菜单下没有更深的层级可以直接这样写template el-menu classside-menu background-color#001529 text-color#ffffff active-text-color#409eff :collapseisCollapse :default-active$route.path router el-menu-item v-forchild in currentChildren :keychild.path :indexchild.path i :classchild.meta.icon v-ifchild.meta.icon/i span slottitle{{ child.meta.title }}/span /el-menu-item /el-menu /template script import menuRoutes from /router/menu export default { name: SideMenu, props: { isCollapse: { type: Boolean, default: false } }, computed: { currentTopPath() { return this.$route.matched[0] ? this.$route.matched[0].path : }, currentChildren() { const current menuRoutes.find(route route.path this.currentTopPath) return current ? current.children.filter(child !child.meta.hidden) : [] } } } /script当二级菜单下还有三级菜单时currentChildren里某个child自身也有children就不能直接el-menu-item了需要递归组件。ElementUI 的el-submenu嵌套el-menu-item可以继续嵌套el-submenu但递归组件更通用。我项目中封装了一个SideMenuItem递归组件判断当前路由是否有children来决定渲染el-submenu还是el-menu-item。左侧菜单的default-active直接绑定$route.path这样访问二级页面时对应菜单项能正确高亮。需要注意default-active是初始化时的值后续路由变化不会自动更新高亮所以需要监听路由变化手动更新或者用计算属性绑定activeMenu。实际上 ElementUI 的el-menu在router模式下组件内部会监听路由变化自动更新菜单高亮但如果遇到高亮不对的情况手动绑定:default-active$route.path是最直接的解决办法。3.4 激活态同步与面包屑联动顶部菜单和左侧菜单的激活态必须保持同步否则会出现「顶部在工作台左侧还是系统管理」的割裂感。我的做法是统一通过$route.matched获取当前路由层级然后分别驱动顶部和左侧的高亮。具体实现上顶部菜单通过matched[0]拿到一级 path 作为高亮值左侧菜单通过matched[matched.length - 1].path拿到当前实际页面的 path 作为高亮值。如果当前页面有meta.activeMenu则左侧高亮优先使用activeMenu的值。比如从用户列表跳转到用户编辑页编辑页的meta.activeMenu设置为/system/user左侧就会保持「用户管理」高亮不会因为地址变成/system/user/edit/1而丢掉高亮。面包屑和菜单的联动本质上也依赖matched。我封装了一个Breadcrumb组件遍历$route.matched过滤掉meta.breadcrumb false的项渲染成面包屑。最后一个面包屑项通常是当前页面名点击前面的面包屑项跳转到对应路由。这样菜单、路由、面包屑三者共享同一份数据模型不需要各写一套。4. 动态菜单与权限控制进阶4.1 后端返回菜单树的动态渲染如果系统需要权限控制前端路由表就不能一次性全部注册。常见做法是登录成功后后端返回当前用户的菜单树和权限标识前端把这些动态路由通过router.addRoutes注册到路由实例中同时渲染成菜单。后端返回的菜单树 JSON 结构一般长这样[ { path: /system, name: System, component: Layout, redirect: /system/user, meta: { title: 系统管理, icon: el-icon-setting }, children: [ { path: /system/user, name: UserManage, component: system/user/index, meta: { title: 用户管理 } } ] } ]前端拿到这个树之后需要做两件事一是把component字符串映射成实际的组件对象二是递归处理成 Vue Router 需要的路由配置。组件映射表我在项目里维护在router/component-map.js里类似const componentMap { Layout: () import(/layout/index.vue), system/user/index: () import(/views/system/user/index.vue), system/role/index: () import(/views/system/role/index.vue) }component字段统一写相对views目录的路径前端根据这个 key 去映射表里找对应的懒加载函数。这样后端改菜单时只需要调整 JSON 数据前端不需要发版。当然新开发的页面需要提前把组件路径加进映射表里否则后端配置了菜单也加载不了组件。动态渲染菜单的核心组件我做成了递归组件MenuTree它接收一个路由数组遍历渲染el-submenu或el-menu-item并在el-submenu内部递归调用自身渲染子菜单。这个方案对任意层级的菜单都适用顶部一级菜单加左侧二级菜单不过是它最简单的一种形态。4.2 路由守卫与菜单权限过滤动态菜单场景下路由守卫的逻辑要调整一下。我常用的写法是在beforeEach里判断用户是否已经拉取过菜单如果没拉取过先调用后端接口获取菜单数据和权限标识用addRoutes动态注册然后next({ ...to, replace: true })重新进入当前路由。这样处理之后刷新页面时即使路由还没有注册也会先拉菜单再重新进入目标页面不会出现白屏。router.beforeEach(async (to, from, next) { const token getToken() if (!token) { if (to.path /login) { next() } else { next(/login) } return } if (to.path /login) { next() return } if (!store.getters.menuLoaded) { try { const menus await store.dispatch(user/getUserMenus) const accessRoutes filterAsyncRoutes(menus) router.addRoutes(accessRoutes) store.commit(menu/SET_MENU_LOADED, true) next({ ...to, replace: true }) } catch (error) { await store.dispatch(user/logout) next(/login?redirect${to.path}) } } else { next() } })filterAsyncRoutes这里做两层过滤第一层过滤掉当前用户无权访问的菜单第二层把hidden: true的页面过滤掉不显示在菜单但路由仍然注册。权限判断的依据是meta.roles和当前用户角色列表是否有交集如果meta.roles不存在表示所有登录用户都可访问。菜单权限过滤有一个容易被忽略的坑addRoutes之后之前已经发起的导航不会自动完成所以要用next({ ...to, replace: true })让当前导航重新走一遍。如果不加replace: true可能会出现重复触发beforeEach导致死循环的风险我自己就被这个坑折磨过一次。4.3 菜单图标、隐藏菜单与多级菜单扩展图标这块如果你用的是 ElementUI 自带的图标直接在meta.icon里写类名就行比如el-icon-setting。但实际项目里图标需求往往会超出 ElementUI 内置图标范围我用的是 SVG 雪碧图方案meta.icon存的是 SVG 图标名称渲染时通过svg-icon icon-classsetting /这个全局组件来显示。这样图标库可以按需扩展不会跟 ElementUI 版本绑定。隐藏菜单和alwaysShow的配合要注意一下。hidden: true的页面要区分两种场景一种是真的不能被菜单访问比如编辑页另一种是「菜单不显示但路径要可访问」比如详情页通过列表跳转。后者虽然菜单里看不到但路由必须注册否则跳转过去直接 404。alwaysShow: true则适用于只有两个子页面但希望以一级菜单展示的场景比如某个模块只有一个「数据总览」子页默认会渲染成可直接点击的菜单项如果你想保留子菜单结构就加上alwaysShow。多级菜单的递归扩展其实不复杂关键是el-submenu的index不能和el-menu-item的index冲突。我在递归组件中统一用route.path作为index因为路由路径全局唯一不会重复。另一个小细节是折叠状态下多级菜单的el-submenu弹层显示效果更好图标建议单独取一级路由的meta.icon子菜单标题只在弹层里显示折叠时el-submenu会自动处理。5. 常见问题与排查记录5.1 刷新后菜单不选中页面却正常问题描述点击二级菜单进入页面页面正常显示但刷新之后左侧菜单的高亮丢失顶部一级菜单也回到了默认状态。排查思路default-active是一个初始化属性只在el-menu挂载时生效一次。刷新页面后组件重新挂载此时如果default-active的值没有正确绑定到当前路由就会出现高亮丢失。而页面正常是因为路由本身已经通过router跳转到位了。解决办法把default-active改成绑定计算属性比如activeMenu计算逻辑永远从$route反推。同时确保路由守卫在动态菜单场景下拉取菜单完成后再渲染菜单组件否则组件挂载时$route还没匹配到动态路由拿到的matched是空的。这里我还有一个心得不要在菜单组件的mounted里手动调一次setActive这只能解决初始化问题后续路由变化还是需要依赖el-menu的router模式自动同步。手动处理和自动处理叠加容易产生混乱最后你会分不清是谁把高亮改坏了。5.2 点击顶部一级菜单后左侧菜单没切换问题描述顶部菜单有「系统管理」「订单中心」两个模块点击「订单中心」后路由跳转了但左侧菜单仍然显示「系统管理」下的子菜单。排查思路左侧菜单的数据来源是currentTopPath如果currentTopPath没有正确变成「订单中心」的 path说明$route.matched解析有问题或者menuRoutes里找不到匹配项。排查步骤打开控制台打印this.$route.matched确认matched[0].path是否为/order。打印menuRoutes.find(route route.path this.currentTopPath)确认是否找到对应的一级路由配置。检查一级路由的path写法是否统一比如有的写/order有的写/order/末尾斜杠不一致会导致匹配失败。常见原因有两个一个是redirect配置了但路由表里没有对应path的匹配记录另一个是左侧菜单组件里使用了menuRoutes的原始引用路由动态注册后没有更新。如果是动态菜单建议把菜单路由存入 Vuex菜单组件从 store 中读取保证响应式更新。5.3 折叠侧边栏后菜单文字重叠问题描述点击折叠按钮后侧边栏宽度变成 64px但菜单文字没有隐藏把图标和文字挤在一起看起来很难看。原因分析el-menu的collapse属性只影响菜单容器本身的样式菜单项里的文字需要写在slottitle的span里才会被自动隐藏。很多人直接写span{{ item.meta.title }}/span没有加slottitle导致折叠时文字显示不出来或者错位。正确写法el-menu-item :indexitem.path i :classitem.meta.icon/i span slottitle{{ item.meta.title }}/span /el-menu-item另外el-submenu的折叠在 ElementUI 2.x 版本里有一个已知样式问题el-submenu__title的子元素在折叠状态下可能不隐藏。解决办法是给el-submenu__title里的标题也加上slottitle或者覆盖样式强制隐藏。这个问题在不同小版本里表现不同我用的是element-ui2.15.x实测加slottitle能解决大部分问题。5.4 动态菜单刷新后 404 页面问题描述刷新一个动态路由页面地址栏 URL 正确但页面渲染成了 404。原因分析动态路由是通过addRoutes注册的刷新后 Vue Router 会重新初始化路由表。如果beforeEach里的菜单拉取逻辑还没有执行完路由匹配时找不到当前 URL 对应的路由就会掉进 404 兜底路由。404 路由通常是通配符path: *注册顺序很关键如果它注册在动态路由之前动态路由永远匹配不到。解决办法在beforeEach里确保菜单拉取完成并addRoutes之后再放行。404 路由不要放在constantRoutes里而是等动态路由注册完之后再加进去。我项目里的做法是constantRoutes只放登录页和空路由菜单路由和 404 路由都在动态阶段注册。如果你已经用了上述方案还是 404检查一下next({ ...to, replace: true })是否有执行以及addRoutes是不是空数组比如后端返回菜单为空时。5.5 菜单高亮和路由 path 大小写不一致问题描述路由 path 写的是/system/User菜单高亮显示的是/system/user访问页面时左边菜单不高亮。原因分析el-menu的router模式下点击菜单项会用index作为 path 调用router.push而default-active绑定的是$route.path。如果两者大小写不一致el-menu内部比对时就会匹配不上。解决办法项目里统一约定路由 path 全部小写单词之间用中划线分隔。组件文件命名可以用驼峰但路由 path 必须规范。这个约定要在项目初始化时写进开发规范不然后期排查起来非常痛苦。最后分享一点个人心得菜单组件写多了之后你会发现大多数问题其实不是组件本身的问题而是路由设计的问题。路由配置规范了菜单联动就是顺水推舟的事路由随意写菜单联动就是无底洞。我自己每次接手新项目第一件事就是看路由文件路由写得规整的项目菜单、面包屑、权限、标签页这些功能基本都很好做。大家在实现这套顶部一级加左侧二级菜单时建议先把路由这一层夯实不需要动态权限就先静态路由 meta过滤需要动态权限就做好component-map映射和路由守卫菜单永远从路由推导。等这套模型跑通了后续就算要加多级菜单、Tab 标签页、面包屑导航都是在同一个数据源上做扩展不会推倒重来。
网站建设高端定制企业官网