从零手写三级导航:CSS定位、移动端适配与问题排查
2026/9/16 23:50:35 网站建设 项目流程

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 class="main-nav" aria-label="主导航"> <ul class="nav-list"> <li class="nav-item"> <a href="/" class="nav-link">首页</a> </li> <li class="nav-item has-sub"> <a href="/product" class="nav-link">产品中心</a> <ul class="sub-menu"> <li class="sub-item has-sub"> <a href="/product/cloud" class="sub-link">云服务</a> <ul class="sub-menu third"> <li><a href="/product/cloud/host">云主机</a></li> <li><a href="/product/cloud/db">云数据库</a></li> <li><a href="/product/cloud/cdn">内容分发</a></li> </ul> </li> <li class="sub-item"><a href="/product/ai">智能产品</a></li> </ul> </li> <li class="nav-item has-sub"> <a href="/solution" class="nav-link">解决方案</a> <ul class="sub-menu"> <li><a href="/solution/edu">教育行业</a></li> <li><a href="/solution/retail">零售行业</a></li> </ul> </li> </ul> </nav>

上面这段骨架里,has-sub这个类名是个关键约定:只要某个li带了这个类,就说明它里面有子菜单,后续 CSS 就可以针对它做特殊处理,比如给个下三角小箭头。子菜单统一用sub-menu类,三级菜单在二级基础上再叠一个third类,方便单独微调位置。

2.2 语义化属性和无障碍标签,加上去不会亏

纯视觉的三级导航,很多人懒得加无障碍属性。但只要加上那么几个属性,键盘用户和屏幕阅读器用户就能顺畅操作,成本几乎为零。一级触发项建议加aria-haspopup="true",表示"点我会有弹层";展开状态用aria-expanded="true/false"标记,JS 切换的时候顺手改一下就行。导航容器加aria-label,说明这块区域的作用。子菜单的ul可以用role="menu",但要注意,一旦用了菜单角色,键盘的方向键导航就应当被实现,否则反而会给辅助技术造成困惑。

我的建议是分场景:如果只是普通的网站导航,用原生的nav > ul > li > a结构就够了,aria-haspopuparia-expanded加上即可,不必强行套role="menu";如果是应用内那种真正的菜单栏,那再考虑完整的菜单角色和方向键支持。

注意:aria-expanded必须和页面上真实的展开状态保持一致,JS 里展开和收起时都要更新,否则屏幕阅读器会读出错误信息。

2.3 骨架阶段最容易埋的三个坑

第一个坑是层级套错。三级菜单的ul一定要放在对应二级li的内部,而不是放在二级li外面。放外面的话,CSS 里想通过li:hover > ul这种选择器去控制展开就找不到了,定位基准也会乱掉。第二个坑是链接和触发分离不当。如果二级项本身有真实页面要跳转,同时又想展开三级,那就不能用整个li来绑点击,否则用户想点链接却被展开动作拦截。常见做法是给"展开"单独做一个按钮或小箭头元素,链接照常跳转。第三个坑是结构里塞了非法嵌套,比如在ul里直接放div,浏览器解析时会自动纠正,结果 DOM 结构和你写的对不上,样式自然失效。

我个人的习惯是,骨架写完先在浏览器里打开,用开发者工具把嵌套关系从上到下扫一遍,确认每一层的父子关系正确,再开始写 CSS。这一步花两分钟,能省后面半小时的瞎调。

3. CSS 定位与过渡:让二三级菜单稳稳地出现在该出现的地方

3.1 父相子绝:定位基准点的确立

三级导航最核心的一句 CSS 是父相子绝——父级liposition: relative,子级ulposition: absolute。原理不复杂:绝对定位的元素是相对最近的已定位祖先来计算位置的,父级一旦变成relative,子菜单的topleft就以父级为原点,想让它紧贴在父项正下方,只要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

这是新手最容易踩的坑:想给菜单加淡入动画,于是对displaytransition,结果完全没效果。因为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 与堆叠上下文:菜单被盖住的根因

菜单明明显示出来了,却被下面的轮播图、卡片盖住,这是三级导航最高频的视觉问题。根因几乎都是堆叠上下文。当某个祖先元素设置了transformopacity小于 1、filter等属性时,它会创建一个独立的堆叠上下文,子元素的z-index再大也只在那个上下文内部比较,跨不出去。所以你会遇到"我给菜单设了 z-index: 9999 还是被盖住"的情况。

解决办法是找到那个创建了堆叠上下文的祖先,看能不能去掉它的transformopacity之类属性,或者干脆把导航整体提到更高的层级,同时确保它的祖先链上没有别的堆叠上下文与它竞争。我一般的做法是给导航容器设一个较高的z-index,并把它放在页面结构的靠上层,避免和内容区产生层叠冲突。

现象大概率原因处理方向
菜单被下方内容盖住祖先创建了堆叠上下文检查 transform/opacity/filter
z-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补焦点展开逻辑

我把这套导航在几个项目里反复用过,最后沉淀下来的经验其实就一条:把定位、堆叠、触发这三件事拆开排查,问题就会从"玄学"变成"必现"。定位问题看父相子绝,堆叠问题看堆叠上下文,触发问题看断点和事件绑定。每次卡住的时候按这个顺序过一遍,基本十分钟内能定位。菜单本身不复杂,复杂的是各种边界情况互相叠加,而边界情况恰恰是看你把基础理解得牢不牢的地方。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询