
1. 先把手写三级导航这件事想透形态、选型与整体思路三级导航菜单栏说白了就是一级横向铺开、二级向下弹出、三级再往侧面或下方继续展开的那套结构。做后台管理系统的人对它不陌生做企业官网、电商分类页、文档站的人几乎天天要碰。它解决的核心问题很具体当站点的信息架构超过两层横向导航栏根本放不下硬塞会让整行文字挤成一团用户根本扫不动。三级导航的思路是把深度信息折叠起来只在鼠标或手指触达的瞬间展开从而在有限的屏幕宽度里承载几十甚至上百个入口。这篇文章面向的是刚学完 HTML 和 CSS 基础、想拿一个完整案例练手的人也面向那些用组件库用久了、突然被要求手写一套、一时间不知道从哪下手的开发者。我不打算一上来就丢代码而是先把形态、结构和实现路线拆开讲清楚后面再一层层落到可以复制运行的代码上。1.1 三级导航的四种常见形态先认出你要做哪一种在动手写之前得先判断需求属于哪一类因为不同形态的实现重点完全不同。第一种是纯横向三级一级水平排列二级悬停后向下垂直展开三级从二级的右侧继续展开这是最经典的下拉菜单形态很多后台系统左侧栏也是这个逻辑的变体。第二种是二级全宽面板式二级菜单展开时占满整个容器宽度里面再分列排布三级入口这种做法适合电商视觉上很大气但定位计算要复杂一些。第三种是手风琴式纵向三级一级点击展开二级二级点击展开三级常见于移动端和文档目录。第四种是混合式桌面端用悬停横向收缩到移动端自动切成手风琴。实际项目里第四种最多。你先确定自己做哪种后面的 HTML 骨架和 CSS 定位策略才不会白写。从技术难度看纯横向三级的难点在定位的基准点选择和悬停间隙的处理面板式的难点在宽度的动态计算和内容对齐手风琴式的难点在展开动画的高度过渡和状态管理。这三类我都做过踩的坑各不相同。本文以第一种为主线因为它是最典型的三级结构把它的每个细节吃透之后另外几种无非是换个展开方向和触发方式而已核心的定位和状态思路是相通的。1.2 为什么建议先纯手写一遍再考虑用组件库很多人的第一反应是找个现成的导航组件库几行引入就完事。这没错生产环境下我也不排斥。但如果你连三级导航的定位原理都没弄明白遇到样式覆盖、层级错乱、移动端点击穿透这类问题时就只能靠猜。我见过不少人调了一下午最后发现只是父级少了position: relative。纯手写一遍的价值在于你能亲手体会到定位基准、堆叠上下文、悬停事件传播这三个东西是怎么互相影响的。另外手写方案的体积优势很实在。一个成熟的三级导航组件库往往夹带一堆你用不上的功能而一套纯 CSS 的三级导航核心代码可能不到 100 行配合二十几行的 JS 增强就够覆盖绝大多数场景。加载快、可控性强、出问题好排查。我的建议是拿一天时间手写一遍理解透了之后再决定用不用库心里就有底了。1.3 整体实现路线HTML 搭骨架CSS 管展开JS 补交互一套完整的三级导航我通常按三层来分。HTML 负责语义结构用嵌套的无序列表把层级关系表达清楚这是所有样式和脚本的地基。CSS 负责视觉与展开包括定位、过渡动画、悬停状态、以及桌面端的默认展开逻辑。JavaScript 负责增强处理移动端的点击展开、键盘访问、以及一些需要状态记忆的场景比如点击菜单项后保持高亮。这个分工的好处是即使 JS 没加载成功桌面端的基本功能依然可用这叫渐进增强。注意不要把所有逻辑都塞进 JS 里。菜单的展开收起能用 CSS 的悬停或 focus 状态搞定就别用脚本少一层状态就少一类 bug。路线定下来之后接下来就是一步步落地。我会先从 HTML 骨架讲起再讲定位与过渡这些容易翻车的地方然后是移动端和键盘的增强最后把我这些年遇到的典型问题和排查方法整理成一份速查表。整个过程的代码都是可以直接复制运行的最小可复现版本你在本地建一个 HTML 文件就能看到效果。2. HTML 骨架嵌套列表才是三级导航的正解2.1 用 ul 嵌套表达层级语义和可维护性都在线三级导航的 HTML 骨架标准做法就是三层嵌套的无序列表。一级用nav包一个ul每个一级li里如果还有下级就再套一个ul二级里有下级就再套一层。这样写的好处是层级关系一眼可见浏览器默认的无序列表语义也能帮助屏幕阅读器理解这里有子菜单。有人喜欢用一堆 div 堆出来能跑但结构和层级完全靠 class 名记忆维护起来很痛苦。nav classmain-nav aria-label主导航 ul classnav-list li classnav-item a href/ classnav-link首页/a /li li classnav-item has-sub a href/product classnav-link产品中心/a ul classsub-menu li classsub-item has-sub a href/product/cloud classsub-link云服务/a ul classsub-menu third lia href/product/cloud/host云主机/a/li lia href/product/cloud/db云数据库/a/li lia href/product/cloud/cdn内容分发/a/li /ul /li li classsub-itema href/product/ai智能产品/a/li /ul /li li classnav-item has-sub a href/solution classnav-link解决方案/a ul classsub-menu lia href/solution/edu教育行业/a/li lia href/solution/retail零售行业/a/li /ul /li /ul /nav上面这段骨架里has-sub这个类名是个关键约定只要某个li带了这个类就说明它里面有子菜单后续 CSS 就可以针对它做特殊处理比如给个下三角小箭头。子菜单统一用sub-menu类三级菜单在二级基础上再叠一个third类方便单独微调位置。2.2 语义化属性和无障碍标签加上去不会亏纯视觉的三级导航很多人懒得加无障碍属性。但只要加上那么几个属性键盘用户和屏幕阅读器用户就能顺畅操作成本几乎为零。一级触发项建议加aria-haspopuptrue表示点我会有弹层展开状态用aria-expandedtrue/false标记JS 切换的时候顺手改一下就行。导航容器加aria-label说明这块区域的作用。子菜单的ul可以用rolemenu但要注意一旦用了菜单角色键盘的方向键导航就应当被实现否则反而会给辅助技术造成困惑。我的建议是分场景如果只是普通的网站导航用原生的nav ul li a结构就够了aria-haspopup和aria-expanded加上即可不必强行套rolemenu如果是应用内那种真正的菜单栏那再考虑完整的菜单角色和方向键支持。注意aria-expanded必须和页面上真实的展开状态保持一致JS 里展开和收起时都要更新否则屏幕阅读器会读出错误信息。2.3 骨架阶段最容易埋的三个坑第一个坑是层级套错。三级菜单的ul一定要放在对应二级li的内部而不是放在二级li外面。放外面的话CSS 里想通过li:hover ul这种选择器去控制展开就找不到了定位基准也会乱掉。第二个坑是链接和触发分离不当。如果二级项本身有真实页面要跳转同时又想展开三级那就不能用整个li来绑点击否则用户想点链接却被展开动作拦截。常见做法是给展开单独做一个按钮或小箭头元素链接照常跳转。第三个坑是结构里塞了非法嵌套比如在ul里直接放div浏览器解析时会自动纠正结果 DOM 结构和你写的对不上样式自然失效。我个人的习惯是骨架写完先在浏览器里打开用开发者工具把嵌套关系从上到下扫一遍确认每一层的父子关系正确再开始写 CSS。这一步花两分钟能省后面半小时的瞎调。3. CSS 定位与过渡让二三级菜单稳稳地出现在该出现的地方3.1 父相子绝定位基准点的确立三级导航最核心的一句 CSS 是父相子绝——父级li设position: relative子级ul设position: absolute。原理不复杂绝对定位的元素是相对最近的已定位祖先来计算位置的父级一旦变成relative子菜单的top、left就以父级为原点想让它紧贴在父项正下方只要top: 100%; left: 0;就行。.main-nav { background: #1f2430; } .nav-list { display: flex; list-style: none; margin: 0; padding: 0; } .nav-item { position: relative; } .nav-link { display: block; padding: 14px 20px; color: #e6e9ef; text-decoration: none; white-space: nowrap; } .sub-menu { position: absolute; top: 100%; left: 0; min-width: 180px; background: #2a3040; list-style: none; margin: 0; padding: 6px 0; opacity: 0; visibility: hidden; transform: translateY(6px); transition: opacity .18s ease, transform .18s ease, visibility .18s; box-shadow: 0 8px 20px rgba(0,0,0,.25); }这里我故意没用display: none来做隐藏原因在下一节说。min-width给个下限防止二级菜单因为文字太少而窄得难看。white-space: nowrap防止菜单项文字换行导致宽度忽大忽小。三级菜单的定位要换基准。它相对的是二级的li所以.sub-item也得设position: relative然后三级菜单的定位方向改成从右侧弹出top: 0; left: 100%;。.sub-item { position: relative; } .sub-menu.third { top: 0; left: 100%; transform: translateX(6px); }3.2 用 opacity 加 visibility 做过渡别用 display这是新手最容易踩的坑想给菜单加淡入动画于是对display做transition结果完全没效果。因为display是离散属性它只有显示和不显示两个状态没有中间过程浏览器根本没法补间。正确的组合是opacity管透明度、visibility管是否可交互、再加一点点transform做位移三者一起过渡视觉上就是丝滑的淡入。为什么加visibility而不是只用opacity: 0因为只设透明度的话元素虽然看不见但仍然在那里鼠标划过时依然会触发悬停还可能挡住下面内容的点击。visibility: hidden能让它彻底不参与交互同时它又是可以过渡的属性动画结束时才真正隐藏两全其美。.has-sub:hover .sub-menu, .has-sub:focus-within .sub-menu { opacity: 1; visibility: visible; transform: translateY(0) translateX(0); }这里我顺手用了:focus-within这样键盘 Tab 到菜单项时子菜单也会展开一举两得后面讲无障碍时还会提到。3.3 二级向右展还是向下展取决于空间三级菜单展开方向的选择本质上是空间预算问题。如果一级菜单靠页面右侧二级再往右展开三级就很容易超出视口用户得横向滚动才能看到体验很差。这种情况可以给三级改成向下展开或者做一个翻面处理让最右侧的一二级菜单向左展开。实际项目里我通常给菜单容器加一个overflow: visible然后分两种情况处理左侧区域正常向右展开右侧区域通过加一个类名手动切到向左。.sub-menu.flip { left: auto; right: 0; } .sub-menu.third.flip { left: auto; right: 100%; transform: translateX(-6px); }判断该不该翻面可以纯 CSS 做不了得靠 JS 测量菜单位置和视口宽度。我一般写一小段脚本在菜单展开时用getBoundingClientRect()拿到右边界超过视口就加flip类。这个逻辑不复杂但能显著提升右侧菜单的可用性。3.4 z-index 与堆叠上下文菜单被盖住的根因菜单明明显示出来了却被下面的轮播图、卡片盖住这是三级导航最高频的视觉问题。根因几乎都是堆叠上下文。当某个祖先元素设置了transform、opacity小于 1、filter等属性时它会创建一个独立的堆叠上下文子元素的z-index再大也只在那个上下文内部比较跨不出去。所以你会遇到我给菜单设了 z-index: 9999 还是被盖住的情况。解决办法是找到那个创建了堆叠上下文的祖先看能不能去掉它的transform、opacity之类属性或者干脆把导航整体提到更高的层级同时确保它的祖先链上没有别的堆叠上下文与它竞争。我一般的做法是给导航容器设一个较高的z-index并把它放在页面结构的靠上层避免和内容区产生层叠冲突。现象大概率原因处理方向菜单被下方内容盖住祖先创建了堆叠上下文检查 transform/opacity/filterz-index 调大无效处于不同堆叠上下文提升导航整体层级菜单只在部分区域被盖兄弟元素 z-index 更高统一层级规划提示不要盲目堆 z-index。先理清堆叠上下文的边界比无脑加数字有效得多。4. 从纯 CSS 到 JS 增强移动端与键盘的那点事4.1 移动端悬停失效点击展开要接管桌面端的悬停逻辑搬到移动端就废了触屏没有悬停这个稳定状态第一次点往往被解释成悬停菜单闪一下又收起。所以移动端必须切换到点击展开。我的处理方式是用媒体查询做分界宽屏走悬停窄屏走点击触发逻辑交给一小段 JS 控制aria-expanded和类名。media (max-width: 900px) { .nav-list { flex-direction: column; } .sub-menu, .sub-menu.third { position: static; opacity: 1; visibility: visible; transform: none; box-shadow: none; display: none; } .has-sub.open .sub-menu { display: block; } }窄屏下我把子菜单改成静态定位让它撑开父级高度形成纵向手风琴这样就不用再算绝对定位的坐标省心很多。展开状态用open类控制点击一级或者二级触发项时切换。4.2 键盘能跑到的地方交互才算完整有一类用户体验经常被忽略只用键盘操作的人。他们靠 Tab 在链接间移动Enter 触发。纯 CSS 方案里如果用:hover展开键盘用户根本看不到子菜单。这就是我在第 3 节里用:focus-within的原因——只要焦点落在某个带子菜单的项里子菜单就会展开Tab 继续往里走就能访问到三级菜单项。再补一个细节按 Escape 键应当收起当前展开的菜单并且把焦点还给触发项。这个纯 CSS 做不到需要 JS 监听keydown。逻辑很简单但加上之后整套导航对键盘用户就友好了。4.3 一段可复用的增强脚本下面这段脚本做了三件事移动端点击展开收起、同步aria-expanded、Escape 收起并归还焦点。代码不长可以直接拿来用。(function () { const nav document.querySelector(.main-nav); const items nav.querySelectorAll(.has-sub); const mq window.matchMedia((max-width: 900px)); function setState(li, open) { li.classList.toggle(open, open); const link li.querySelector(:scope .nav-link, :scope .sub-link); if (link) link.setAttribute(aria-expanded, String(open)); } items.forEach(function (li) { const link li.querySelector(:scope .nav-link, :scope .sub-link); if (!link) return; link.setAttribute(aria-haspopup, true); link.setAttribute(aria-expanded, false); link.addEventListener(click, function (e) { if (!mq.matches) return; // 桌面端交给 CSS 悬停 e.preventDefault(); // 移动端先展开不跳转 const isOpen li.classList.contains(open); // 收起兄弟节点保持单开 items.forEach(function (other) { if (other ! li) setState(other, false); }); setState(li, !isOpen); }); }); document.addEventListener(keydown, function (e) { if (e.key ! Escape) return; const openLi nav.querySelector(.has-sub.open); if (!openLi) return; setState(openLi, false); const link openLi.querySelector(:scope .nav-link, :scope .sub-link); if (link) link.focus(); }); })();这段脚本里我特意做了单开处理展开一个菜单时自动收起别的避免移动端同时展开一堆把页面撑得很长。另外用:scope 选择器限定只找直接子级的触发链接防止三级里同名的元素被误选。这些细节不写清楚后面调试会很难受。5. 常见问题与排查技巧实录5.1 菜单一闪就没鼠标移不过去这是三级导航最经典的毛病。原因通常是父项和子菜单之间存在视觉间隙鼠标从父项往下移动的途中短暂离开了悬停区域触发收起。纯 CSS 里top: 100%紧贴父项底部理论上没间隙但如果父项有margin或者子菜单有translateY位移还没到位就会出现缝隙。解决办法有两个一是给子菜单加一个不可见的缓冲带比如给子菜单顶部加一层透明的padding或者用伪元素往上撑 8 像素二是把展开动画的translateY起点设小一点比如 4 像素别太大。.sub-menu::before { content: ; position: absolute; top: -10px; left: 0; width: 100%; height: 10px; background: transparent; }这个伪元素把父项和菜单之间的死区填满鼠标经过时悬停不会中断问题基本就消失了。5.2 菜单宽度忽宽忽窄文字换行菜单项文字长度不一如果没给white-space: nowrap短文字那项可能刚好卡在容器边缘被迫换行菜单宽度就会跳。另一个原因是min-width和内容宽度之间的博弈。我的经验是给子菜单一个合适的min-width同时所有菜单项加nowrap让它按最长的一行撑开。如果最长的那行实在太长可以考虑截断加省略号但导航里一般不建议截断用户看不出完整名称反而更糟。5.3 移动端点了没反应或者点了直接跳走点了直接跳走多半是点击事件绑在了li上但链接的默认跳转没被拦截或者脚本根本没执行。先检查控制台有没有报错再看matchMedia的断点值和你 CSS 里的媒体查询是否一致。两边一个写 900、一个写 768就会出现 CSS 已经切成手风琴、JS 还以为自己在桌面端的尴尬情况。我的习惯是把断点值抽成一个常量CSS 用变量、JS 用同一个数字改成一致。5.4 排查速查表问题表现排查顺序常见解法子菜单不显示定位祖先是否 relative补 position: relative淡入无动画是否用了 display 过渡改 opacity visibility菜单被盖住祖先堆叠上下文调整 z-index 与层级结构悬停闪退父子间隙加伪元素缓冲带移动端无反应断点与事件绑定统一断点拦截默认跳转三级向右溢出未做翻面JS 测量后加 flip 类键盘访问不到未用 focus-within补焦点展开逻辑我把这套导航在几个项目里反复用过最后沉淀下来的经验其实就一条把定位、堆叠、触发这三件事拆开排查问题就会从玄学变成必现。定位问题看父相子绝堆叠问题看堆叠上下文触发问题看断点和事件绑定。每次卡住的时候按这个顺序过一遍基本十分钟内能定位。菜单本身不复杂复杂的是各种边界情况互相叠加而边界情况恰恰是看你把基础理解得牢不牢的地方。