导航这东西,看起来简单,做起来全是细节。我做过不少后台系统、内容站点和工具类产品的前端,三级导航几乎是绕不开的一个坎——一级放模块,二级放功能分组,三级落到具体页面或操作。很多人第一次写三级导航,写出来的东西要么层级混乱、要么交互别扭、要么在移动端直接崩掉。这篇就围绕“实现简单的三级导航”这个主题,把我自己反复踩坑、反复重构之后沉淀下来的思路和代码结构完整拆一遍,从设计取舍到落地实现,再到排查技巧,尽量让你看完就能直接抄作业。
先说清楚这篇适合谁看:如果你已经会写基础的HTML/CSS,能看懂一点JavaScript,但一到多级菜单就不知道数据怎么组织、展开收起怎么控制、样式怎么不打架,那这篇正好对路。如果你是有经验的前端,也可以从里面的数据结构设计和状态管理思路里挑点能复用的东西。核心关键词就三个:三级导航、数据结构、展开收起交互,全文都会围绕它们展开。
1. 三级导航的整体设计与思路拆解
1.1 为什么“简单”反而更难做对
标题里写的是“简单的三级导航”,这个“简单”二字其实是个陷阱。我见过太多人一上来就写死三层HTML嵌套,ul > li > ul > li > ul > li,静态页面看着没问题,但只要产品经理说一句“二级菜单要能动态配置”,整个结构就得推倒重来。所以这里的“简单”我理解为:结构清晰、逻辑可维护、扩展不痛苦,而不是代码行数少。
三级导航的本质是一个树形结构,层级固定为三层。固定层级是个很重要的前提,它意味着我们不需要写通用的递归渲染组件,可以用更直白的方式表达,可读性反而更好。但固定层级不代表数据要写死,数据依然应该用嵌套数组来描述,渲染层再去消费它。这个分离是我做所有导航组件的第一原则:数据描述结构,渲染负责呈现,交互管理状态,三者不要混在一起。
从使用场景看,三级导航常见于后台管理系统的侧边栏、内容站点的分类目录、工具类产品的功能入口。不同场景对交互的要求不一样:后台侧边栏通常要求手风琴式展开(同一时间只展开一个一级项),内容站点可能允许多个同时展开,移动端则往往要求点击一级项后整屏切换。所以设计之初就要想清楚:是单开还是多开、是悬停触发还是点击触发、展开状态要不要持久化。这三个问题决定了后面状态管理的复杂度。
1.2 数据结构选型:嵌套数组还是扁平表
数据怎么存,直接决定了渲染和交互怎么写。我试过两种主流方案,各有适用场景。
第一种是嵌套数组,每个节点自带children:
const navData = [ { id: 'module-a', label: '模块A', children: [ { id: 'group-a1', label: '分组A1', children: [ { id: 'page-a1-1', label: '页面A1-1', path: '/a/a1/1' }, { id: 'page-a1-2', label: '页面A1-2', path: '/a/a1/2' } ] } ] } ];第二种是扁平表加 parentId,所有节点放一个数组里,靠父子关系字段关联。扁平表的好处是查找、移动节点方便,适合节点数量大、需要频繁增删改的场景;缺点是渲染前要先做一次树化,多一层转换。
对于三级固定层级的导航,我强烈建议用嵌套数组。原因很实在:层级固定,嵌套深度就是三层,渲染时直接三层循环或者三层组件嵌套,代码一目了然;而且导航数据通常是配置好就不怎么变的,不需要频繁按id查找节点。扁平表那套树化逻辑在这里属于过度设计,反而增加理解成本。我踩过的坑是早期为了“通用”上了扁平表,结果每次改导航都要先想一遍树化函数,维护的人换一个就懵一个。
提示:不管用哪种结构,每个节点一定要有唯一
id。展开状态、选中状态都靠它来索引,用 label 当 key 是灾难,改个文案状态就丢了。
1.3 交互模式的选择逻辑
三级导航的交互模式,我总结成三个维度来决策,做成表格更直观:
| 维度 | 选项A | 选项B | 我的建议 |
|---|---|---|---|
| 触发方式 | 悬停展开 | 点击展开 | 桌面端侧边栏用点击,顶部横向导航用悬停 |
| 展开策略 | 单开(手风琴) | 多开 | 层级深、项多时用单开,减少视觉噪音 |
| 状态持久 | 不记忆 | 记忆到本地 | 后台系统建议记忆,内容站点可不记忆 |
为什么桌面侧边栏我倾向点击而不是悬停?因为三级导航的二级、三级区域通常比较宽,鼠标从一级滑到三级的过程中很容易划过其他一级项,悬停触发会导致菜单频繁切换,体验很糟。点击触发虽然多一次操作,但意图明确、状态稳定。而顶部横向导航因为一级项是横向排列、二级是下拉面板,悬停反而更顺手,这是场景差异。
单开还是多开,本质是视觉信息量的权衡。三级导航全部展开时,屏幕上的条目可能几十个,用户找东西反而更累。单开模式保证同一时间只有一条路径是展开的,视觉焦点集中。但如果你的导航一级项很少(比如三四个),多开也无妨。这个没有标准答案,我一般默认单开,需要时再放开。
2. 核心细节解析与实操要点
2.1 三层结构的语义化标记
结构层面,我坚持用语义化标签打底:nav包住整体,每一级用ul,每个条目用li,可点击的入口用button或a。为什么强调这个?因为导航本质上是“一组链接的集合”,用列表表达语义最准确,屏幕阅读器也能正确识别条目数量和层级关系。
一个容易忽略的点:展开/收起按钮和跳转链接要分开。一级、二级项如果既有子菜单又要能跳转,就得把“展开”和“跳转”拆成两个可点击区域,否则用户点一下不知道是要展开还是要跳走。我的做法是条目右侧放一个独立的展开按钮(带aria-expanded属性),左侧文字区域负责跳转。这样职责清晰,无障碍支持也到位。
<nav class="nav" aria-label="主导航"> <ul class="nav-level-1"> <li class="nav-item"> <div class="nav-item-head"> <a href="/module-a" class="nav-link">模块A</a> <button class="nav-toggle" aria-expanded="false" aria-label="展开模块A"> <span class="arrow"></span> </button> </div> <ul class="nav-level-2"> <!-- 二级、三级同理 --> </ul> </li> </ul> </nav>aria-expanded这个属性别嫌麻烦,它既是无障碍要求,也方便我们用 CSS 属性选择器控制箭头方向,一举两得。实测下来,加上它之后样式和逻辑都更干净。
2.2 展开状态的集中管理
状态管理是三级导航最容易写乱的地方。新手常见写法是给每个li单独绑事件、单独改class,层级一多,状态就散落在 DOM 里,想实现“单开”就得遍历所有节点清状态,代码又臭又长。
我的做法是用一个状态对象集中管理,结构大致是:
const state = { openLevel1: null, // 当前展开的一级 id openLevel2: null // 当前展开的二级 id };因为层级固定三层,其实只需要记录“哪条路径是展开的”就够了——一级展开哪个、它下面二级展开哪个,两个字段足矣。三级是叶子节点,不需要展开状态。这个设计的好处是:单开逻辑变成“设置新值前先清空”,渲染时拿状态和节点 id 比对即可,不用碰 DOM 遍历。
如果是多开模式,就把字段换成Set:
const state = { openLevel1: new Set(), openLevel2: new Set() };切换时add/delete对应 id。用Set而不是数组,是因为判断“是否展开”是高频操作,Set.has是 O(1),数组includes是 O(n),节点多了差距就出来了。这种小优化平时不起眼,但导航项上百个时能感觉到差别。
注意:状态里存 id 而不是存 DOM 引用。存 DOM 引用看似方便,但一旦列表重新渲染(比如数据更新),引用就失效了,状态和视图对不上,这是很隐蔽的 bug。
2.3 样式层面的层级表达
三级导航的视觉层级,靠三样东西区分:缩进、字号、颜色。我的经验是缩进每级递增 16px 到 24px,字号每级递减 1px 到 2px,颜色从一级的深色文字逐级变浅。不要靠加边框或者背景色块来区分层级,那样视觉会很碎。
展开收起的动画,很多人用height过渡,但height: auto没法直接过渡,得先算高度或者用max-height猜一个值。我现在的做法是用grid-template-rows从0fr过渡到1fr,配合overflow: hidden,不需要知道具体高度,动画也顺滑:
.nav-level-2 { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.2s ease; } .nav-item.is-open > .nav-level-2 { grid-template-rows: 1fr; } .nav-level-2 > ul { overflow: hidden; }这个技巧我用了很久,比max-height猜值靠谱得多,不会出现动画到一半突然跳一下的情况。唯一要注意的是内层必须有个包裹元素承担overflow: hidden,直接给grid容器加是不生效的。
3. 实操过程与核心环节实现
3.1 从数据到DOM的渲染实现
渲染这块,我给出一个不依赖任何框架的原生实现,逻辑最透明,换成任何框架都好迁移。核心是三层循环,每层生成对应的ul和li。
function renderNav(data, container) { const rootUl = document.createElement('ul'); rootUl.className = 'nav-level-1'; data.forEach(level1 => { const li1 = document.createElement('li'); li1.className = 'nav-item'; li1.dataset.id = level1.id; li1.appendChild(createHead(level1, 1)); if (level1.children && level1.children.length) { const ul2 = document.createElement('ul'); ul2.className = 'nav-level-2'; level1.children.forEach(level2 => { const li2 = document.createElement('li'); li2.className = 'nav-item'; li2.dataset.id = level2.id; li2.appendChild(createHead(level2, 2)); if (level2.children && level2.children.length) { const ul3 = document.createElement('ul'); ul3.className = 'nav-level-3'; level2.children.forEach(level3 => { const li3 = document.createElement('li'); li3.className = 'nav-leaf'; const a = document.createElement('a'); a.href = level3.path; a.textContent = level3.label; li3.appendChild(a); ul3.appendChild(li3); }); li2.appendChild(ul3); } ul2.appendChild(li2); }); li1.appendChild(ul2); } rootUl.appendChild(li1); }); container.appendChild(rootUl); }createHead负责生成“文字链接 + 展开按钮”的组合,一级二级共用,只是层级参数不同。这种写法看起来有点啰嗦,但胜在直白——三层就是三段,谁来看都能顺着读下去。如果层级不固定,我会改成递归,但三级固定场景下,直白比“优雅”更重要。
3.2 事件委托与状态同步
事件绑定我统一用事件委托,绑在根容器上,而不是给每个按钮单独绑。原因有两个:一是节点多时性能更好,二是动态增删节点不用重新绑事件。判断点击目标是不是展开按钮,用closest往上找:
container.addEventListener('click', (e) => { const toggle = e.target.closest('.nav-toggle'); if (!toggle) return; const li = toggle.closest('.nav-item'); const level = li.parentElement.classList.contains('nav-level-1') ? 1 : 2; const id = li.dataset.id; if (level === 1) { state.openLevel1 = state.openLevel1 === id ? null : id; state.openLevel2 = null; // 切换一级时清空二级 } else { state.openLevel2 = state.openLevel2 === id ? null : id; } syncView(container); });syncView负责把状态映射回 DOM:遍历所有一级、二级节点,根据 id 是否等于状态值来增删is-open类,同时更新aria-expanded。这里有个细节——切换一级时要把二级状态清空,否则会出现“一级A收起、一级B展开,但B下面还残留着上次A的二级展开状态”这种诡异现象。我第一次写的时候就漏了这步,测试时怎么都复现不出来,后来发现是状态没清干净。
3.3 默认展开与路由联动
实际项目里,导航往往要和当前页面联动:用户刷新页面后,应该自动展开到当前页面所在的那条路径。实现思路是拿当前路径去数据里反查,找到对应的三级节点,再回溯出它的一级、二级 id,写进状态。
function findPathByRoute(data, currentPath) { for (const l1 of data) { for (const l2 of l1.children || []) { for (const l3 of l2.children || []) { if (l3.path === currentPath) { return { level1: l1.id, level2: l2.id }; } } } } return null; }三层循环看着笨,但数据量就那么大,性能完全不是问题,可读性还高。找到后把openLevel1、openLevel2设好,再调一次syncView,页面一加载就是展开到当前页的状态。这个功能用户感知很强,属于“做了没人夸、不做有人骂”的典型,建议一开始就加上。
提示:如果路径匹配要考虑查询参数或者尾斜杠,反查前先对
currentPath做一次规范化,否则/a/b和/a/b/匹配不上,展开逻辑就失效了。
4. 常见问题与排查技巧实录
4.1 展开动画卡顿与高度塌陷
动画问题我遇到最多的是两种:一是展开时内容“跳”一下,二是收起后高度没归零导致下方内容错位。前者多半是用了max-height猜值,动画结束前内容已经撑开,视觉上就跳;后者是overflow: hidden加错了位置。用前面说的grid-template-rows方案基本能规避这两类问题。
还有一种情况是动画过程中快速连点,状态和动画打架。我的处理是给过渡加个短时长(0.2s 左右),并在syncView里以状态为准强制同步,不依赖动画结束事件。动画只是视觉修饰,状态才是唯一真相,这个原则能避免大部分时序 bug。
4.2 状态错乱的排查思路
状态错乱的表现通常是:点了A,B也跟着变;或者收起后箭头方向不对。排查我一般按这个顺序走:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 点A影响B | id 重复或选择器范围过大 | 检查数据 id 唯一性,确认closest边界 |
| 箭头方向不对 | aria-expanded没同步 | 检查syncView是否覆盖所有节点 |
| 刷新后状态丢失 | 没做路由反查或没持久化 | 确认初始化逻辑是否执行 |
| 收起后留白 | overflow位置错误 | 检查内层包裹元素 |
id 重复这个问题特别隐蔽,因为数据是配置出来的,两个人各配一段很容易撞 id。我的习惯是在开发环境加一个校验函数,渲染前扫一遍所有 id,有重复就console.warn,早发现早治疗。
4.3 移动端适配的取舍
三级导航在移动端是另一个故事。屏幕窄,三级全部展开会占满整屏,体验很差。我的做法是移动端改成逐级下钻:点一级项进入二级列表页,点二级进入三级列表页,配一个返回按钮。这本质上是把“树形展开”换成了“栈式导航”,交互模型变了,但数据还是同一份。
实现上不需要重写渲染,只需要根据视口宽度切换一套 CSS 加一段交互逻辑。判断用matchMedia,比监听resize干净:
const isMobile = window.matchMedia('(max-width: 768px)').matches;桌面端走展开收起,移动端走下钻,两套逻辑共用同一份数据和渲染函数,维护成本可控。这个方案我在几个项目里都用过,实测下来比强行在移动端做三级展开要稳得多。
4.4 几个容易忽略的实操心得
第一个心得:展开按钮的点击区域要够大。三级导航的箭头图标往往很小,用户点不准就会误触到跳转链接。我的做法是给按钮加内边距,保证点击热区至少 32x32,视觉上图标还是小的,但手感好很多。
第二个心得:键盘操作别落下。导航是高频交互区域,Tab能聚焦、Enter/Space能触发展开,这些是基本要求。用原生button就自动具备这些能力,这也是我坚持用button而不是div的原因之一。
第三个心得:数据里给每个节点留个meta字段。实际项目里导航项经常要挂权限标识、图标、徽标数量这些附加信息,提前留个扩展位,后面加需求不用改结构。这个字段平时不用,但需要时能救命。
第四个心得:别在渲染函数里做业务判断。我见过在渲染时根据用户角色过滤节点的写法,逻辑和视图搅在一起,后来权限规则一变,渲染函数改得面目全非。正确做法是渲染前先把数据过滤好,渲染函数只管画。
三级导航这个题目,说小很小,说大能把状态管理、无障碍、响应式、动画这些前端基本功全串一遍。我自己是写完第一版觉得“就这”,上线后被各种边界情况教育了几轮,才慢慢把上面这些细节补全。现在再写类似的导航,我会先把数据结构和状态模型定下来,渲染和交互反而写得很快——结构对了,剩下的都是体力活。如果你正在做类似的东西,建议先把第1节的数据和状态设计想透,后面能省掉大量返工。