ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HTML菜单源码落地实践:从选型、改造到接口驱动

HTML菜单源码落地实践:从选型、改造到接口驱动

简介:这是一份面向网页开发者的HTML菜单源码合集,涵盖纯CSS基础菜单、结合JavaScript与jQuery的交互菜单,以及配套的字体图标资源,适合从入门到进阶的开发者快速搭建导航、下拉菜单等常见界面,省去从零编写样式和脚本的时间。压缩包共38个文件,大小约481KB,包含13个less与13个scss样式源码、3个js交互脚本、2个css样式表,以及多种字体格式文件(woff2、ttf、svg、eot等),便于按项目技术栈选择使用,也适合研究样式预处理与菜单动效的写法。已有2595人学习下载。通过这套源码,开发者可以掌握使用HTML结构配合CSS/JS/jQuery实现悬停下拉、平滑滚动、状态切换等交互效果的方法,同时了解Font Awesome字体图标在菜单中的集成思路,对提升前端页面交互质量与搭建效率都有直接帮助。

1. 拿到漂亮精美的html菜单源码:先别贴,回答三个问题再动手

在默认浏览器里打开别人给你的 demo,漂亮精美 html 菜单源码几乎挑不出毛病:阴影恰到好处、hover 过渡顺滑、图标也精致。可把这个页面整段复制进自己的项目,问题就来了——点击父级目录页面直接跳走,二级菜单在组件边缘一闪而过,移动端点几下就没了响应。这不是源码质量问题,而是“菜单源码”本质是一套特定条件下的代码片段,超实用不等于拿来即用;它能替你省掉一整块导航的排版和基础交互,但省不掉适配、状态管理和可访问性的活。

它适合两类人:一类是需要马上出效果的官网、营销页,一类是后台管理端想找个数据驱动菜单的从业者。开写之前先把“漂亮精美”放一边,确认三件事:这份源码用的是纯 CSS 还是 CSS+JS,菜单数据是写死还是可以换,有没有键盘和移动端的状态处理。这三件事决定后面所有步骤,也是这篇笔记要逐层拆开的内容。

2. 菜单源码的三种常见风格与选型:纯CSS、CSS+JS、纯JS渲染,怎么挑

2.1 纯CSS菜单:零依赖、跑得快,代价是交互上限卡在hover

拿到一份“菜单源码”先看包里的文件构成,如果只有 html 和 css、没有 js,那大概率是纯 CSS 实现。这类菜单的原理是把展开行为挂到:hover和:focus-within两个伪类上,打开开发者工具看样式表,常见写法是这样:

.submenu { opacity: 0; visibility: hidden; /* 先隐藏,visibility 保留布局占位,配合 opacity 做淡入 */ transition: opacity 0.2s ease; position: absolute; top: 100%; } .menu-item:hover > .submenu, .menu-item:focus-within > .submenu { opacity: 1; visibility: visible; }

这段 CSS 的坑点其实在visibility上。如果直接用display: none,元素从文档流里彻底移除,opacity的过渡动画会直接失效,菜单变成“唰”地一下出现而不是淡入;visibility则保留了元素尺寸,能在过渡结束后才真正拦截点击。0.2s 这个数值在鼠标端舒服,在触屏上会显得拖沓,建议移动端单独覆盖成 0.1s 或直接关闭过渡。

纯 CSS 菜单的好处有三个:没有初始化时序,页面打开即就绪;没有外部依赖,不存在 CDN 挂了菜单就白板的玄学;代码量最小,出问题排查路径短。代价也明确:交互上限卡在 hover 上,触屏上第一次点击往往只是触发 hover,第二次点击才能触发链接跳转。如果你做的是官网 banner 下的主导航,用户主要用鼠标,那纯 CSS 完全够用,没必要上 JS。

2.2 CSS+JS菜单:主流形态,焦点管理和外层点击都要自己写

纯 CSS 菜单遇到“点击父项才展开、再点其他区域收起”这类需求时就无法胜任,于是出现了 CSS+JS 的组合。这类菜单 demo 里动效最花哨,但把它当成品用时有两个隐性成本:其一,点击外部关闭的逻辑必须自己写,不然菜单打开后只能靠再次点击父级来切换;其二,键盘焦点顺序如果不处理,Tab 键会直接飘到子菜单的隐藏链接上。注意,这套逻辑和 Electron 里的原生菜单模板不是一回事,浏览器菜单的收起逻辑永远要自己接管,这一点后面第 5 章还会展开。

