在公司的一个后台管理系统项目里,我接手过一套“Vue + ElementUI 后台管理系统实现顶部一级菜单栏,左侧二级菜单栏”的需求。当时需求文档写得很简单,就一句话:“顶部一级菜单,左侧二级菜单,点顶部切换左侧,刷新不能丢状态。”听起来确实不难,但真正动手才发现,这套菜单体系牵扯到数据模型、路由联动、高亮回显、权限过滤和边界情况,稍微偷懒就会在测试阶段被一堆 bug 追着跑。
这篇文章不聊大而全的框架设计,就围绕“顶部一级 + 左侧二级”这两个核心诉求,把我在实际项目里的完整实现思路和踩坑经验拆开讲清楚。从菜单数据怎么设计、el-menu 怎么渲染、顶部和左侧怎么互相联动,到刷新后状态怎么恢复、哪些坑我替你先踩了,一次说完。适合用 ElementUI 做中后台项目、尤其是正在为菜单高亮和路由联动头疼的开发者。
1. 顶一左二的导航结构,本质上是在解决什么
很多开发拿到这个需求就开始写组件,但我觉得有必要先聊清楚:为什么后台管理系统普遍喜欢用“顶部一级菜单栏 + 左侧二级菜单栏”这种组合?这里面的产品逻辑想明白了,后面写代码时很多取舍就顺理成章了。
1.1 这个布局背后通常对应一套模块化权限模型
我接触过的后台系统,凡是采用这种导航布局的,基本都有一个共同点:系统业务被横向切成了若干个互不相交的大模块,比如“工作台”“订单中心”“营销管理”“系统设置”。这些模块数量一般控制在 5 到 10 个以内,放在顶部横向排列正好合适。每个大模块内部再纵向拆成若干功能页,比如“系统设置”下面挂“用户管理”“角色管理”“权限配置”。
这种结构下,顶部一级菜单表达的是“你现在处在哪个业务域”,左侧二级菜单表达的是“这个业务域里有哪些具体操作页”。导航和业务模块是严格对应的,所以代码层面也需要让这两个菜单保持同样的映射关系。
1.2 二级菜单下面还有三级怎么办
做后台难免遇到层级更深的时候,比如“系统设置 → 商品配置 → 运费模板”这种。很多人的第一反应是左侧菜单继续嵌套 el-submenu,让左侧变成长长的手风琴。我个人不太建议一上来就无限套嵌套,因为深度嵌套的菜单在小屏幕上体验很差,而且会让高亮逻辑复杂化。
更常见的做法是左侧只渲染两级,第二级如果有子页面,通过 tabs 标签页或页面内锚点导航来处理。如果实在避免不了三级,那就用递归组件渲染,这个我后面会专门讲。但设计菜单数据模型的时候,务必给子节点预留 children 字段,哪怕二级菜单下面暂时没有三级,这个字段空着也比将来推翻重构强。
1.3 这种导航组合对路由结构有一个硬性要求
菜单要能正常联动,路由本身最好是嵌套的。比如顶部一级“system”对应的路径是/system,左侧二级“user”对应的是/system/user,这样通过$route.path就能反推当前处于哪个模块。
如果项目里路由是打平写的,比如一级菜单是SystemLayout,二级页面全部注册成根路由下的平级页面,那后续做菜单高亮和面包屑会非常痛苦,因为你没有办法从当前路由可靠地推断出顶部该高亮谁。所以如果还没开始写代码,建议让路由配合菜单设计成嵌套结构;如果已经是打平路由了,至少要在路由 meta 里补一个topKey之类的字段,把页面和一级菜单的归属关系标记出来。
2. 动手写组件之前,先把菜单数据模型定下来
很多项目里的菜单是直接写在组件里的,或者从路由表里遍历出来。我这次的建议是:单独维护一份菜单配置文件,同时作为顶部菜单、左侧菜单、路由表、面包屑甚至权限校验的唯一数据源。听起来有点绕,但做完一次你就会发现,后续加页面只需要往配置里加一个对象,导航、菜单、标签页全都自动带出来了。
2.1 一份菜单配置应该长什么样
我经常用的菜单配置结构是下面这样,比较精简,但足够覆盖大部分后台系统的需求:
// src/config/menu.js const menuConfig = [ { key: 'dashboard', title: '工作台', icon: 'el-icon-data-board', path: '/dashboard', permission: 'dashboard:view', children: [] }, { key: 'system', title: '系统管理', icon: 'el-icon-setting', path: '/system', permission: 'system:view', children: [ { key: 'user', title: '用户管理', icon: 'el-icon-user', path: '/system/user', permission: 'system:user:view', children: [] }, { key: 'role', title: '角色管理', icon: 'el-icon-user-solid', path: '/system/role', permission: 'system:role:view', children: [] } ] } ]几个字段的取舍我说明一下:
key:菜单项的唯一标识,我强烈建议不要用 path 当 key,因为同一个业务页面可能要在路由上挂多个路径,比如带参数或 tab 场景。用稳定的 key 更可靠,后面高亮和跳转都靠它。path:这个菜单对应的默认路由路径,用于点击菜单时的跳转。permission:权限标识,非必需字段。没有权限控制的项目可以不加,需要做权限过滤的时候就靠它。children:二级、三级菜单的数组。一级菜单 children 为空的,视为单页模块,点击顶部直接跳转。
2.2 为什么不直接从路由表遍历出菜单
刚开始写 Vue 项目的时候我也图省事,直接在布局组件里遍历this.$router.options.routes生成菜单。后来发现有几类情况很难处理:
第一,路由表里存在布局组件、页面组件、重定向记录,还有隐藏路由,比如详情页、编辑页、公共错误页。直接遍历路由表,要么菜单里多出一堆不该出现的东西,要么为了过滤条件把路由表写得特别乱。
第二,菜单项和路由不是一一对应的。有的菜单项点击后要跳转到外部链接,有的菜单项只负责展开子菜单,自身没有路由。这些业务语义塞进路由表里会让路由表变得不伦不类。
第三,权限过滤的逻辑应该是“根据权限码过滤菜单”,而不是“根据权限码过滤路由”。因为路由表是静态注册的,动态菜单才是根据权限渲染的。如果两者混在一起,权限一变路由表也要跟着重建,耦合度太高。
所以我的做法是:路由表还是静态全量注册,菜单配置单独维护,渲染时根据权限码过滤出可见菜单,再按菜单的 path 跳转。路由变成“兜底校验”的角色,页面能否被访问由路由守卫去判断,菜单栏只负责展示和跳转,各司其职。
2.3 菜单状态放 Store,而不是堆在组件里
顶部一级菜单要影响左侧二级菜单,组件层级里它们是兄弟或跨层级的组件,靠事件传递会很别扭。我的做法是把菜单状态统一放到 Vuex(或 Pinia,思路一样)里管理。
核心状态就三个:
topMenus:当前用户能看到的一级菜单列表sideMenus:当前选中模块下的二级菜单列表activeTopKey:当前高亮的一级菜单 keyactiveSideKey:当前高亮的二级菜单 key
这些状态既被顶部菜单组件读取,也被左侧菜单组件读取,还会被路由监听逻辑修改。集中管理之后,联动逻辑就变得非常清晰。后面我会把 Store 里的关键代码直接贴出来。
3. 左侧二级菜单的渲染,el-menu 的正确用法
左侧菜单我使用的还是 ElementUI 的el-menu+el-menu-item+el-submenu。这里有一个核心思路:菜单组件要变成纯渲染组件,它不关心菜单数据怎么来、点击菜单之后做什么,只负责把数据渲染成正确的 DOM,把用户的选择抛回给 Store 或事件。
3.1 从 menuConfig 映射到 el-menu 节点
左侧菜单模板大致是这样:
<template> <el-menu :default-active="activeSideKey" :default-openeds="openedKeys" @select="handleSelect" > <template v-for="item in sideMenus"> <el-submenu v-if="item.children && item.children.length" :index="item.key" :key="item.key"> <template slot="title"> <i :class="item.icon"></i> <span>{{ item.title }}</span> </template> <el-menu-item v-for="child in item.children" :key="child.key" :index="child.key" > <i :class="child.icon"></i> <span>{{ child.title }}</span> </el-menu-item> </el-submenu> <el-menu-item v-else :key="item.key" :index="item.key"> <i :class="item.icon"></i> <span>{{ item.title }}</span> </el-menu-item> </template> </el-menu> </template>注意这里el-submenu的index和el-menu-item的index用的是菜单项的key,不是路径。这一点非常关键,我后面会单独讲为什么不用 path。
default-openeds用来控制哪些二级菜单默认展开。如果你希望选中二级菜单时,它所属的父级菜单自动展开,就需要维护一个openedKeys数组,在路由监听或者菜单点击时把父级 key 塞进去。
3.2 activeSideKey 与路由状态的正向绑定
el-menu组件的default-active控制当前高亮的菜单项。很多人在这里直接用this.$route.path,但如果菜单的index是 key,高亮就永远对不上。
我的做法是维护一个activeSideKey状态,让它和路由形成“路由变化了就根据路由算出当前菜单应该高亮谁”的逻辑:
watch: { '$route.path': { handler(newPath) { const menu = findMenuByPath(newPath) if (menu) { this.activeSideKey = menu.key this.activeTopKey = getTopKeyByKey(menu.key) this.openedKeys = getParentKeys(menu.key) } }, immediate: true } }这样就把路由和菜单解耦了:不管菜单是通过点击跳转的,还是用户在地址栏里手敲的,只要路由变了,高亮自动同步。
这里有个很容易被忽略的点,就是监听路由的 handler 里要做findMenuByPath,而不是简单地把 path 赋值给激活项。因为业务页面可能包含 query 参数,比如/system/user?type=1,直接拿$route.path对比是没问题的,path 不含 query,但如果菜单项的index是 key 而不是 path,你仍然需要查配置表。
3.3 三级菜单怎么办:递归组件
前面提到,如果坚持要做三级菜单,用递归组件比层层嵌套 v-for 要干净得多。ElementUI 的el-menu是支持递归渲染的,把每个节点都抽象成一个组件,节点有 children 就渲染el-submenu,否则渲染el-menu-item:
<!-- MenuItem.vue --> <template> <el-submenu v-if="node.children && node.children.length" :index="node.key"> <template slot="title"> <i :class="node.icon"></i> <span>{{ node.title }}</span> </template> <menu-item v-for="child in node.children" :key="child.key" :node="child" ></menu-item> </el-submenu> <el-menu-item v-else :index="node.key"> <i :class="node.icon"></i> <span>{{ node.title }}</span> </el-menu-item> </template> <script> export default { name: 'MenuItem', props: { node: { type: Object, required: true } } } </script>递归组件在嵌套层级加深时也能正常渲染,并且唯一的 key 就是菜单项的 key,不会再出现嵌套循环里 key 冲突的问题。
4. 顶部一级菜单和左侧二级菜单的联动闭环
这块是整个逻辑的核心。顶部点击、左侧点击、路由变化、刷新恢复,四条链路要全部打通,这个菜单才算真正做完。我拆成三条主线来讲。
4.1 点击顶部:切换模块 + 定位首个可访问页面
顶部菜单组件监听el-menu的select事件,拿到一级菜单的 key 后,做三件事:更新顶部高亮、重新筛选左侧菜单、跳转到这个模块下的第一个可访问页面。
handleTopMenuSelect(topKey) { // 1. 更新当前顶部高亮 this.activeTopKey = topKey // 2. 根据 key 找出一级菜单配置,把 children 筛成左侧菜单 const topMenu = this.menuConfig.find(item => item.key === topKey) const sideMenus = filterByPermission(topMenu.children || []) this.setSideMenus(sideMenus) // 3. 找到第一个可访问的子菜单路径并跳转 const firstPath = getFirstUsablePath(sideMenus) if (firstPath) { this.$router.push(firstPath) } else { this.$router.push(topMenu.path) } }getFirstUsablePath需要递归找下去,因为一级菜单的 children 可能还有 children,要钻到最底层拿到第一个可跳转的 path。没有可访问子菜单的一级菜单,就当成单页跳转。
这里有一个业务细节值得注意:点击顶部一级菜单时,是否必须自动跳转第一个子页?有些产品希望只切换左侧菜单,不自动跳转,停留在当前页面。但我接触的大多数后台系统都选择了自动跳转,因为用户的操作意图是“切换模块”,如果不跳转,用户还要再点一次左侧菜单,多一步操作。
4.2 路由监听:反向同步高亮状态
光靠点击事件维护高亮是不行的,用户可能通过浏览器前进后退按钮、面包屑、标签页等方式跳转路由。所以必须有一个反向同步逻辑:当路由变化时,根据路由找到对应的菜单配置,更新顶部高亮和左侧菜单列表。
我通常在 Vuex 里建一个syncMenuByRoute的 action:
syncMenuByRoute({ commit }, route) { const path = route.path const menu = findMenuByPath(this.menuConfig, path) if (!menu) return // 找到该菜单项所属的一级菜单 const topKey = getTopKeyByKey(menu.key) const topMenu = this.menuConfig.find(item => item.key === topKey) // 根据权限过滤一级菜单的 children const sideMenus = filterByPermission(topMenu.children || []) commit('SET_TOP_MENUS', this.menuConfig) commit('SET_SIDE_MENUS', sideMenus) commit('SET_ACTIVE_TOP_KEY', topKey) commit('SET_ACTIVE_SIDE_KEY', menu.key) commit('SET_OPENED_KEYS', getParentKeys(menu.key)) }这个 action 在根组件的 watch 里调用,也在页面刷新后的初始化里调用。这样的话,无论用户怎么到达当前页面,菜单状态都是对的。
4.3 刷新页面后的状态恢复
刷新后 Vuex 内存数据全部清空,这个问题几乎每个后台项目都会遇到。处理方案是:页面初始化时不要依赖 Store 里已有的菜单状态,而是根据当前路由重新构建整个菜单状态。
我一般在布局组件的created钩子里做:
created() { this.$store.dispatch('initMenu', this.$route) }initMenu做的事情和前面syncMenuByRoute类似,多了权限过滤这一步。因为刷新后菜单数据需要重新从内存或接口中恢复,而路由是现成的,可以第一时间算出当前菜单状态。
这就是“路由是唯一可信来源”的思路:不依赖 Store 持久化,而是每次从路由反推状态。这样即使以后做标签页、做浏览器刷新、甚至直接跳到深链接,菜单状态都不会乱。
| 场景 | 触发动作 | 菜单状态谁负责 |
|---|---|---|
| 点击顶部一级菜单 | handleTopMenuSelect | 主动更新 Store 并跳转 |
| 点击左侧二级菜单 | handleSideMenuSelect | 主动更新 Store 并跳转 |
| 路由前进/后退 | $route watcher | syncMenuByRoute 反向同步 |
| 刷新页面 | created 钩子 | initMenu 从路由重建状态 |
5. 几个让我折腾到半夜的细节坑
这部分是我最想说的。菜单栏这类基础功能看着简单,但很多项目恰恰在这种地方埋了很多隐蔽的坑。下面这几个问题都是我在真实项目里遇到过的,每个都让我花了不少时间定位。
5.1 index 用 path,高亮却始终不对
我之前在另一个项目里图省事,el-menu-item的 index 直接用 path,比如/system/user。看似没问题,但很快发现当路由带 query 时,比如从/system/user?page=2刷新回来,default-active绑定的是$route.path还能正常工作,可一旦路径有重定向,比如访问/system重定向到/system/dashboard,高亮就停留在 dashboard 上,回不到 system 模块。
后来我把 index 改成用 key,彻底解决了“一个菜单项对应多个路由入口”的情况。比如“用户管理”菜单的 key 是user,它对应的路由可能是/system/user,也可能是/system/user/detail/:id,但高亮永远都应该是user。用 path 做 index 无法表达这种“一对多”的关系,用 key 就没这个问题。
5.2 el-menu 的 router 模式,我建议慎开
ElementUI 的el-menu自带一个router属性,开启后点击菜单会自动用 index 的值调router.push。听起来很方便,但实际用起来限制很大。项目里菜单点击往往需要先做权限校验、记录标签页、更新 Store 状态,甚至拦截“当前页面有未保存表单”的提醒,这些逻辑放到select事件里统一处理比散落在router模式内部要可控得多。
所以我通常不开router模式,而是手动在handleSelect里拿 key 去查配置,拿到 path 后用this.$router.push({ path, query })跳转。多写了一行代码,换来的却是所有跳转逻辑都在一个函数里,可维护性高了一个量级。
5.3 菜单配置里有,但路由表里没有对应路由
这个问题是我新同事第一次联调时踩的。他在菜单配置里加了一个路径/system/audit-log,但忘了在路由表里注册,结果点击菜单后页面空白,浏览器控制台一片报错。
排查思路大概是:先看$route.path有没有变化,再看是否有匹配的组件。如果 path 变了但没有渲染任何东西,基本就是路由表少了这条记录。所以我后来在开发环境里加了一个检测逻辑:在路由守卫里判断当前 path 是否能在$router.resolve(path)时找到匹配记录,找不到就提示加菜单的人去补路由表。这种配置和表不一致的问题,宁可早暴露,也别等到上线后由用户发现。
5.4 keep-alive 缓存导致的高亮恢复失灵
后台系统通常会对页面组件做 keep-alive 缓存,避免切换菜单后表单数据丢失。但缓存的坑在于:如果用户从 A 页面切到 B 页面,再回 A 页面,A 页面组件不会重新执行 created,某些依赖 created 里初始化数据的逻辑就不生效了。
菜单高亮如果只依赖页面组件的声明周期去设置,就会受 keep-alive 影响。正确的做法是把菜单同步逻辑放在布局组件里,监听$route,页面组件缓存不缓存都不影响布局的 watcher 触发。我在前面强调的“菜单状态跟随路由变化”的思路,这时候就体现出价值了:只要路由变了,菜单状态一定会同步,跟页面是否被缓存无关。
6. 往深处走一步:这份菜单机制能带出的周边能力
菜单配置和数据模型定下来之后,不只是解决“顶部切左侧”这一个需求。我在实际项目中顺手用它做了面包屑和标签页,效果非常好,相当于一份数据源支撑了导航相关的所有功能。
6.1 面包屑:复用同一份菜单元信息
面包屑的难点在于如何为每个路由定义“我在哪”。如果菜单配置里已经有 title、path、parent key 这些信息,面包屑就是一次路径回溯。
我实现的时候,会先从菜单配置里找到当前 path 对应的节点,依次向上收集父级节点,反转数组后渲染为面包屑。遇到不在菜单配置里的路由(比如详情页、编辑页),就在路由 meta 里补充一个title和parentKey,让详情页也能正确显示“用户管理 > 用户详情”这样的层级。
这样一种菜单、面包屑、标签页共享同一份元数据的做法,比每个功能都单独维护一套标题逻辑可靠得多。
6.2 标签页:点击菜单时顺手记录
做标签页功能时,可以在左侧菜单select事件里,把目标菜单项 push 到标签页列表,同时把标签页的标题、path、key 都从菜单配置里取出来,不用在页面组件里重复定义标题。关闭标签页时,如果关闭的是当前激活页,再根据标签页列表里前一个标签的 path 做一次router.push,配合路由监听,左侧菜单高亮自动切换,不会出现“页面关了但菜单还高亮着”的问题。
这个联动我强烈建议做掉,因为后台系统只要菜单变完整了,标签页几乎是刚需,现在花一天时间把数据源理顺,后面能省一周的返工。
6.3 如果项目准备升级 Element Plus
现在很多新项目直接上 Element Plus,老项目也有升级计划。菜单相关代码的迁移点主要有三个:el-menu基本用法没变,但el-submenu的slot="title"改成了具名 slot 的#title;图标从<i class="el-icon-setting">变成了<el-icon><Setting /></el-icon>,递归组件内部要多判断一层;另外default-active和select事件名没有变,所以核心的联动逻辑可以原样保留。
我自己的建议是,菜单数据模型和联动逻辑尽量写成和 UI 库无关的代码,切换 Element UI 和 Element Plus 时只改渲染层组件,业务逻辑完全不动。这也是把菜单配置提取出来的额外收益,换 UI 库的代价被压缩到了最小。
最后我再分享一个个人习惯:做完菜单功能后,我会顺手在代码里留一个只读的menuConfig结构预览工具页面,把所有菜单配置渲染成一棵树,方便后端同学确认权限标识是否对齐,也方便产品同学看到最终的信息结构调整。这个工具页面不属于需求,但每次项目中期评审时都会帮大忙。菜单这个东西,说到底不只是前端组件,它是整个系统的信息骨架,骨架立稳了,后续的页面、权限、导航都会顺很多。