1. 三级菜单真正的难点,从来不是"多套一层ul"
我第一次被三级导航菜单绊住,是在给一个后台管理项目改版的时候。当时需求方甩过来一张截图,说"就是把现在的二级菜单再往下拆一层,很简单吧"。我心想,无非就是在一个<ul>里再塞一个<ul>,position: absolute一挂,hover 一开,半小时的事。结果这一层多出来之后,我前后返工了四遍:鼠标从一级斜着划到三级的时候菜单自己收起来了,二级面板被后面那个卡片区域的z-index盖住了半截,触屏笔记本上点一次没反应、点两次才展开,还有键盘用户压根就进不去第三级。
后来我把这套东西沉淀成了一个内部复用组件,才发现三级导航菜单栏这个看起来像练习题的需求,实际上是HTML 结构语义、CSS 层叠与定位、指针事件时序、无障碍访问四件事的交汇点。它不像轮播图那样有现成的库可以抄,也不像表单校验那样有一堆成熟方案,很多时候你搜到的教程只给你hover展开的效果,跑起来能用,一放进真实项目就各种别扭。
所以这篇我打算按真实项目的推进顺序来讲:先想清楚信息架构和标签结构,再用纯 CSS 把三层撑起来,然后老老实实承认纯 CSS 方案在触屏和键盘上的短板,补一段克制的 JavaScript,接着处理窄屏下的另一套交互形态,最后把我排查过的几类故障完整复现一遍。文章里的代码都是可以直接复制跑起来的,我会尽量把"为什么这么写"讲透,而不是只丢一段 CSS 给你。
适合谁看?如果你写过二级下拉菜单、但对三级之后的空间关系和事件时序没什么把握,这篇能帮你少走弯路;如果你已经能写出来、只是做出来的东西总有点小毛病,那第 6 章和第 7 章的排查链路应该对你更有用。
2. 动手写标签之前,先把"多一层"的空间关系想明白
2.1 横向下拉与右侧飞出,是两种不同的空间模型
二级菜单的展开方向基本是唯一的:从父项正下方垂直展开。但到了第三级,方向就出现了分叉,而选哪个方向直接决定了后面 CSS 的写法。
第一种是纵向堆叠式,第三级继续往下排在第二级的下方,靠缩进和背景色区分层级。这种写法结构上最简单,所有层级都是top: 100%,但问题也很明显:面板会越来越长,鼠标要移动的纵向距离越来越远,一旦中间哪个元素的padding没算准,指针就会掉出悬停区。
第二种是右侧飞出式,第三级从第二级菜单项的右侧水平展开,也就是left: 100%; top: 0。这是桌面端最主流的做法,视觉层次清楚,鼠标移动路径短,但代价是面板宽度会横向累加——一级 200px、二级 200px、三级 200px,三级菜单的右边界可能已经顶到 600px 开外,在小屏笔记本上要小心溢出。
我自己的判断标准很简单:一级项不超过 6 个、且二级项文字长度参差不齐的时候,用右侧飞出;如果是后台那种二级项本身就很多的场景,反而适合纵向堆叠加缩进,因为用户视线是一直往下的,横向跳跃会打断阅读节奏。
这里有个容易被忽略的细节:右侧飞出时,第三级的top一定要归零,因为position: absolute的定位基准是最近的position: relative祖先。如果你给二级菜单项设了position: relative,那么第三级的top: 0就是贴着这个二级项顶部对齐的;但如果你一偷懒把relative加在了整个二级<ul>上,第三级就会相对整个二级面板定位,top: 0会让它跑到二级面板的最顶部去。这个坑我在第 7 章还会再展开讲。
2.2 语义化标签的取舍:什么时候该用 div
关于用<ul><li>还是清一色的<div>,争论一直没停过。我的立场是导航就是一组链接的集合,天然适合用列表,理由有三个。
第一,屏幕阅读器会把列表读成"列表,共 N 项",用户能提前知道这里有多少个选项,这是<div>给不了的信息。第二,<li>天然是块级盒子,li下面的ul嵌套在语义上就是子级,结构自解释,不需要靠类名去猜。第三,也是最实际的——当你需要做递归渲染的时候,列表结构的父子关系和你菜单数据的children字段是完全对应的,写递归函数非常顺手。
但也不是所有东西都往<ul>里塞。整个导航最外层我用<nav>包起来,并且给它加aria-label,因为一个页面可能有主导航、面包屑导航、页脚导航好几个<nav>,不给标签的话屏幕阅读器用户分不清谁是谁。二级、三级的面板本身不再包<nav>,它们只是同一套导航的一部分。
<nav class="nav" aria-label="主导航"> <ul class="nav__list"> <li class="nav__item has-sub"> <a class="nav__link" href="#" aria-haspopup="true" aria-expanded="false">产品中心</a> <ul class="nav__sub"> <li class="nav__sub-item has-sub"> <a class="nav__sub-link" href="#" aria-haspopup="true" aria-expanded="false">云端服务</a> <ul class="nav__sub nav__sub--third"> <li><a class="nav__sub-link" href="#">弹性计算</a></li> <li><a class="nav__sub-link" href="#">对象存储</a></li> <li><a class="nav__sub-link" href="#">内容分发</a></li> </ul> </li> <li class="nav__sub-item"><a class="nav__sub-link" href="#">技术支持</a></li> </ul> </li> <li class="nav__item"><a class="nav__link" href="#">解决方案</a></li> </ul> </nav>aria-haspopup="true"告诉辅助设备"这个链接点开会弹出东西",aria-expanded则负责表达当前是开还是关,它需要 JS 来同步,后面会讲。这两个属性是很多人写导航时最容易漏掉的,加上去成本几乎为零,但对键盘和读屏用户来说是可用与不可用的差别。
2.3 类名命名:给三级留出扩展位
命名上我用的是 BEM 变体的思路,但做了一点简化。.nav__sub这个类同时用在二级和三级面板上,因为它们共享同一套视觉规则(白底、圆角、阴影、内边距),只在定位方式上有差异,差异部分用.nav__sub--third覆盖。
为什么不给二级和三级起两套完全独立的类名?因为那样你会写两遍几乎一样的样式,改一个阴影值要改两处,早晚会不一致。共用一个基础类、用修饰类处理差异,是我踩过几次样式不同步的坑之后固定下来的做法。
有一点要提醒:.has-sub这个标记类是挂在<li>上的,不是挂在<a>上。原因是悬停的触发区域应该包含<a>和它旁边可能存在的箭头图标,而且子面板是<li>的子元素,把状态类放在<li>上,CSS 里才能用.has-sub:hover > .nav__sub这种直接子选择器精准命中,避免影响到嵌套更深的层级。用.has-sub:hover .nav__sub不加>的话,鼠标停在第一级时,二级和三级的面板会全部被点亮,这个错误非常常见。
3. 纯 CSS 把三层撑起来:定位、缝隙与层叠顺序
3.1 relative 与 absolute 的接力,一层只能接一次
CSS 部分先建立基础布局。一级用 flex 横排,二级从父项底部展开,三级从二级项右侧展开。
.nav__list, .nav__sub { list-style: none; margin: 0; padding: 0; } .nav__list { display: flex; gap: 4px; } .nav__item { position: relative; } .nav__sub-item { position: relative; } .nav__sub { position: absolute; top: 100%; left: 0; min-width: 180px; background: #fff; border-radius: 8px; box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12); padding: 8px 0; opacity: 0; visibility: hidden; transform: translateY(6px); transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s; } .nav__sub--third { top: 0; left: 100%; transform: translateX(6px); } .has-sub:hover > .nav__sub, .has-sub:focus-within > .nav__sub { opacity: 1; visibility: visible; transform: translate(0, 0); }这段代码里最关键的两行是.nav__item和.nav__sub-item上的position: relative。它们各自负责一次"接力":一级项给二级面板提供定位基准,二级项给三级面板提供定位基准。每一层容器只能接一次力,多接一次就会乱。我见过有人为了省事,在所有li上统一写position: relative,结果三级面板的定位基准变成了最近的那个二级项——虽然碰巧也对了,但一旦某个二级项没有子菜单、层级错位,问题就会突然冒出来,排查起来很费劲。
用opacity + visibility做显隐而不是display: none,是因为display不参与过渡动画,切换时是硬生生的闪现,没有淡入效果。visibility的好处是它虽然不可见,但仍然可以参与 transition,并且在隐藏状态下会阻止元素接收鼠标事件,比单纯用opacity: 0(元素还在那里、还能被点到)要干净。transform: translateY(6px)那一点点位移是让面板"落下来"的感觉更自然,纯淡入会显得有点呆。
3.2 那道要命的缝隙:鼠标斜着移动为什么菜单会收起来
这是三级菜单最经典的坑,没有之一。现象是:鼠标从一级项往下滑向二级菜单,走到两者交界的那一瞬间,菜单突然收起来了,得重新从一级项开始划。
根因在于,一级<a>和二级<ul>之间如果存在任何非悬停区域,指针就会短暂地离开.has-sub这个悬停容器,hover状态中断,CSS 立刻把子面板隐藏了。这个"非悬停区域"最常见的来源就是margin-top——很多人喜欢用margin-top: 8px让菜单和父项之间留点空隙,好看是好看,但那 8px 就是悬停的断点。
解决方式有三种,我按推荐度排:
第一种,把空隙改成 padding。给.nav__sub设padding-top: 8px,同时在视觉上把面板的背景起始位置上移——也就是让面板的 padding 区域成为悬停区的一部分。但这样面板的圆角和阴影会包住那 8px,视觉上不干净。稳妥的写法是给子面板外面再套一层透明的定位容器,容器负责撑开悬停区,内层负责视觉。
第二种,用::before伪元素搭一座透明桥:
.nav__item.has-sub > .nav__sub::before { content: ""; position: absolute; left: 0; right: 0; top: -10px; height: 10px; }这块区域透明不可见,但它属于子面板的一部分,指针经过时依然在.nav__sub内,hover不会断。这是改动最小、副作用最少的方案,我现在基本都用这个。
第三种,加过渡延迟。给隐藏状态加transition-delay,让鼠标离开后过 200ms 才真正隐藏,给用户一个"划过去"的容错窗口。这个方案手感很顺,但要注意它只是缓解,如果用户移动速度慢下来停在缝隙里,菜单还是会收,所以它更适合作为前两种方案的补充。
3.3 z-index 不是越大越好,先看层叠上下文
三级菜单的另一个高频问题是"面板被下面的内容盖住了"。第一反应通常是去调z-index,从 999 一路加到 99999,加完发现还是被盖。这时候问题基本可以确定:父级创建了新的层叠上下文,你的子元素 z-index 再大也跳不出这个上下文。
会创建层叠上下文的情况不少,和导航菜单最相关的几个是:元素设了transform且值不为none、opacity小于 1、filter不为none、will-change指定了会触发合成的属性、以及position: fixed。也就是说,如果你给导航外层的某个容器加了transform: translateZ(0)做性能优化,导航的z-index就被锁死在这层容器里了。
我的处理套路是:先给导航根元素一个明确的position: relative; z-index: 100,确保它整体高于主内容区;然后在导航内部,各级面板的 z-index 按层级递进,二级 10、三级 20。不要在内部疯狂加数值,内部数值只需要保证"后展开的盖住先展开的"就够了。
.nav { position: relative; z-index: 100; } .nav__sub { z-index: 10; } .nav__sub--third { z-index: 20; }另外提醒一句,z-index只对定位元素生效(position为relative、absolute、fixed、sticky),给一个position: static的元素写z-index是完全无效的。这个基础点我见过不止一个工作两三年的人会忘。
4. 纯 CSS 方案在触屏和键盘上撞到的三道墙
4.1 触屏设备上,hover 的语义已经变了
纯 CSS 的 hover 方案在桌面鼠标上体验很好,但一放到触屏上就露馅。移动端浏览器为了让那些只为鼠标设计的网站能用,发明了一套"hover 模拟"机制:第一次点击元素时,如果它有:hover样式,浏览器会先应用 hover 状态而不触发链接跳转;第二次点击才真正执行跳转。
这个机制放在三级菜单上会产生两个后果。一是用户点一级项,二级菜单展开了,感觉正常;但用户接着点二级项想展开三级,浏览器可能把它理解成"点击链接"直接跳走了,因为二级项本身是<a href="#">。二是菜单展开后不会自动收起,用户点完一级看到菜单出来了,再点旁边空白处,菜单还挂在那里,得再点一次一级项才收。
有人尝试用媒体查询绕开:在触屏设备上禁用 hover 展开,改成点击展开。
@media (hover: hover) and (pointer: fine) { .has-sub:hover > .nav__sub { opacity: 1; visibility: visible; } }@media (hover: hover)用来判断设备是否支持真正的悬停,@media (pointer: fine)判断是不是精确指针(鼠标、触控笔)。这两个媒体特性在主流浏览器上的支持度已经足够好,用它们把 hover 规则包起来,触屏设备就不会误触发 hover 了。但这样一来,触屏上必须靠 JS 点击展开,绕了一圈还是得写 JS。
4.2 focus-within 能救键盘,但救不全
:focus-within是个很好用的选择器,当元素自身或它的任何后代获得焦点时匹配。把它加进展开条件里,键盘用户按 Tab 键就能一级一级往下走,走到哪里哪一级展开,这一点是它最大的价值。
.has-sub:focus-within > .nav__sub { opacity: 1; visibility: visible; }但它有个明显的局限:按 Tab 是线性遍历。三级菜单有 3 个选项,用户想从第一级跳到第三级,得按好几次 Tab;跳到下一个一级项时,还得把当前展开的所有子项都 Tab 一遍才能出去,焦点顺序冗长且不可控。真正的无障碍导航需要方向键支持:右箭头展开并进入子项,左箭头回到父项,Esc 关闭整个菜单。这些行为focus-within给不了,只能靠 JS 接管键盘事件。
还有个副作用要注意::focus-within的匹配范围会随着焦点移动扩大。如果一级项获得焦点,整个子树都处于"包含焦点"的状态,这时候三级面板也会被点亮。如果你只想让当前层级展开,需要额外用:not()或者更精确的选择器限定,比如只对直接触发的那一层生效。
4.3 什么时候该果断放弃纯 CSS
我给自己的判断标准是三条,只要中了一条就用 JS:
- 需要在点击时保持展开状态,而不是靠悬停维持(触屏、平板、触控笔记本都算);
- 需要方向键、Esc 这类键盘交互;
- 需要根据屏幕宽度切换成手风琴模式。
反过来,如果需求就是一个桌面端后台、用户都是鼠标操作、不需要无障碍审计,那纯 CSS 方案确实更快更省事,硬塞 JS 反而是过度设计。技术选型不用追求"最强",够用且好维护才是对的。
5. 补一段克制的 JavaScript:事件委托与状态管理
5.1 用 mouseover 做委托,别在几十个节点上绑监听
最简单的写法是遍历所有.has-sub,逐个addEventListener('mouseenter', ...)。三五个菜单项的时候没问题,但菜单数据是从后端配置来的、二级三级加起来几十上百个项时,绑定几百个监听器就有点浪费了。而且如果菜单是动态渲染的,新增的节点还得重新绑一遍。
用事件委托可以一次搞定。mouseover和mouseenter的区别要搞清楚:mouseover会冒泡,mouseenter不会。要做委托就必须用会冒泡的事件,也就是mouseover和mouseout。
const nav = document.querySelector('.nav'); let openTimer = null; nav.addEventListener('mouseover', (e) => { const li = e.target.closest('li.has-sub'); if (!li || !nav.contains(li)) return; clearTimeout(openTimer); openTimer = setTimeout(() => { li.classList.add('is-open'); li.querySelector(':scope > a') ?.setAttribute('aria-expanded', 'true'); }, 120); }); nav.addEventListener('mouseout', (e) => { const li = e.target.closest('li.has-sub'); if (!li) return; // 指针仍在同一个 li 内部时不处理 if (li.contains(e.relatedTarget)) return; clearTimeout(openTimer); li.classList.remove('is-open'); li.querySelector(':scope > a') ?.setAttribute('aria-expanded', 'false'); });这段代码里有三个关键点值得说。
第一,e.target.closest('li.has-sub')是从实际触发事件的最深层元素往上找最近的父级菜单项,这样不管鼠标停在哪一层,都能找到对应应该展开的<li>,不用写死层级。
第二,li.contains(e.relatedTarget)这个判断是必须的。mouseout在指针从<a>移到同一个<li>内的子菜单时会触发(因为<a>和子菜单是兄弟节点),如果不做判断,鼠标一往子菜单挪就会触发关闭。relatedTarget是mouseout事件里表示"指针要去哪"的属性,用它判断是否还在当前项内部,是这类菜单的标准写法。
第三,那 120ms 的延迟不是随便定的。太短起不到防误触的作用,用户手一抖划过一级项,菜单哗地开了又关,很晃眼;太长又会觉得迟钝、跟不上手。100 到 150ms 之间是比较舒服的区间,我做过多轮测试,120ms 是手感和稳定性的平衡点。这段延迟只加在打开上,关闭不加,因为关闭慢半拍会让人感觉菜单"粘"在屏幕上。
5.2 点击模式下,展开状态和 aria-expanded 必须同步
触屏或者点击模式下,逻辑要换成 toggle,并且必须同步aria-expanded。同时要注意一点:点击展开菜单时不能让链接跳走,所以如果是<a href="#">,要preventDefault。
nav.addEventListener('click', (e) => { const link = e.target.closest('a'); if (!link) return; const li = link.parentElement; if (!li.classList.contains('has-sub')) return; // 触屏/小屏下拦截跳转,改为展开收起 if (!window.matchMedia('(hover: hover)').matches) { e.preventDefault(); const willOpen = !li.classList.contains('is-open'); // 关闭同级已展开的项,保持同一时间只开一个 li.parentElement .querySelectorAll(':scope > .has-sub.is-open') .forEach((sib) => { sib.classList.remove('is-open'); sib.querySelector(':scope > a') ?.setAttribute('aria-expanded', 'false'); }); li.classList.toggle('is-open', willOpen); link.setAttribute('aria-expanded', String(willOpen)); } });aria-expanded这个属性看着不起眼,但它是读屏用户判断"这个东西能不能展开"的唯一依据。手动切is-open类却忘了同步属性,视觉上完全正常,可读屏用户听到的永远是"折叠",点了也没反应,这种 bug 在视觉测试里根本发现不了。我的习惯是把类名切换和属性同步写在同一段逻辑里,绝不分开。
还有一点:同一时间只展开一个同级菜单,这个策略在触屏上尤其重要。手机屏幕窄,同时展开两个面板会挤成一团,用户根本不知道自己点的是哪个。桌面端悬停时倒是可以允许多个同时开着,因为鼠标位置本身就是明确指示。
5.3 键盘方向键,几行代码换来完全不同的可用性
键盘支持我实现的是最基础的一套:右箭头进入子菜单,左箭头返回父菜单,上下箭头在同级之间移动,Esc 关闭并回到一级项。核心是维护一个"当前聚焦的链接"的概念,然后按li的层级关系去找下一个目标。
nav.addEventListener('keydown', (e) => { const link = e.target.closest('a'); if (!link) return; const li = link.parentElement; switch (e.key) { case 'ArrowDown': { const next = li.nextElementSibling; if (!next) return; e.preventDefault(); (next.querySelector('a'))?.focus(); break; } case 'ArrowUp': { const prev = li.previousElementSibling; if (!prev) return; e.preventDefault(); (prev.querySelector('a'))?.focus(); break; } case 'ArrowRight': { if (!li.classList.contains('has-sub')) return; e.preventDefault(); li.classList.add('is-open'); link.setAttribute('aria-expanded', 'true'); (li.querySelector(':scope > .nav__sub a'))?.focus(); break; } case 'ArrowLeft': { const parentLi = li.closest('ul')?.closest('li'); if (!parentLi) return; e.preventDefault(); parentLi.classList.remove('is-open'); parentLi.querySelector(':scope > a') ?.setAttribute('aria-expanded', 'false'); parentLi.querySelector(':scope > a')?.focus(); break; } case 'Escape': { nav.querySelectorAll('.has-sub.is-open').forEach((openLi) => { openLi.classList.remove('is-open'); openLi.querySelector(':scope > a') ?.setAttribute('aria-expanded', 'false'); }); nav.querySelector('.nav__link')?.focus(); break; } } });li.closest('ul')?.closest('li')这一句是往上一层找父级菜单项的写法:先找包含当前li的那个<ul>,再找这个<ul>所属的<li>,正好就是上一层的菜单项。用closest而不是手写层级遍历,好处是无论菜单嵌套几层都能正确工作,将来加第四级都不用改。注意这里写的选择器是:scope > a,是为了只命中直接子级的链接,不会误选到更深层的后代链接,这个细节在多层菜单里非常重要。
6. 窄屏下换一套形态:手风琴的展开动画怎么做得不卡
6.1 断点之前先想清楚:是复用 DOM 还是另起一套
窄屏下三级菜单基本都是切成手风琴或者抽屉。这里有个架构决策:是同一份 DOM 用媒体查询改样式,还是移动端渲染另一套结构?
我推荐复用同一份 DOM。理由是维护成本。如果另写一套移动端结构,菜单数据的来源、后端配置的变化、链接地址的调整,全都要同步两个地方,只要有一个人漏改了一个,就会出现"PC 上有的菜单手机上没显示"这种问题,而且往往上线后才发现。
复用的做法是:桌面端每个层级用绝对定位浮层,窄屏下全部改成静态流布局,把浮层压平,变成纵向堆叠。
@media (max-width: 768px) { .nav__list { flex-direction: column; } .nav__sub { position: static; opacity: 1; visibility: visible; transform: none; box-shadow: none; display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.25s ease; padding: 0; } .nav__sub > li { overflow: hidden; } .has-sub.is-open > .nav__sub { grid-template-rows: 1fr; } .nav__sub--third { left: auto; top: auto; } }桌面端那套opacity + visibility + transform的规则在窄屏下必须显式重置成静态可见,否则子面板还是会浮着。这里我把position改回static是关键一步——它让面板回到正常文档流,跟着父项一起被撑开。
6.2 grid-template-rows 的 0fr 到 1fr,比 max-height 靠谱
移动端手风琴最头疼的是展开动画。display: none到display: block没有过渡,max-height方案要硬编码一个猜测的最大高度(写小了内容被截断,写大了动画先慢后快,节奏很怪),都谈不上理想。
grid-template-rows从0fr过渡到1fr这个技巧,是近几年我找到的最干净的方案。原理是:把子面板设成单行网格,行高用分数单位控制,0fr时行高为 0,内容被隐藏;1fr时行高自适应内容高度。因为fr是可以在过渡中插值的,所以动画能自然地从 0 增长到内容的实际高度,不需要猜高度。
有一个必须注意的配套条件:外层网格的行高变化不会自动裁剪内容,你必须给子元素加overflow: hidden。很多人试了这个方案发现动画期间文字从面板里"溢"出来,就是因为漏了这一行。我上面给.nav__sub > li加了overflow: hidden,正好起到裁剪作用。
还有个小细节:grid-template-rows的过渡在部分较老的浏览器上表现不一致,如果项目需要兼容,退回到固定max-height也能接受,把max-height设成一个明显大于内容的值(比如 400px)配合overflow: hidden,观感上差异不算大,只是动画前段会稍慢。另外别忘了尊重系统的"减少动画"偏好:
@media (prefers-reduced-motion: reduce) { .nav__sub, .nav__sub > li { transition: none !important; } }有些用户会因为前庭功能原因关闭动画效果,这不仅仅是个"高级功能",加了也就两行 CSS,我现在的项目里都是默认写上的。
7. 菜单不显示、被遮挡、闪一下:完整排查链路
7.1 面板完全不出现,先查祖先的 overflow
现象:CSS 看着没问题,hover也生效了(控制台能看到类名变化),但二级或三级面板就是不显示。
我的排查顺序是这样的。第一步,打开开发者工具的 Elements 面板,把鼠标悬停到菜单项上,看.nav__sub元素的盒模型有没有出现在页面上,它的尺寸是不是 0。如果宽度高度都是 0,说明样式没生效,检查选择器是不是被更具体的规则覆盖了,或者被visibility: hidden之外的属性藏了。
第二步,如果尺寸正常但看不见,重点查祖先元素的overflow。position: absolute的元素会脱离文档流,但它依然受最近的有overflow: hidden(或auto、scroll)的祖先裁剪。导航栏本身为了横向滚动或者清除浮动,经常会加overflow: hidden;有时候是导航外面那层.header加的。这种情况下子菜单确实渲染出来了,只是被裁到了可视区域之外。
我在一个项目里遇到的就是这个:导航的父容器为了固定高度加了overflow: hidden,二级菜单从底部展开时正好超出容器高度,被整个裁掉。解决方式不是删掉overflow(那会破坏布局),而是给导航创建一个新的层叠上下文,让它脱离裁剪——把菜单挂到position: fixed的浮层里,或者把overflow: hidden换成清除浮动的其他方式(display: flow-root是现在最干净的做法)。
第三步,如果还是不行,检查top、left的值算出来是不是负数或者超出屏幕。left: 100%在右边界附近会让三级菜单跑到屏幕外面,需要有边界检测:当菜单右边界超过视口宽度时,翻转成向左展开。
.nav__sub--third.is-flip { left: auto; right: 100%; transform: translateX(-6px); }这个翻转逻辑需要 JS 判断元素的getBoundingClientRect().right是否超过window.innerWidth,在展开时动态加类。属于典型的小细节,但不做的话,右侧的一级项永远展不开三级。
7.2 时好时坏:是不是层叠上下文的锅
现象:菜单能显示,但部分区域被后面的内容(比如页面里的卡片、图片、浮层)盖住,而且有时调整别的元素样式后突然又好了。
这类"时好时坏"的表现基本可以锁定层叠上下文。定位方法是在开发者工具里选中子菜单,看它的 Computed 面板里z-index的实际生效值,然后沿着父链一路往上找,看哪一层创建了新的层叠上下文。DevTools 里有个好用的技巧:选中一个元素后,在 Layers 面板或者直接看 Computed 中的transform、opacity、filter这几个属性,只要有一个不是默认值,这一层就建了上下文,你的z-index就被关在里面了。
知道了原因,方案就有两种。一种是给创建上下文的那个祖先也提升 z-index,让整个上下文整体浮上去;另一种是把菜单移到body下(用 portal 的思路),彻底摆脱祖先约束。前者改动小,后者更彻底但需要 JS 维护位置同步。三级菜单通常用前者就够了,因为它本身就在页面顶部区域。
还要注意position: sticky的头部导航。sticky 元素会创建层叠上下文,如果导航是 sticky 的,页面里其他有 z-index 的元素很容易和它打架。我一般给 sticky 头部一个明确的较高 z-index,然后页面内容区的浮层统一控制在它之下,形成清晰的约定,避免每次都要现场调试。
7.3 点一下闪一次:事件冒泡和状态互斥
现象:点击菜单项,面板展开后立刻又收起来,或者快速闪一下。
最直接的怀疑对象是事件冒泡。如果你的展开监听挂在导航上,而收起监听挂在document上(用来实现"点空白处收起"),点击菜单项时,事件先冒泡到导航触发展开,然后继续冒泡到document触发收起,一开一关,表现为闪烁。
修法是在文档点击的处理里判断事件目标是否在导航内部:
document.addEventListener('click', (e) => { if (nav.contains(e.target)) return; nav.querySelectorAll('.has-sub.is-open').forEach((li) => { li.classList.remove('is-open'); li.querySelector(':scope > a')?.setAttribute('aria-expanded', 'false'); }); });另一个常见原因是同一个元素同时被 hover 和 click 两套逻辑控制。悬停逻辑加了is-open,点击逻辑判断!is-open后切换,结果两次操作互相抵消。避免的办法是把两种模式用媒体查询明确分开,hover只在精确指针设备生效,click只在触屏生效,不要同时挂在同一个元素上。
还有一类原因稍微隐蔽一点:给<a>绑定点击时没有preventDefault,页面发生了锚点跳转,浏览器滚动到顶部,视觉上就像菜单闪了一下消失了。这种在开发时不容易发现,因为href="#"的跳转很快,看起来只是"闪"。
8. 抽成可配置组件:数据结构驱动渲染
8.1 菜单数据长什么样
菜单一旦超过十个项,手写 HTML 就开始变得难以维护——改一个链接地址要翻半天标签。这时候把菜单结构抽成数据,用 JS 递归渲染,是能显著降低维护成本的一步。
数据结构保持和 DOM 层级一一对应:
const menuData = [ { label: '产品中心', href: '#', children: [ { label: '云端服务', href: '#', children: [ { label: '弹性计算', href: '/compute' }, { label: '对象存储', href: '/storage' } ] }, { label: '技术支持', href: '/support' } ] }, { label: '解决方案', href: '/solutions' } ];children数组存在就表示有子菜单,不存在或者为空数组就是叶子节点。这个约定让渲染函数非常简洁,不需要额外的type或isLeaf字段来判断。
递归渲染的核心思路是:每一个节点生成一个<li>,里面放一个<a>,如果有children就再加一个<ul>,并对每个子节点递归调用同一个函数。
function renderMenu(items) { const ul = document.createElement('ul'); ul.className = 'nav__sub'; items.forEach((item) => { const li = document.createElement('li'); const hasChildren = Array.isArray(item.children) && item.children.length; li.className = hasChildren ? 'nav__sub-item has-sub' : 'nav__sub-item'; const a = document.createElement('a'); a.className = 'nav__sub-link'; a.textContent = item.label; a.href = item.href || '#'; if (hasChildren) { a.setAttribute('aria-haspopup', 'true'); a.setAttribute('aria-expanded', 'false'); } li.appendChild(a); if (hasChildren) li.appendChild(renderMenu(item.children)); ul.appendChild(li); }); return ul; }渲染出来的结构必须和手写版本保持一致,尤其是类名和aria-*属性,否则前面那套 CSS 和 JS 的交互逻辑会失效。我建议渲染完之后先手动检查一个三级节点的 HTML 输出,跟手写的对比一下,这一步能提前发现大部分属性遗漏。
8.2 递归渲染的性能账与安全注意
递归本身在菜单这种规模(几十到几百个节点)下性能开销可以忽略,瓶颈不在递归,而在一次性插入大量 DOM引起的重排。稳妥的做法是先把整棵树在内存里构建好,最后一次性挂到页面:
const navRoot = document.querySelector('.nav__list'); navRoot.appendChild(renderMenu(menuData));这样只触发一次重排,比每生成一个节点就 append 一次要快得多。菜单如果是从接口异步拿的,还要处理加载期间的占位和失败时的降级——我的做法是接口失败时至少保留一级菜单,宁可少展示,也不要让整个导航栏空着,那样用户连首页都回不去。
安全方面有一个必须提的点:如果菜单的label来自后端的用户可填写内容(比如自定义菜单名),绝对不能用innerHTML拼接。我上面用的是textContent,它会把内容当纯文本处理,不会解析标签,从根上避免了脚本注入的问题。如果确实需要渲染图标之类的 HTML,也要先做转义或者用可信的白名单组件,不要图省事直接innerHTML。
另外,如果菜单结构会随窗口宽度变化(比如窄屏只显示一级、宽屏展开到三级),记得把渲染和窗口尺寸的响应分开:DOM 结构一次性渲染完整,层级显隐完全交给 CSS 媒体查询,不要在resize事件里重建 DOM。resize触发频率很高,重建 DOM 会让页面明显卡顿,这个坑我在早期项目里踩过一次,鼠标拖拽窗口边缘时页面直接白屏了两秒。
最后分享一个我自己一直在用的小习惯:把这套菜单单独做成一个独立页面来开发,页面里只放导航和几个不同颜色的内容区块。这样调试z-index遮挡的时候特别直观,哪一层浮在哪个上面一眼就能看出来,不用在完整的业务页面里靠猜。等这个独立页面对了,再整体移植过去,往往一次就能成。