事件委托是我在这个场景里唯一推荐的写法。不要给每个菜单项单独绑 click,而是统一在 document 上挂一个监听:

document.addEventListener('click', function (event) { const item = event.target.closest('.has-children'); if (!item) { // 点击的是菜单以外的区域,全部收起 document.querySelectorAll('.has-children.expanded').forEach(function (li) { li.classList.remove('expanded'); const btn = li.querySelector('button'); if (btn) btn.setAttribute('aria-expanded', 'false'); }); return; } item.classList.toggle('expanded'); });

这段代码的意思是事件先冒泡到 document,判断点击目标是否落在某个带下拉的子容器里;落在外面就全部收起,落在某个容器里就切换它自己。closest会沿着祖先链往上找,所以点击子菜单里的任意链接也能命中父容器。用事件委托的核心收益是:后续通过接口动态新增菜单项,不需要重新绑定监听器,事件照样生效。

2.3 选型对照与取舍建议:菜单模板到底适配哪种项目

很多人直接在 Bootstrap、Element UI 里找菜单,那是组件库的一部分,不是“菜单源码”。项目本来就没引组件库,为一份菜单引入整套框架,是捡芝麻丢西瓜的事。我这里说的“菜单模板”,是指独立 HTML+CSS+JS 文件组成的导航片段。

实现方式依赖交互上限维护性典型场景
纯CSS无hover 为主,键盘支持弱结构简单,状态靠伪类表达官网导航、静态站点
CSS+JS无外部依赖点击开合、外部点击关闭、焦点控制逻辑集中在组件内带下拉的官网、Web 系统
纯JS渲染依赖数据接口动态增删、无限层级、接口驱动数据结构决定一切后台管理端、多租户系统

我的选型标准很简单:菜单项在 10 个以内、后续基本不变,选纯 CSS,省心;菜单项会随权限动态变化,或者后台要能配置菜单,选纯 JS 渲染;鼠标键盘混用、又要支持触屏,选 CSS+JS 并补上焦点管理。标题里的“menu 源码”在不同仓库里的实现风格差异很大,先按这个框架筛一遍,能省掉后面一大半调试时间。

3. 把html菜单源码落到自己的页面:最小结构与四个CSS变量

3.1 先抄骨架:一个能跑的最小菜单源码,逐行解释节点层级

先把一段能直接跑起来的最小菜单源码贴出来。它不是我编的复杂组件,而是几乎所有漂亮菜单 demo 背后的公共骨架,你拿到的源码大概率也是在这个骨架上加样式:

<!doctype html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>最小可用菜单源码</title> <style> /* 样式见 3.2 节 */ </style> </head> <body> <nav class="menu" aria-label="主导航"> <ul class="menu-list"> <li class="menu-item"> <a href="#home">首页</a> </li> <li class="menu-item has-children"> <button type="button" aria-expanded="false"> 产品 <span class="arrow" aria-hidden="true"></span> </button> <ul class="submenu" hidden> <li><a href="#desktop">桌面端</a></li> <li><a href="#mobile">移动端</a></li> </ul> </li> <li class="menu-item"> <a href="#price">价格</a> </li> </ul> </nav> </body> </html>

三个结构细节值得说。第一,有下拉的父项用的是<button>而不是<a>,原因很简单:<a>自带导航行为,点击就会跳转刷新,菜单状态全丢;<button>没有默认跳转,展开收起完全交给 JS。第二,hidden属性直接作为初始收起态,它的默认样式是display: none,JS 只需要切换这个属性,不需要维护一套 visible/invisible 类名。第三,aria-expanded="false"和aria-hidden="true"是给读屏器读的,前者告诉辅助技术这个按钮控制一个展开区域,后者避免箭头符号被重复朗读。

3.2 用CSS变量调“漂亮精美”:主题色、圆角、阴影、间距一次说清

菜单的“漂亮精美”几乎全在视觉参数上,而视觉参数最该收敛成 CSS 变量,否则你会在几十处颜色值里翻车。我一般会把四个变量单独拎出来:

:root { --menu-bg: #ffffff; --menu-radius: 8px; --menu-shadow: 0 6px 24px rgba(0, 0, 0, 0.12); --item-padding: 10px 18px; } .menu { background: var(--menu-bg); border-radius: var(--menu-radius); box-shadow: var(--menu-shadow); padding: 8px; } .menu-item > button, .menu-item > a { padding: var(--item-padding); border-radius: calc(var(--menu-radius) - 4px); }

参数怎么调?--menu-bg决定底色,想走深色导航就把导航背景换成#1f2937这类深灰,同时记得给文字和箭头换成浅色,否则对比度不够;--menu-radius控制圆角,官网大 banner 下 0px 直角更干脆,卡片式布局 8px 更柔和;--menu-shadow控制悬浮感,6px 24px 是常见的“浮起来”手感,阴影太小时菜单会贴背景,太大会显得像弹窗;--item-padding是点击面积的关键,垂直方向小于 8px 时手指点击很容易落空,这是移动端最常被忽略的参数。

如果拿到的源码用的是写死的颜色值,没有变量体系,不要全局替换,直接把:root里的变量覆盖到body选择器上,效果一样。部分源码会用!important,遇到这种情况沿用 3.3 节的方式通过 JS 给菜单容器加覆盖类。

3.3 JS事件初始化与参数:事件委托、点击外部关闭、菜单模块搜索

有了骨架和样式,JS 只需要做三件事:点击父项时切换展开,点击外部时全部收起,以及一个很多人会追加的“菜单模块搜索”过滤。不推荐用 jQuery,原生就能写清楚:

const nav = document.querySelector('.menu'); nav.addEventListener('click', function (event) { const trigger = event.target.closest('.has-children > button'); if (!trigger) return; event.stopPropagation(); const li = trigger.parentElement; const sub = li.querySelector('.submenu'); const expanded = trigger.getAttribute('aria-expanded') === 'true'; trigger.setAttribute('aria-expanded', String(!expanded)); sub.hidden = expanded; }); document.addEventListener('click', function () { nav.querySelectorAll('.has-children').forEach(function (li) { const btn = li.querySelector('button'); btn.setAttribute('aria-expanded', 'false'); li.querySelector('.submenu').hidden = true; }); });

stopPropagation的作用是让点击父项时事件不要冒泡到 document 的外层关闭逻辑,否则菜单刚展开就被关掉。用aria-expanded作为状态源而不是 class,是为了让读屏器和样式表共用一套状态。搜索过滤可以挂在input事件上:

searchInput.addEventListener('input', function () { const keyword = searchInput.value.trim().toLowerCase(); nav.querySelectorAll('.menu-list > .menu-item').forEach(function (item) { const text = item.textContent.toLowerCase(); item.hidden = keyword.length > 0 && text.indexOf(keyword) === -1; }); });

这里用item.textContent过滤会把子菜单的文字一起带上,优点是输入“桌面”也能找到二级菜单里的内容,缺点是有子菜单的父项常驻显示。菜单项一多,这个搜索框就是刚需,但注意它只是前端过滤,数据量大时应该走接口查询,那是第 6 章的内容。

4. 多级菜单与折叠箭头:从平铺HTML换成树形数据渲染

4.1 树形数据驱动渲染:别用双向链表的思路写前端菜单

菜单项一多,写死在 HTML 里的问题就暴露了:改一个菜单项要同时改结构、样式、还有可能存在的脚本引用。更常见的做法是把菜单数据收成树形 JSON,渲染时递归生成 DOM。这里提醒一句:别用双向链表那套思路管理前端菜单,浏览器 DOM 天然是树结构,父查子直接用children,比维护前驱、后继指针简单得多,也不需要“双向链表多级菜单”那种写法。

const menuTree = [ { title: '首页', href: '#home' }, { title: '产品', children: [ { title: '桌面端', href: '#desktop' }, { title: '移动端', href: '#mobile' } ] }, { title: '价格', href: '#price' } ]; function renderMenu(items, parent) { const ul = document.createElement('ul'); ul.className = 'menu-list'; items.forEach(function (item) { const li = document.createElement('li'); li.className = 'menu-item' + (item.children ? ' has-children' : ''); if (item.children) { const btn = document.createElement('button'); btn.type = 'button'; btn.setAttribute('aria-expanded', 'false'); btn.innerHTML = item.title + ' <span class="arrow" aria-hidden="true"></span>'; li.appendChild(btn); renderMenu(item.children, li); } else { const a = document.createElement('a'); a.href = item.href || '#'; a.textContent = item.title; li.appendChild(a); } ul.appendChild(li); }); parent.appendChild(ul); } renderMenu(menuTree, document.querySelector('#nav'));

注意两个边界:叶子节点有href就渲染成<a>,父节点无论有没有href都渲染成<button>,保证点击不会触发页面跳转;children字段缺省时按叶子处理,接口返回的数据常常省略空数组,渲染函数要兼容。递归终止条件就是item.children不存在,层级深浅不必在函数里显式限制,但建议渲染前先深拷贝并校验数据,避免循环引用导致栈溢出。

4.2 折叠箭头不用贴图:CSS伪元素加旋转过渡,和WinForm折叠箭头一个道理

很多菜单源码里箭头用的是字体图标或图片,一旦字体没加载,箭头位置就会出现一个方块。更轻的做法是用 CSS 伪元素画箭头,原理和 WinForm 里常见的菜单折叠箭头是同一种几何思路:两段边框旋转 45 度得到角标。画法如下:

.arrow::before { content: ""; display: inline-block; width: 6px; height: 6px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: translateY(-2px) rotate(45deg); transition: transform 0.2s ease; } .has-children.expanded .arrow::before { transform: translateY(2px) rotate(225deg); }

rotate(45deg)让两条边框拼出一个指向右下方的角,看起来像是“展开更多”的箭头;菜单展开后旋转到 225 度,箭头朝上。currentColor表示箭头颜色自动跟随按钮文字颜色,这样深色主题下不用额外改色。这里有个容易踩的细节:width和height必须显式设置,否则伪元素没有尺寸,边框画出来只有像素级的一条线。源码里若用的是图片箭头,想换成伪元素,记得删掉原来的<img>或<i>标签,只保留这个<span class="arrow">。

4.3 移动端收起与展开:汉堡按钮和max-height动画的边界

移动端菜单的常规做法是:屏幕宽度小于 768px 时,菜单默认收成一个汉堡按钮,点击后菜单从上往下展开。这里最容易翻车的不是按钮本身,而是展开动画。很多人直接用max-height: 9999px来做平滑展开,动画会快得离谱或者延迟严重,因为浏览器要计算从 0 到 9999 的高度差。正确做法是给一个接近实际高度的值:

@media (max-width: 768px) { .menu-list { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; } .menu.open .menu-list { max-height: 480px; } }

480px 对应大约 6~7 个一级菜单项的高度。如果菜单项多于 8 个,实际高度超过 480px,动画会在半路截断。一个技巧是先用 JS 量出scrollHeight,再把max-height设成这个值,但要注意改变窗口大小后需要重新计算。移动端还建议把 hover 展开全部关闭,因为触屏没有 hover 态,点一次按钮既要展开菜单又要防止误触链接,所以父级按钮保持 button 形态,叶子链接才用<a>,这和 3.1 节的结构设计是呼应的。

5. 菜单源码落地避坑:从hover失灵到点击外部关不掉

5.1 二级菜单在hover切换时“一闪而过”

现象:鼠标从一个父菜单移到二级菜单上,二级菜单立刻消失,怎么都移不过去。原因:父菜单的:hover区域和子菜单之间有个空隙,通常来自子菜单的margin-top,或者父菜单的padding-bottom太小。鼠标离开父菜单瞬间,hover 断掉,子菜单就没了。解决:子菜单不要用负 margin 做定位微调,改成在子菜单内部加padding-top作为缓冲,把它当成父菜单 hover 面积的延伸:

.submenu { top: 100%; padding-top: 10px; }

这样鼠标从父菜单到子菜单的路径都在可 hover 区域内。另一种常见修复是把选择器从.menu-item:hover > .submenu改成.menu-item:is(:hover, :focus-within) > .submenu,键盘 Tab 到子菜单时也不会丢。

5.2 点击带下拉的父菜单,页面直接跳走,菜单状态全丢

现象:点击“产品”菜单,页面刷新了,菜单没展开。原因:源码里父项用的是<a href="#products">,点击后浏览器执行默认导航。解决:父菜单永远用<button>,只有叶子链接用<a>;如果你拿到的源码已经是<a>,又不想改太多结构,就在 JS 里统一拦截:

nav.addEventListener('click', function (event) { const trigger = event.target.closest('.has-children > a'); if (trigger) { event.preventDefault(); // 后续再执行切换展开逻辑 } });

拦截后需要手动管理 href 的跳转,比如父项没有独立落地页时可以直接去掉 href 属性。别把这个逻辑写在<a>的 onclick 里,菜单一多,内联事件既难维护又容易漏。

5.3 子菜单被兄弟模块盖住:z-index设置了却始终不生效

现象:菜单展开后,页面的轮播图或者弹层盖在子菜单上面,把.submenu { z-index: 9999; }加上也没用。原因:菜单的某个父级容器上设置了transform、opacity、filter或animation,这些属性会在父级创建独立层叠上下文,子元素的z-index只能在那个上下文内部比较。解决:确认整个菜单的祖先链上有没有这些属性,把transform动画移到别处,或者干脆让菜单容器脱离那个父级,直接用position: fixed配合 JS 计算坐标。这个坑在后台模板里最常见,因为侧边栏常带transform过渡。

5.4 菜单文字前出现不明符号:伪元素content在背锅

现象:菜单项前面多了个圆圈、斜杠或诡异字符,样式表里根本没有写过。原因:这类效果大多是某个伪元素在背锅,比如源码用.menu-item::before { content: "›"; }做“更多”指示符,但选择器没写限定,结果所有菜单项都带一个箭头;又或者字体图标没加载,显示成了十六进制占位符。解决:打开 DevTools 找到该伪元素的计算样式,确认是哪条规则引入的。如果是content写死的符号,直接删掉或改空字符串;如果是字体图标,给图标字体补一个系统字体回退:

.menu-item::before { content: none; }

排查时把font-family也看一眼,很多“花屏”是字体文件跨域加载失败,和菜单源码本身无关。

5.5 嵌进后台或iframe后菜单被裁剪,弹出方向也是反的

现象:把菜单源码嵌进后台系统的侧边栏,二级菜单被overflow: hidden裁掉半截,或者向上弹出时超出视口被截断。原因:后台布局为了滚动和圆角,经常会给父容器设置overflow: hidden或overflow: auto,绝对定位的子菜单一旦超出那个容器就会被裁剪。解决思路有两个:一是把子菜单的定位从absolute改成fixed,再用 JS 在展开时计算按钮在视口中的位置并设置 left/top,适合全局悬浮的菜单;二是给最近的父容器去掉overflow,用overflow: visible,但这要看布局允不允许。另外确认子菜单弹出方向,视口底部空间不足时,展开方向应该向上翻转,参考 popper 类组件的 flip 行为。

6. 把菜单从源码改造成接口驱动:用数据换掉写死的HTML,再验证一遍

6.1 用fetch把一份菜单JSON渲染成真实菜单

到了后台管理系统这一步,菜单基本不能写死在页面里,接口返回什么角色就显示什么菜单。改造方式就是第 4 章的递归渲染函数加一层 fetch:

fetch('/api/menu') .then(function (res) { return res.json(); }) .then(function (tree) { renderMenu(tree, document.querySelector('#nav')); }) .catch(function () { // 接口挂了回退到静态菜单,别把导航页搞成白屏 renderMenu(defaultMenu, document.querySelector('#nav')); });

接口返回的数组字段要和你渲染函数里的命名对上,常见的是title、href、children。如果接口字段叫name或path,在进入渲染函数前先做一层 map 转换,别在渲染函数里堆判断。菜单模块搜索的逻辑也可以直接复用 3.3 节的过滤函数,数据驱动后搜索范围变成整棵渲染好的树,效果一致。

6.2 键盘可达性与Esc关闭:最小改动但别不做

菜单源码默认只处理鼠标点击,键盘用户 Tab 到隐藏的子菜单时可能发生焦点丢失。最小改动是补一个 Esc 关闭全局监听:

document.addEventListener('keydown', function (event) { if (event.key === 'Escape') { document.querySelectorAll('.has-children[data-open]').forEach(function (li) { li.removeAttribute('data-open'); }); } });

style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进