资讯动态

三级导航菜单实战:HTML语义、CSS定位与无障碍交互

发布时间:2026/9/16 19:00:19 来源:尧图企业网站定制
1. 三级菜单真正的难点从来不是多套一层ul我第一次被三级导航菜单绊住是在给一个后台管理项目改版的时候。当时需求方甩过来一张截图说就是把现在的二级菜单再往下拆一层很简单吧。我心想无非就是在一个ul里再塞一个ulposition: 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关于用ulli还是清一色的div争论一直没停过。我的立场是导航就是一组链接的集合天然适合用列表理由有三个。第一屏幕阅读器会把列表读成列表共 N 项用户能提前知道这里有多少个选项这是div给不了的信息。第二li天然是块级盒子li下面的ul嵌套在语义上就是子级结构自解释不需要靠类名去猜。第三也是最实际的——当你需要做递归渲染的时候列表结构的父子关系和你菜单数据的children字段是完全对应的写递归函数非常顺手。但也不是所有东西都往ul里塞。整个导航最外层我用nav包起来并且给它加aria-label因为一个页面可能有主导航、面包屑导航、页脚导航好几个nav不给标签的话屏幕阅读器用户分不清谁是谁。二级、三级的面板本身不再包nav它们只是同一套导航的一部分。nav classnav aria-label主导航 ul classnav__list li classnav__item has-sub a classnav__link href# aria-haspopuptrue aria-expandedfalse产品中心/a ul classnav__sub li classnav__sub-item has-sub a classnav__sub-link href# aria-haspopuptrue aria-expandedfalse云端服务/a ul classnav__sub nav__sub--third lia classnav__sub-link href#弹性计算/a/li lia classnav__sub-link href#对象存储/a/li lia classnav__sub-link href#内容分发/a/li /ul /li li classnav__sub-itema classnav__sub-link href#技术支持/a/li /ul /li li classnav__itema classnav__link href#解决方案/a/li /ul /navaria-haspopuptrue告诉辅助设备这个链接点开会弹出东西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遮挡的时候特别直观哪一层浮在哪个上面一眼就能看出来不用在完整的业务页面里靠猜。等这个独立页面对了再整体移植过去往往一次就能成。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价