ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

后台管理系统侧边栏菜单重构实践:权限模型、动态渲染与路由联动

后台管理系统侧边栏菜单重构实践:权限模型、动态渲染与路由联动 干了两个多星期后台管理系统的侧边栏菜单今天终于完工版本号直接用了日期20260110。说出来有点不好意思这个菜单模块看着不复杂但真正做进去才发现越是基础的功能越容易在细节上翻车。这篇文章就当项目记录把菜单从设计到落地的过程完整拆开权限模型怎么定、动态菜单怎么渲染、刷新以后高亮状态怎么不丢、移动端适配该怎么取舍。如果你也正在重构后台菜单或者正准备接手类似的模块这份笔记可以直接拿去参考。1. 项目背景与需求梳理1.1 旧菜单为什么非改不可先交代一下背景。我们系统上线三年原来的菜单是前端写死的数组一级导航十多个二级导航也硬编码在路由配置文件里。产品每次加页面都得同时改两个文件一个加路由一个加菜单。稍微漏改一次菜单和页面就对不上明明配了地址点了就是空白页。更麻烦的是权限控制后端只返回当前用户的角色标识前端靠switch判断角色再过滤菜单。每加一个角色就要改一次前端代码发一次版维护成本已经快赶上业务开发了。这次重做菜单表面上是把侧边栏做得更好看本质上是把菜单从“静态配置”变成“数据驱动”。我不想再让菜单和路由绑死在代码里产品调整菜单顺序、隐藏某个入口都应该是后台配置的事而不是前端发版的事。1.2 新菜单的功能清单和验收标准动手之前我把需求列成了四个验收点。第一菜单必须由后端接口动态返回前端只负责渲染不做角色判断。第二支持一级、二级、三级菜单超过两级要能递归渲染折叠状态下不能错位。第三菜单要和路由、面包屑、页签联动刷新页面后当前菜单能自动高亮。第四移动端需要换一种展示方式不能把 PC 的侧边栏硬塞进去。这四点看起来不复杂但每一项都牵涉数据结构和前端状态管理。我特意把验收标准写进文档里就是为了避免做到一半出现“我觉得可以了”这种模糊状态。菜单这种组件改起来很容易测起来很容易漏验收标准不写清楚后续返工非常难受。1.3 技术选型Vue 还是 React我们的主技术栈是 Vue 3 Element Plus所以这次菜单重构没有任何悬念继续使用 Element Plus 的el-menu作为基础组件。不过如果你用的是 React Ant Design思路完全一样只是 API 名称不同。菜单重构的核心在于数据模型和状态管理不在于具体组件库。我见过有些团队为了换菜单顺手把整个 UI 框架换掉实在没必要菜单组件再难也只是整个系统的入口没有必要因为入口重构大厅。2. 菜单数据模型与权限设计2.1 菜单表字段少一个都不行菜单既然是后端返回的第一件事就是把数据结构定好。我这里最终用了这些字段menuId、parentId、path、name、icon、sort、type、perms。别小看这几个字段每一个都是后面功能的地基。menuId和parentId用来组成树形结构path是前端跳转的路由路径name是菜单显示名称icon是菜单图标标识sort是同级菜单的排序值type用来区分目录、菜单和按钮perms是权限标识。有些团队喜欢把菜单类型拆成三个枚举1 表示目录2 表示菜单3 表示按钮。这里我建议一定把按钮也放进菜单表不然按钮权限单独一张表维护起来非常痛苦。菜单的各字段说明我在开发文档里列了一张对照表方便后端同学理解字段名类型说明menuIdnumber菜单唯一 IDparentIdnumber父级菜单 ID根节点为 0pathstring前端路由路径namestring菜单显示名称iconstring图标组件名称sortnumber同级菜单排序值越小越靠前typenumber1目录 2菜单 3按钮permsstring权限标识如 system:user:add这张表可以直接作为前后端接口的字段约定。需要注意的是icon字段我存的是字符串名称不是整段 SVG 或图片链接。这样做的原因后面也会提到。2.2 权限标识怎么挂到菜单上很多后台系统的菜单权限是“用户 - 角色 - 菜单权限”这个关系。后端在登录接口里根据当前用户角色查出可见的菜单列表前端拿到的就不是全量菜单而是已经过滤好的菜单树。有人会觉得这在前端过滤更省事比如所有菜单全下发前端根据角色标识隐藏。我强烈不建议这么做因为隐藏不等于安全。懂一点前端基础的用户完全可以通过改请求或看包体找到隐藏页面然后直接访问 URL。菜单可见性必须由后端基于权限过滤后再返回前端只渲染后端给的数据。perms字段一般和按钮绑定比如“新增用户”按钮的权限标识是system:user:add。前端拿到菜单树后如果type 3就把它作为当前页面的按钮权限集合存储起来。页面上用v-if判断这个权限标识是否存在控制按钮是否渲染。这里有个小技巧按钮权限不要单独再做接口而是和菜单一起返回。用户在切换页面的时候前端已经从全局状态里拿到了该页面的按钮权限不需要再发一次请求。2.3 树形结构返回时的坑后端返回菜单时有的接口返回嵌套树结构有的返回扁平列表。如果返回嵌套树前端用起来方便但后端每次都要递归组装分页搜索都很麻烦如果返回扁平列表前端需要自己转树。我这次明确要求后端返回扁平列表前端统一转换。扁平结构的好处是接口轻、易缓存、易搜索。转树形的代码写一次就够了核心逻辑就是循环数组把parentId相等的节点放进对应的children里。实际开发中还要注意一个问题如果父节点被过滤掉子节点会变成孤儿。所以在转树之前可以先根据权限把整个扁平列表过滤一遍确保父节点和子节点都在权限范围内。这个过滤逻辑放在前端还是后端取决于你的权限粒度设计但无论如何孤儿节点都不能出现在菜单树上。3. 前端实现过程与细节3.1 通用菜单渲染组件的设计菜单渲染组件我用了递归组件方案。以 Element Plus 为例el-menu和el-sub-menu是现成的但三级及以上菜单如果不用递归组件模板会写得非常冗余。正确的做法是创建一个MenuTree.vue组件内部遍历菜单数组遇到有children的节点就渲染el-sub-menu然后在el-sub-menu内部递归调用自身组件没有children的节点直接渲染el-menu-item。核心思路类似这样template el-sub-menu v-ifitem.children item.children.length :indexitem.menuId template #title el-iconcomponent :isitem.icon //el-icon span{{ item.name }}/span /template MenuTree v-forchild in item.children :keychild.menuId :itemchild / /el-sub-menu el-menu-item v-else :indexitem.path el-iconcomponent :isitem.icon //el-icon template #title{{ item.name }}/template /el-menu-item /template这个组件的关键在component :is动态渲染图标。刚才说过后端icon字段只存图标名称这样做的好处是第一接口数据量小第二图标库版本升级时不需要改数据。前端需要维护一个图标名称到组件实例的映射这个映射通常放在全局避免每次渲染组件都去遍历图标库。3.2 折叠状态下的悬浮菜单处理侧边栏折叠是菜单的基本操作但这里有个细节折叠的时候如果只是把宽度从 240 压到 64二级菜单的悬浮展开经常会出现层级错乱、位置偏移。我的做法是折叠后只渲染一级菜单悬浮展开使用弹层组件渲染整个子菜单而不是让原菜单组件在窄状态下继续渲染子级。这样做的好处是动画、定位都由弹层组件接管不用自己写 CSS 去算坐标。如果你用的是 Element Plusel-menu自带collapse属性折叠后默认支持悬浮展开子菜单。但如果你自研菜单组件我建议折叠态单独做一套渲染逻辑不要和展开态共用同一个 DOM。折叠还有一个容易忽略的点折叠状态下一级菜单没有文字只有图标用户不容易分辨菜单含义。我加了一个title属性鼠标悬浮时显示菜单全名移动端长按显示名称这个交互成本不高但对体验提升很明显。3.3 当前高亮与面包屑的刷新恢复这部分大概是问题最多的。很多系统刷新页面后菜单高亮丢了面包屑也变成空白。原因很简单刷新之后菜单组件重新创建它不知道你当前在哪个路由。解决思路是让路由和菜单产生关联。我的做法是路由 meta 里关联menuId菜单项里存路由path。刷新时先取当前路由的meta.menuId再根据menuId在菜单树中找到对应节点逐级展开并设置高亮。路由配置类似这样{ path: /system/user, component: () import(/views/system/User.vue), meta: { menuId: 102, title: 用户管理 } }菜单树里menuId等于 102 的节点高亮。这个方案比用路径匹配更可靠因为路径可能因为后端配置而变化但menuId是唯一的业务标识。面包屑也可以用同样思路。面包屑其实可以直接用路由matched数组生成但那样只能显示路由层级显示不了菜单里的业务名称。我额外维护了一个menuId到菜单名称的映射生成面包屑时从这个映射里取名称比每次遍历菜单树要快。尤其菜单量大的时候遍历树的性能问题会很明显。3.4 页签栏与菜单的联动如果系统用了多页签也叫标签栏还需要考虑菜单打开和页签关闭的联动。点击页签关闭当前菜单页时应该回到最近访问的菜单而不是固定跳首页。这个逻辑看着小处理不好体验很受影响。我实现了一个tagQueue队列每次打开新菜单就入队关闭页签时如果当前页被关就重定向到队尾的菜单。这里有个坑关闭页签后立即跳转路由还没来得及刷新容易跳到一个已经不存在的页签。我的解决方法是先移除页签再从队列里取目标菜单用router.push跳转并延时清理当前组件的缓存避免组件销毁顺序错乱。页签和菜单联动时菜单折叠和高亮也要同步。比如页签关闭后跳转到目标菜单侧边栏的高亮就应该跟着变。所以我把菜单的高亮状态和当前页签的菜单 ID 绑定而不是绑定路由地址。这样页面切换、页签切换、刷新恢复都走同一套逻辑。4. 多终端适配与性能细节4.1 移动端抽屉方案设计后台系统做移动端适配别想着把侧边栏“缩小”成移动端侧边栏移动端更合适的是抽屉。入口按钮固定在顶部或左上角点击后从左侧滑入一个抽屉面板。不是因为抽屉好看而是移动端单手操作需要从上往下滑动侧边栏伸到屏幕中间会打断视线。底部导航只适合三到五个核心入口后台菜单动辄几十项底部导航根本放不下。我最后的选择是移动端抽屉 手风琴面板展开一级目录时只显示一级子项继续点子项再展开下一层。桌面端还是用侧边栏两套布局共用同一个菜单数据只是渲染组件不同。移动端还要注意手势穿透。抽屉打开时背景层不能继续滚动否则滑动菜单列表时背景页面跟着滚体验很糟糕。我用了一个简单方案抽屉打开时给body加overflow: hidden关闭时移除。这个操作在原生 JS 里很简单但在 Vue 里要留意生命周期组件销毁时如果忘了移除页面会一直锁滚动。4.2 大数据量菜单的性能优化一个系统如果菜单项很多比如几千个节点前端渲染时就要考虑性能。递归渲染几千个节点首次渲染时间会非常长。我的优化思路有三个第一菜单数据从后端拿到后先转换成一个扁平数组和一个树形结构扁平数组用于搜索和映射树形结构用于渲染第二渲染时只渲染当前展开的层级不渲染整个树第三对图标组件做懒加载不需要在菜单渲染时一次性加载所有图标。按需渲染的实现方式两种方案都可以一种是自己在递归组件里加展开状态另一种是使用虚拟滚动。对后台系统来说菜单量一般不会大到需要虚拟滚动的程度先做层级懒渲染就够用了。如果真遇到几千节点还要折叠展开不卡顿再考虑虚拟列表。4.3 多端权限一致性的校验多端适配之后权限一致性问题就来了。移动端不能因为交互简单就少返回菜单后端接口必须是同一份权限结果否则会出“PC 能看手机不能看”或者反过来。这点在产品验收时经常被忽略。我的做法是移动端菜单渲染时直接复用后端返回的同一份菜单数组只是 UI 组件不同。前端不做二次过滤后端把这当作一个强制约定。上线前我专门在移动端测了一遍权限把某个角色菜单禁用后重新登录确认菜单消失同时直接输入 URL 访问也返回 403。菜单显示问题和路由安全问题要分开处理不能只靠前端隐藏。5. 踩坑记录与排查思路5.1 刷新后菜单状态丢失从项目启动到上线我踩得最典型的坑就是刷新状态丢失。第一次实现高亮时我直接把当前菜单路径存到sessionStorage刷新后再取出来匹配。看起来可用但有两个问题一是用户通过浏览器地址栏手输路由时sessionStorage里的路径可能是旧的二是如果菜单权限被后端收回路径还在但菜单找不到高亮就会变成空白。最后我改成以路由 meta 为唯一数据源路径加 menuId 双重校验。只有 meta 里的 menuId 在菜单树中存在时才高亮否则回到默认仪表盘。这个逻辑写完之后我还特意测试了一个场景用户登录后后台临时删掉他某个菜单权限刷新页面系统不应显示这个菜单也不应高亮这个菜单。5.2 子菜单默认展开且用户信息串台另一个常见问题是用户 A 上次展开的是“用户管理”用户 B 登录后展开的还是“用户管理”。这个看起来是小问题但多人共用同一台电脑时私有信息会串。根因是展开状态用了全局 store没有按用户隔离。我的解决方式是在菜单状态里加了一个userKey维度每个用户保留一份折叠和展开状态退出登录时清空当前用户的数据。这里特别提醒如果你用localStorage存储菜单展开状态记得在退出登录时清理对应键值否则下一个用户登录会读到上一个用户的菜单状态。这是我在测试时最容易忽略的地方。5.3 菜单与路由权限不一致菜单返回了某个节点但用户直接访问 URL 时前端路由守卫没有拦截导致页面能打开但没有菜单入口。这其实是两个层面的问题菜单是显性入口路由守卫才是最后一道防线。我建议在全局前置守卫里做一次动态权限校验判断当前路由的 meta.menuId 是否在可见菜单树中不在就跳 403。这个校验不能用简单的“菜单树里有没有这个 path”来判断因为同一个路径可能挂在多个菜单下。必须用 menuId 判断否则会出现路径相同但菜单权限不同的情况。我这次的代码里专门写了一个isInVisibleMenus(menuId)函数所有受保护路由都过这个函数。如果以后菜单模块还要扩展这个函数会是权限校验的核心。5.4 后端返回排序值冲突菜单排序也是一个隐蔽的坑。后端如果只是简单地按sort字段排序可能会出现两个菜单sort值一样排序结果随机。尤其在树形结构转完以后子菜单的顺序也会跟着乱。我的方案是排序时加一个二级排序字段sort相同的情况下再按menuId升序。这样顺序是确定的不会因为接口返回顺序变化而跳动。如果你使用 Java 后端排序可以直接在 SQL 里写ORDER BY sort ASC, menu_id ASC。不要依赖数据库默认排序数据库默认排序在实际数据量变化时非常不可靠。6. 上线准备与个人体会6.1 菜单上线前的自测清单上线前我列了一份自测清单基本可以照着用每个一级菜单能跳转高亮正确。每层子菜单在折叠和展开状态下都能正常打开。刷新页面后高亮、面包屑、页签状态都正确。直接输入 URL 访问无权限页面能正确拦截并跳转 403。删除某个角色的菜单权限后重新登录确认菜单消失直接访问也已拦截。移动端抽屉滑动流畅点击无穿透关闭后页面能正常滚动。多用户登录时菜单展开状态不串台。这份清单不是一次性测完就结束而是每次改动菜单相关代码后的回归测试项。菜单模块改动频繁如果每次手动测全部项会很浪费时间。后来我把这些检查项写成了简单的自动化用例比如断言路由守卫、断言菜单数量至少解决了大部分重复劳动。6.2 关于版本号 20260110 的由来最后说下版本号。其实一开始我准备叫 v2.0想了想还是用 20260110 这个日期更准确。这个日期就是这版菜单真正落地的时刻。以后回到 Git 记录里看到这个分支名就能立刻知道是哪天的状态。我给这次上线打了个 tag就叫menu-20260110。等过几个月再回头看这个日期就是菜单重构的里程碑。6.3 这次重构给我最大的三点教训第一菜单永远不只是前端页面。如果你一开始就盯着菜单组件写代码大概率会漏掉权限、路由、缓存这些整体设计。第二数据模型统一是最大的效率杠杆。菜单字段和接口约定确定后前后端配合会顺畅很多。第三刷新的状态恢复一定要以路由 meta 为唯一依据不要依赖临时存储。这三条在这次重构里都被验证过也算是一点实战经验。这次菜单模块告一段落后续还会继续迭代。菜单这东西做起来不难但做好不容易希望这篇记录对你也有参考价值。
返回列表