做了好多年前端以后,我发现自己对侧边栏的执念比想象中深。后台系统里真正影响日常体验的地方,往往不是业务逻辑有多漂亮,而是左侧那个导航栏用着顺不顺手。这次就围绕“二级菜单导航栏(左侧)”这个需求,把数据模型、实现方案、交互状态和权限接入完整梳理一遍,顺便聊聊我在项目里踩过的坑。如果你是刚接手后台系统的新手,可以照着文章里的思路搭一套;如果已经是老手,也可以看看自己在高亮保持和动态菜单这两块有没有遗漏。
1. 从布局习惯说起:左侧二级菜单为什么会成为后台系统标配
如果你翻过几十个后台系统,会发现一个规律:导航几乎都放在左侧,层级深一点的还会做成二级菜单。这背后不完全是审美惯性,更多是使用效率和历史习惯共同作用的结果。
1.1 为什么是左侧而不是顶部
先从一个反直觉的现象说起:顶部导航在官网、内容站里很常见,但到了业务后台,几乎清一色换成左侧导航。原因主要有两个。
第一是阅读扫描习惯。人眼扫视页面时,视线通常从左上角开始,沿着左侧边缘往下寻找信息。后台用户每天要高频切换页面,左侧导航最容易被眼睛捕捉到,不需要刻意寻找。顶部导航虽然也在视野范围内,但当菜单项多了以后,用户需要横向移动视线,扫描成本明显更高。
第二是空间利用方式不同。顶部导航的宽度受浏览器窗口限制,一般只能放六到十个一级入口,再多就要做下拉或横向滚动,体验迅速劣化。左侧导航可以向下无限延伸,天然适合承载多层级菜单。二级菜单在左侧展开时,只是纵向多出一排文字,不会挤压页面的主体内容区域;如果放在顶部,二级菜单通常要弹出浮层,遮挡内容不说,鼠标一移开就消失的交互也容易让人烦躁。
另外还有一层历史因素。桌面软件时代,资源管理器、各类开发工具都采用左侧树形导航,用户对“左边是功能列表,右边是工作区”的心智模型已经非常牢固。Web后台系统延续这套布局,学习成本几乎为零。
1.2 二级菜单要回答的三个核心问题
说起来“做个左侧二级菜单”好像是个非常小的需求,但它实际上要同时回答三个问题:
- 层级怎么呈现:一级菜单和二级菜单之间,是默认全部展开,还是点击一级菜单才展开?
- 状态怎么保持:用户当前停留在哪个页面,导航要如何高亮?刷新浏览器之后,展开状态和高亮状态能不能还原?
- 权限怎么区分:不同角色登录后看到的菜单不同,前端要如何渲染出对应的二级菜单?
大多数项目里,样式和动画反而是最简单的部分,真正的复杂度全在这三件事上。我见过不少系统,导航刚做出来时很清爽,上线后天天收到“菜单展开状态不对”“刷新后高亮没了”“某人看不到某个二级菜单”之类的反馈,追根到底都是这三个问题没想透。
所以,我建议每个接触后台开发的人,先不要把“二级菜单”当成一个 CSS 任务,而要当成一个“数据 + 状态 + 权限”的联动任务。后面的内容都会围绕这个思路展开。
2. 先把菜单数据模型定清楚:树形结构、排序和图标字段的设计
我在重构一个后台导航栏时,最耗时的不是写组件,而是把菜单数据理清楚。一个项目经过多年迭代后,菜单数据往往散落在各个接口和配置文件里:有的模块从接口拿,有的模块写死在静态文件里,有的二级菜单甚至通过运营后台动态配置。这种情况下,不管前端代码多优雅,导航都会变得很难维护。
2.1 树形结构:使用嵌套children还是扁平列表
要搭建左侧二级菜单,第一件事是设计菜单的数据结构。目前主流有两种形式。
一种是嵌套树形结构,例如:
const menuData = [ { id: 'dashboard', title: '工作台', icon: 'Home', path: '/dashboard', children: [] }, { id: 'system', title: '系统管理', icon: 'Settings', path: '/system', children: [ { id: 'user', title: '用户管理', icon: 'User', path: '/system/user', children: [] }, { id: 'role', title: '角色管理', icon: 'UserFilled', path: '/system/role', children: [] } ] } ]树形结构非常直观,一眼就能看出层级关系,前端渲染时递归遍历即可。缺陷是如果后端存储的是平铺数据,组装树的时候需要额外写转换逻辑,操作不慎还容易造成循环引用。
另一种是扁平列表,每条数据带parentId:
const menuData = [ { id: 1, title: '工作台', path: '/dashboard', parentId: 0, sort: 10 }, { id: 2, title: '系统管理', path: '/system', parentId: 0, sort: 20 }, { id: 3, title: '用户管理', path: '/system/user', parentId: 2, sort: 10 }, { id: 4, title: '角色管理', path: '/system/role', parentId: 2, sort: 20 } ]扁平列表的好处是数据库落表方便,权限过滤时只需要按id筛选,排序也稳定。缺点是需要前端手动组装树。
我的建议是:接口给你什么你就接什么,但前端内部统一处理成树形结构再交给导航组件渲染。这样无论后端数据结构怎么变,导航组件只认一种输入,不会被乱七八糟的字段带偏。
2.2 排序、隐藏和图标字段:不注意会在后期吃大亏
菜单数据结构里,有几个字段比想象中重要,漏掉它们会埋下隐患。
首先是sort排序字段。很多后台系统早期不设计排序,接口直接按主键 ID 返回菜单,看起来没问题。一旦运营需要调整菜单顺序,数据库不能随便改 ID,只能临时加字段,反而要动底层表结构。正确的设计是预留sort,并且数值之间留间隔,比如 10、20、30,方便后续插入新菜单时不用整体重排。
其次是hidden字段。这个字段用来表示“菜单存在,但不在导航中显示,只能通过 URL 直达”。比如一些特殊页面、详情页、引导页,都不该放在导航里,但路由必须能访问。如果没有hidden,开发者往往会硬着头皮把不需要展示的入口也塞进菜单,导致侧边栏越来越长。
第三是icon字段。图标在菜单里不只是装饰,更是帮助用户快速识别功能区的重要视觉线索。设计时最好存图标名而不是存图片地址,因为前端流行图标库会升级,图片地址一变就裂。存图标名后,前端用映射表统一转换成图标组件,既灵活又可控。
我见过一些项目为了省事,把权限、隐藏、排序逻辑全部写在导航组件里,数据模型不做区分。结果导航组件越来越胖,改一次需求要动用全局搜索。先定清楚数据结构,后面很多问题会自动消失。
3. 二级菜单的三种实现路子:纯CSS、JS点击、组件化选型对比
数据模型定好之后,就要决定用什么方式实现交互。我见过的常规方案有三种:纯 CSS 展开、JS 点击控制、组件库方案。三者的适用场景差别很大,选错会平白增加维护成本。
3.1 纯CSS hover展开:只适合轻量导航
纯 CSS 实现通常是用:hover控制二级菜单的显示隐藏:
.menu-item:hover .sub-menu { display: block; }这种方案在官网导航、文档站里很常见,因为这类站点菜单层级浅、交互简单,而且主要是鼠标操作。但拿到后台系统里,问题就会接踵而来。
移动端没有 hover,触屏设备上根本点不开二级菜单;键盘用户无法通过 Tab 键展开子菜单,不符合可访问性要求;展开状态完全不受代码控制,想做到“点击其他菜单时自动收起当前展开的菜单”只能依赖额外逻辑。后台系统普遍需要精确控制导航状态,纯 CSS 方案可以说是天然乏力。
3.2 JS点击控制展开收起:后台管理系统最常用的方案
后台项目里更通用的是用 JS 维护一个“展开菜单列表”。点击一级菜单时,如果它没有子菜单就跳转路由;如果有子菜单就切换展开状态。数据结构通常是一个数组或 Set:
openKeys = new Set(['system', 'user'])把这个 Set 存储在当前页面的状态管理里,渲染时判断每个菜单是否在openKeys中,从而决定二级菜单是否展开。
这个方案最大的好处是状态可控:可以配合路由高亮自动展开对应菜单,也可以实现手风琴模式,还可以把展开状态持久化到本地存储,用户刷新后菜单也能保持原样。相比纯 CSS,代码量多一些,但可控性换来的是长期维护的省心。
很多人在这里会犯一个经典的错误:用单个布尔值控制所有菜单的展开与收起,导致要么全部展开、要么全部收起,无法精确控制某一个菜单。正确的思路一定是维护一个“当前展开菜单的集合”,而不是一个开关。
3.3 组件化方案与自研如何选
现在主流组件库里都有现成的导航组件,比如 Element 的el-menu、Naive UI 的n-menu、React 生态的antd Menu。如果你用的是现成组件库,直接用就好,省去很多基础交互代码。组件库的default-active和default-openeds对应高亮和展开状态,基本都能满足常规需求。
但组件库方案也有尴尬的地方:定制性受限。比如你希望二级菜单的缩进距离更紧凑、需要支持拖拽排序、或者要融合一套全新的视觉风格,改组件库的内部结构往往比自研还痛苦。另外有些组件库的导航默认行为是父级菜单只展开不跳转,如果你希望父级菜单既能展开又能跳转到默认子页面,就要自己处理额外逻辑。
我的选型经验可以总结成下面这张表:
| 方案 | 适用场景 | 交互可控性 | 维护成本 |
|---|---|---|---|
| 纯 CSS hover | 官网、文档站、轻量导航 | 低 | 低 |
| JS 点击自研 | 后台系统、需要精确控制状态 | 高 | 中 |
| 组件库 | 快速开发、视觉风格固定 | 中 | 中 |
如果项目里后台业务复杂、权限模型要求高,我倾向于自研一个轻量导航组件。如果只是快速交付一个内部工具,直接用组件库足够,没必要重复造轮子。
4. 手写一套可落地的左侧二级菜单:完整实现与关键代码
讲了这么多,还是要落到代码上。下面我用原生 HTML、CSS、JavaScript 实现一套最简可用的左侧二级菜单,思路可以直接迁移到 Vue 或 React 项目里。
4.1 HTML结构:嵌套列表的语义化写法
左侧菜单的本质是“树形列表”,用ul和li嵌套是比较自然的语义化写法。一级菜单如果包含二级菜单,就在该li内部再放一个ul。这样不仅结构清晰,样式上也容易做层级缩进。
<nav class="side-menu"> <ul class="menu-list"> <li class="menu-item"> <button class="menu-title">.side-menu { width: 220px; border-right: 1px solid #e5e7eb; overflow-x: hidden; overflow-y: auto; } .menu-list { list-style: none; margin: 0; padding: 8px; } .menu-title { display: block; width: 100%; padding: 10px 12px; background: transparent; border: none; cursor: pointer; text-align: left; font-size: 14px; color: #333; border-radius: 6px; } .menu-title:hover { background: #f3f4f6; } .sub-menu { overflow: hidden; max-height: 0; padding-left: 16px; transition: max-height 0.25s ease; } .sub-menu.open { max-height: 200px; } .sub-menu a { display: block; padding: 8px 12px; font-size: 13px; color: #666; text-decoration: none; border-radius: 6px; } .sub-menu a.active { background: #ecf5ff; color: #2d6cdf; }max-height方案有一个问题:如果你设定的max-height值小于实际菜单高度,动画会截断;值太大,展开速度又显得不自然。于是很多人会采用grid-template-rows: 0fr到1fr的过渡,它能精确跟随内容高度,实现类似展开/收起的效果:
.sub-menu { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.25s ease; } .sub-menu.open { grid-template-rows: 1fr; } .sub-menu > ul { overflow: hidden; }这两种方案我都用过,实际项目中grid-template-rows的体验更好,不需要估算高度,也不用担心菜单项增减导致动画截断。
4.3 交互逻辑:点击、高亮和路由同步
原生 JavaScript 逻辑其实很简单:维护一个openKeys集合,点击一级菜单时切换对应 key 在集合中的状态,然后重新给每个二级菜单容器加/删 class。
const openKeys = new Set(); document.querySelectorAll('.menu-title').forEach((titleBtn) => { titleBtn.addEventListener('click', () => { const key = titleBtn.dataset.key; const subMenu = titleBtn.nextElementSibling; if (openKeys.has(key)) { openKeys.delete(key); subMenu.classList.remove('open'); } else { openKeys.add(key); subMenu.classList.add('open'); } }); });这个基础版本的交互在简单场景下够用,但拿到真实后台项目里,还需要补充两块逻辑。
第一块是路由高亮。菜单里的链接被点击后,浏览器 URL 变了,导航组件必须知道“当前路径对应哪个菜单项”,从而在对应链接上加上.active高亮类。原生实现通常是在window.addEventListener('hashchange' 或popstate` 事件里重新计算当前路径,然后遍历所有菜单链接,找到 path 匹配的那一项。
第二块是持久化。希望用户刷新后菜单还保持展开状态,可以在点击展开时把openKeys存入localStorage,初始化菜单时读取并还原。这一点在纯前端方案里很容易实现,后面我会进一步展开。
5. 高亮保持、展开冲突与路由联动:最容易翻车的三个坑
菜单写起来很简单,但大部分后台系统的导航问题都出在三个特定场景:刷新之后状态丢失、多个菜单同时展开导致界面混乱、路由和菜单状态不同步。下面逐个拆解。
5.1 刷新后高亮丢失:从路由反推展开链
很多开发者做高亮的方式是“点谁给谁加 active”,这样刷新页面后 active 就丢了。因为用户可能在地址栏直接输入某个子页面 URL,也可能从通知消息里跳转到某个二级页面,根本不会经过菜单点击。
正确做法是每次路由变化后,用当前路径去菜单树里匹配,找到对应菜单项,然后把它的所有祖先节点都展开,再高亮当前项。这需要一个“根据路径查询菜单祖先链”的工具函数:
function findMenuChain(menus, targetPath, chain = []) { for (const menu of menus) { const nextChain = [...chain, menu.id]; if (menu.path === targetPath) { return nextChain; } if (menu.children && menu.children.length) { const result = findMenuChain(menu.children, targetPath, nextChain); if (result) { return result; } } } return null; }拿到chain后,把链上的所有菜单 id 加入openKeys,把最后一个菜单项标记为 active。这套逻辑放在路由变化后的统一处理函数里,而不是分散在各处的点击事件中,才能保证任何入口跳转都能正确还原导航状态。
需要注意处理路径匹配的边界情况:比如/system/user和/system/user/1可能对应同一个菜单详情页,匹配时可能需要按前缀匹配,而不是严格相等。引入路由表里的name字段来匹配通常更可靠,因为 URL 可能带参数,name是稳定标识。
5.2 展开冲突:手风琴效果还是多开并存
后台导航有两种常见交互模式:手风琴模式和自由展开模式。
手风琴模式指同一时间只保留一个一级菜单是展开状态。优点是界面干净,不会出现大片菜单铺满侧边栏的情况;缺点是用户在一个模块下展开过二级菜单后,切换到另一个模块时,原来的展开区域会自动收起,操作上可能多一步。
自由展开模式则允许多个一级菜单同时展开。用户可以在不同模块之间切换时保留各自的展开状态,减少重复打开的操作,但菜单一多界面会显得拥挤。
从技术上看,两者的区别只在于处理openKeys时是否做互斥。手风琴模式在添加新 key 时,清空除它之外的所有 key;自由展开模式则是简单往里添加。
我的建议是小团队内部系统用自由展开,因为用户通常需要在多个模块间来回切换,保留展开状态更高效;面向客户的大系统用默认手风琴,避免不熟悉系统的用户被一屏密密麻麻的菜单吓到。如果预算允许,做成“展开模式”配置项,让用户自己选择,就更完善了。
5.3 路由跳转与菜单联动的时序问题
还有一个典型问题:路由已经跳转成功了,但菜单没有同步展开。
比如用户从工作台通过全局搜索直接跳到/system/user,此时 URL 已经是用户管理了,但左侧导航还是收起状态。这是因为你只把路由变化后的处理逻辑绑定在了菜单点击事件上,没有监听路由本身的变化。
正确做法是在应用层统一监听路由变化,每次变化后重新执行“反推展开链”和“高亮”操作。不要依赖菜单点击事件去设置高亮,菜单点击只是触发路由变化的一种方式,导航高亮必须落在路由层,否则永远会出现不同步的漏网之鱼。
我参与的一个后台项目就因为这个时序问题反复被提缺陷,最后我们把所有导航状态更新逻辑收敛到一个函数里,并且只在路由变化时调用,菜单本身只负责触发跳转,职责分离之后问题彻底消失。
6. 权限控制与动态菜单:接口返回的数据如何映射成导航
到了这一层,菜单已经不只是前端组件的事,它和权限系统深度耦合。常见的业务是:不同角色登录后,侧边栏菜单完全不同。这个功能做不好,轻则用户看到不该有的入口,重则绕过前端直接访问无权限的页面。
6.1 权限菜单体系的基本设计
相对推荐的方案是后端负责过滤,前端只负责渲染。用户登录成功后,后端返回一个已经经过权限过滤的菜单列表,前端把这个列表存到全局状态里,驱动导航渲染。
流程大致是:
- 用户登录,拿到身份标识。
- 前端请求“当前用户菜单接口”。
- 后端根据该用户的角色、权限点,从完整菜单表中筛选出有权访问的树形结构并返回。
- 前端把返回的菜单数据渲染成左侧导航,同时动态注册对应路由。
- 前端路由守卫里再做一次兜底判断,防止用户手动输入 URL 访问无权限页面。
这样的好处是权限规则集中在后端管理,不会因为前端代码被篡改而泄露权限。很多公司为了防止安全问题,会在菜单表里同时存“菜单标识”和“接口权限”,前端无权接口时后端也会拒绝,但菜单显隐仍然以前端渲染为准。
6.2 动态菜单渲染后的路由注册与刷新处理
动态菜单真正的坑在“刷新”。
如果路由是登录后根据菜单动态注册的,刷新页面时全局状态里的菜单数据会被清空,路由自然也没有注册。此时用户明明已经登录了,刷新一下却显示 404 或者空白页。解决思路是在应用初始化时或者路由守卫的某个阶段,先检查全局状态里有没有菜单数据,没有就重新拉取菜单接口、重新注册路由。
用 Vue 项目举例,核心流程类似:
router.beforeEach(async (to, from, next) => { const menus = store.state.menus; if (!menus.length) { const data = await fetchUserMenus(); store.commit('setMenus', data); // 这里根据 data 动态 addRoute next({ ...to, replace: true }); } else { next(); } });React 项目里类似,在路由组件的初始化阶段异步获取菜单,渲染期间显示加载态。这里有一个细节:初始路由若配置成/跳转到第一个合法菜单,需要等菜单数据加载完再决定跳转目标,否则会跳到默认首页,导致用户看到无权限页面。
6.3 图标映射和菜单排序:两个容易忽略的小雷
动态菜单的后端返回数据里,图标通常是字符串,比如"icon-user"。前端不能直接把这个字符串塞进 HTML 或者当作组件名动态解析,这样既慢又不安全。推荐做法是维护一个图标映射表,把接口返回的字符串映射成真正的图标组件:
const iconMap = { 'icon-home': HomeIcon, 'icon-user': UserIcon, 'icon-settings': SettingsIcon, // 其他图标 }; function resolveIcon(name) { return iconMap[name] || DefaultIcon; }这样做的另一个好处是:后端图标名变了,前端只需要在映射表里调整,不用改菜单数据。
排序问题则要记住一条原则:前端不要擅自重排菜单顺序,后端接口返回的顺序就是展示顺序。如果某些菜单需要调整位置,应该让后端修改sort字段,而不是前端写一堆filter和sort逻辑。因为菜单顺序同时影响导航显示和移动端的菜单排列,前端排得再好,和数据库里其他业务逻辑不一致,迟早会对不上账。
我在实际项目里见过太多次“前端写好排序,运营后台改了菜单顺序后前端不生效”的案例,最后都要回过头来规范数据源头。
做任何一个后台系统,左侧二级菜单导航栏都是绕不开的基建环节。我的个人经验是:把菜单数据模型当一等公民来设计,把路由高亮和展开状态的同步逻辑收敛到一个统一入口,权限过滤交给后端处理。这三条做好了,导航栏就不会成为你长期维护的痛点。