YAOTU INSIGHTS

手写展开收缩导航菜单:HTML+CSS+JS实现与优化

手写展开收缩导航菜单:HTML+CSS+JS实现与优化
1. 项目缘起与整体设计思路1.1 为什么还要手写一个展开收缩导航菜单前端生态里组件库多如牛毛随便引入一个UI框架菜单组件开箱即用。但我在实际带项目和做代码评审时发现一个现象很多做了两三年前端的人遇到一个简单的展开收缩菜单第一反应还是去搜组件库文档而不是自己动手写。这本身没什么错但问题在于一旦产品经理提出“这个展开动画要改成从右侧滑入”“子菜单要支持三级嵌套”“移动端要变成抽屉式”这类定制需求不会手写的人就卡住了。这个项目的定位很明确用最基础的HTML结构和CSS配合极少量JavaScript实现一个可展开、可收缩的导航菜单。它不依赖任何框架不引入任何第三方库一个HTML文件加一个CSS文件就能跑起来。适合三类人参考刚学完HTML和CSS基础、想找个完整案例练手的新手需要快速实现一个轻量级菜单、不想为一个小功能引入整个组件库的开发者以及想理解“展开收缩”这个交互背后CSS原理的人。我把它命名为“id1098”纯粹是项目编号习惯方便我在自己的案例库里检索。下面我会把这个菜单从设计思路到代码实现再到踩过的坑完整地拆一遍。1.2 三种主流实现方案的取舍分析在动手之前先想清楚用什么技术方案来实现“展开收缩”。市面上常见的做法有三种我把它们的优缺点列出来对比一下。方案核心原理优点缺点适用场景CSS:hover伪类鼠标悬停时显示子菜单零JavaScript代码量最少移动端无hover概念触屏体验差无法点击固定展开状态纯桌面端、二级菜单CSScheckbox黑科技用隐藏的复选框控制显隐纯CSS实现点击切换无需JSHTML结构受限语义化差无障碍支持弱对JS有严格限制的场景JavaScript 控制类名JS监听点击切换class逻辑清晰可控性强易扩展需要写JS比纯CSS方案多几行代码通用场景推荐我最终选了第三种JavaScript控制类名切换。原因很直接这个菜单要同时兼顾桌面端和移动端要支持点击展开、点击收缩、点击外部区域关闭这些交互用纯CSS的checkbox方案做起来会非常别扭HTML结构也会变得不伦不类。多写十几行JS换来的是清晰的逻辑和无限的扩展空间这笔账怎么算都划算。提示如果你的项目对JavaScript有严格限制比如某些邮件模板环境那checkbox方案是唯一选择。但只要是正常的网页环境我都建议用JS控制类名后期维护成本低得多。1.3 整体结构规划与文件组织项目文件结构保持最简id1098-nav-menu/ ├── index.html ├── style.css └── script.jsHTML负责语义化结构CSS负责视觉呈现和过渡动画JS只做一件事——切换类名。三者职责分明互不越界。这种分离方式的好处是后期想改动画效果只动CSS想改交互逻辑只动JS想调整结构只动HTML。不会出现“改一个颜色要翻三个文件”的情况。菜单的HTML结构采用嵌套的无序列表这是导航菜单最经典的语义化写法。一级菜单项用li包裹如果该项有子菜单就在li内部再嵌套一个ul。这种结构天然表达了层级关系屏幕阅读器也能正确识别。2. 核心细节解析与实操要点2.1 HTML结构语义化是地基先看完整的HTML骨架。我刻意保持了结构的简洁没有多余的div包裹。nav classnav-menu idnavMenu ul classmenu-list li classmenu-item a href# classmenu-link首页/a /li li classmenu-item has-submenu a href# classmenu-link 产品中心 span classarrow/span /a ul classsubmenu lia href#产品介绍/a/li lia href#功能特性/a/li lia href#价格方案/a/li /ul /li li classmenu-item has-submenu a href# classmenu-link 解决方案 span classarrow/span /a ul classsubmenu lia href#企业方案/a/li lia href#个人方案/a/li /ul /li li classmenu-item a href# classmenu-link联系我们/a /li /ul /nav几个关键点需要说明。第一has-submenu这个类名是给JS和CSS用的标记用来区分“有子菜单的项”和“普通项”。第二箭头span classarrow用CSS绘制不用图片这样颜色和大小都能通过CSS控制也省了一次HTTP请求。第三子菜单ul classsubmenu直接嵌套在父级li内部而不是作为兄弟节点这样在语义上明确表达了从属关系。注意a标签的href我暂时写了#实际项目中要替换成真实链接。如果某个菜单项只是用来触发展开、本身不跳转建议用button替代a这样语义更准确也避免了点击后页面跳回顶部的问题。2.2 CSS过渡动画让展开收缩不突兀展开收缩的视觉核心在于max-height的过渡。为什么不用height因为height: auto无法参与CSS过渡动画浏览器无法从一个具体数值过渡到auto。而max-height可以设一个足够大的值让内容在过渡过程中自然展开。.submenu { max-height: 0; overflow: hidden; transition: max-height 0.35s ease; background-color: #f5f5f5; } .menu-item.open .submenu { max-height: 500px; }这里max-height设了500px是一个经验值。如果子菜单项特别多内容实际高度超过500px展开动画会在到达500px时停止后面的内容会突然出现。所以这个值要根据实际内容调整宁可设大一点比如800px代价只是动画前段会稍微快一点点视觉上几乎察觉不到。箭头的旋转也是同样的思路用transform: rotate()配合transition.arrow { display: inline-block; width: 8px; height: 8px; border-right: 2px solid #333; border-bottom: 2px solid #333; transform: rotate(45deg); transition: transform 0.3s ease; margin-left: 6px; vertical-align: middle; } .menu-item.open .arrow { transform: rotate(-135deg); }用边框画箭头是个老技巧了两条边框旋转45度就是一个向下的箭头再旋转-135度就变成向上。比引入图标字体或者SVG简单得多而且颜色、粗细、大小全部可控。2.3 JavaScript交互逻辑只做一件事JS部分我写得非常克制核心逻辑就是事件委托加类名切换。document.addEventListener(DOMContentLoaded, function () { var navMenu document.getElementById(navMenu); navMenu.addEventListener(click, function (e) { var link e.target.closest(.has-submenu .menu-link); if (!link) return; e.preventDefault(); var parentItem link.parentElement; var isOpen parentItem.classList.contains(open); // 关闭其他已展开的菜单项手风琴效果 var openItems navMenu.querySelectorAll(.menu-item.open); openItems.forEach(function (item) { if (item ! parentItem) { item.classList.remove(open); } }); // 切换当前项 parentItem.classList.toggle(open, !isOpen); }); // 点击菜单外部区域关闭所有展开项 document.addEventListener(click, function (e) { if (!navMenu.contains(e.target)) { var openItems navMenu.querySelectorAll(.menu-item.open); openItems.forEach(function (item) { item.classList.remove(open); }); } }); });这段代码有几个设计决策值得展开说。第一用事件委托而不是给每个菜单项单独绑定事件。菜单项数量可能变化事件委托只需要在父容器上绑一次新增的菜单项自动生效。第二closest()方法从点击目标向上查找最近的匹配元素这样即使用户点的是箭头或者文字都能正确找到对应的菜单链接。第三手风琴效果——展开一个菜单时自动关闭其他菜单——是通过遍历所有已展开项并移除open类实现的。这个效果在菜单项较多时能避免页面被撑得过长。提示classList.toggle()的第二个参数是布尔值传true强制添加传false强制移除。这里传!isOpen意思是“如果当前是展开的就收缩如果当前是收缩的就展开”。3. 完整实操流程与关键环节实现3.1 从零搭建HTML骨架的逐行编写打开编辑器新建index.html。第一步写文档声明和基本结构!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title展开收缩导航菜单/title link relstylesheet hrefstyle.css /head body !-- 导航菜单结构写在这里 -- script srcscript.js/script /body /htmlviewport这行meta标签不能省否则移动端会以桌面宽度渲染菜单会小得看不清。langzh-CN声明页面语言为中文对屏幕阅读器和搜索引擎都有好处。接下来在body里写导航结构。我习惯先写最外层容器再逐层向内。nav标签是HTML5的语义化标签专门用来包裹导航区域比用div classnav更规范。一级菜单项直接写在ul里有子菜单的项加上has-submenu类。子菜单的ul嵌套在父级li内部紧跟在触发链接后面。这个顺序很重要——如果子菜单写在触发链接前面CSS选择器和~的匹配逻辑会反过来容易出错。3.2 CSS样式从基础布局到过渡动画新建style.css先做基础重置和布局。* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; background-color: #fafafa; padding: 20px; } .nav-menu { max-width: 320px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); overflow: hidden; } .menu-list { list-style: none; } .menu-link { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; color: #333; text-decoration: none; font-size: 15px; border-bottom: 1px solid #f0f0f0; transition: background-color 0.2s ease; } .menu-link:hover { background-color: #f7f7f7; }box-sizing: border-box让padding和border包含在宽度内避免布局计算时出现意外溢出。display: flex配合justify-content: space-between让文字和箭头自动分列两端不用手动计算间距。子菜单的样式.submenu { list-style: none; max-height: 0; overflow: hidden; transition: max-height 0.35s ease; background-color: #fafafa; } .submenu li a { display: block; padding: 11px 18px 11px 36px; color: #666; text-decoration: none; font-size: 14px; border-bottom: 1px solid #f0f0f0; transition: background-color 0.2s ease, color 0.2s ease; } .submenu li a:hover { background-color: #f0f0f0; color: #222; }子菜单链接的左内边距设为36px比一级菜单的18px多了一倍视觉上形成缩进层级用户一眼就能看出从属关系。overflow: hidden是必须的否则max-height: 0时内容依然会溢出显示。3.3 移动端适配让菜单在小屏幕上也能用移动端只需要做两件事调整容器宽度和触摸区域大小。media (max-width: 480px) { body { padding: 10px; } .nav-menu { max-width: 100%; border-radius: 0; } .menu-link { padding: 16px 18px; font-size: 16px; } .submenu li a { padding: 14px 18px 14px 36px; font-size: 15px; } }移动端把内边距加大到16px是因为手指触摸区域需要至少44px的高度才不容易误触。字号也相应调大避免用户需要放大页面才能看清。容器宽度设为100%去掉圆角让菜单铺满屏幕宽度更符合移动端的使用习惯。注意移动端没有hover状态所以:hover的样式在触屏设备上可能不会触发或者触发后一直保持。如果发现移动端点击后背景色不恢复可以在JS里给链接添加touchstart事件来清除hover状态或者直接用:active替代:hover。3.4 参数计算max-height到底设多少前面提到max-height设500px是经验值这里说下具体怎么算。假设子菜单有5个链接每个链接高度是padding 11px上下共22px加上文字行高约20px加上底部边框1px合计约43px。5个链接就是215px。再加上子菜单自身的padding如果有的话总共约220px。那为什么设500px而不是250px因为过渡动画的速度感受和max-height的差值有关。如果max-height设250px实际内容220px差值只有30px动画会显得很急促。设500px的话差值280px过渡时间0.35秒视觉上更舒缓自然。代价是动画前0.1秒左右内容还没完全展开但人眼几乎察觉不到。如果子菜单项特别多比如10个以上建议把max-height设到800px甚至1000px。或者更精确的做法是用JS动态计算子菜单的scrollHeight然后设置为max-height的值。但那样就增加了JS的复杂度对于大多数场景一个足够大的固定值就够了。4. 常见问题与排查技巧实录4.1 展开动画卡顿或不流畅这是最常见的问题。表现是点击菜单项后子菜单不是平滑展开而是一跳一跳的或者展开到一半突然加速。排查思路分三步。第一检查transition属性是否写在了正确的元素上。transition要写在.submenu上而不是.menu-item.open .submenu上。如果写在后者收缩时没有过渡效果因为收缩时open类已经被移除了选择器不匹配。第二检查是否有其他属性干扰。比如同时设置了height和max-height浏览器会优先使用height导致过渡失效。确保只使用max-height控制高度。第三检查overflow属性。overflow: hidden是必须的但如果父级元素也有overflow设置可能会产生层叠上下文影响渲染性能。可以尝试给.submenu加上will-change: max-height提示浏览器提前优化这个属性的动画。实操心得我在一个项目中遇到过动画卡顿排查了半天发现是子菜单里有一张很大的背景图片每次展开都要重新渲染。把图片换成CSS渐变后动画立刻流畅了。所以如果动画卡顿先检查子菜单内容里有没有耗性能的元素。4.2 点击菜单项后页面跳回顶部这个问题通常是因为a标签的href设成了#点击时浏览器默认行为是跳转到页面锚点而#对应页面顶部。解决方法有两个。一是在JS里调用e.preventDefault()阻止默认行为这个我在前面的代码里已经加了。二是把href改成javascript:void(0)但这种方式不推荐因为它破坏了链接的语义而且某些浏览器会拦截。更好的做法是如果菜单项只是用来触发展开、不需要跳转直接把a换成button。按钮天生没有跳转行为语义也更准确。但要注意按钮的默认样式需要重置.menu-link { /* 如果用的是button */ background: none; border: none; width: 100%; text-align: left; cursor: pointer; font-family: inherit; font-size: inherit; }4.3 移动端点击无响应或响应延迟移动端浏览器为了判断用户是点击还是滚动会给click事件加一个约300毫秒的延迟。虽然现代浏览器在设置了viewportmeta标签后基本消除了这个延迟但某些旧设备上仍然存在。如果遇到移动端点击延迟可以改用touchstart事件。但touchstart的问题是用户手指按下就触发如果用户其实是想滚动页面会误触。折中方案是用pointerdown事件它统一了鼠标和触摸而且没有延迟。navMenu.addEventListener(pointerdown, function (e) { // 同样的逻辑 });但pointerdown在部分旧浏览器上不支持所以实际项目中我会做特性检测var eventType window.PointerEvent ? pointerdown : click; navMenu.addEventListener(eventType, handler);4.4 多个菜单同时展开导致页面过长如果菜单项很多用户可能同时展开好几个页面被撑得很长需要不断滚动。手风琴效果展开一个时自动关闭其他能解决这个问题但有些场景下用户确实需要同时看多个子菜单。我的做法是做成可配置的。在JS里加一个开关变量var accordionMode true; // 改为false则允许多个同时展开 // 在点击处理逻辑中 if (accordionMode) { var openItems navMenu.querySelectorAll(.menu-item.open); openItems.forEach(function (item) { if (item ! parentItem) { item.classList.remove(open); } }); }这样默认是手风琴模式如果产品需求变了改一个变量就行不用重写逻辑。4.5 常见问题速查表问题现象可能原因解决方法子菜单不展开max-height为0且未设置open类检查JS是否正确添加open类展开后内容被截断max-height值小于实际内容高度增大max-height或动态计算动画不流畅transition写错元素或属性确保transition写在.submenu上点击后页面跳顶a href#默认行为加e.preventDefault()或改用button移动端点击延迟300ms点击延迟改用pointerdown事件箭头方向不对rotate角度错误向下箭头rotate(45deg)向上rotate(-135deg)子菜单溢出显示缺少overflow: hidden给.submenu加overflow: hidden多个菜单同时展开未做手风琴处理遍历关闭其他open项5. 扩展思路与进阶玩法5.1 三级菜单的嵌套实现二级菜单做完了三级菜单的思路是一样的只是多嵌套一层。HTML结构上在二级子菜单的某个li里再加一个has-submenu类和嵌套的ul。CSS上三级子菜单的max-height过渡逻辑完全复用只是缩进要再增加比如左内边距从36px增加到54px。JS逻辑不需要改因为事件委托和closest()方法会自动处理任意层级的点击。唯一需要注意的是手风琴效果在三级菜单里可能会让用户困惑——展开三级菜单时二级菜单不应该被关闭。所以手风琴的遍历范围要限定在同一层级内而不是全局遍历。// 只关闭同级已展开项 var siblings parentItem.parentElement.children; for (var i 0; i siblings.length; i) { if (siblings[i] ! parentItem siblings[i].classList.contains(open)) { siblings[i].classList.remove(open); } }5.2 用CSS变量统一管理动画参数如果项目里有多处展开收缩效果建议把动画时长、缓动函数提取成CSS变量方便统一调整。:root { --menu-transition-duration: 0.35s; --menu-transition-easing: ease; --menu-max-height: 500px; } .submenu { max-height: 0; transition: max-height var(--menu-transition-duration) var(--menu-transition-easing); } .menu-item.open .submenu { max-height: var(--menu-max-height); }这样改动画速度只需要改一个地方不用在CSS文件里到处搜0.35s。5.3 无障碍访问的补强导航菜单是交互组件无障碍访问不能忽视。几个关键点给触发链接加上aria-expanded属性展开时设为true收缩时设为false给子菜单加上aria-hidden属性控制屏幕阅读器是否读取给箭头加上aria-hiddentrue因为它是纯装饰元素。link.setAttribute(aria-expanded, !isOpen); var submenu parentItem.querySelector(.submenu); if (submenu) { submenu.setAttribute(aria-hidden, isOpen); }这些属性不影响视觉表现但对使用屏幕阅读器的用户来说能清楚知道当前菜单是展开还是收缩状态。5.4 从菜单到通用折叠面板这套展开收缩的逻辑稍加改造就能变成通用的折叠面板Accordion。把.nav-menu换成.accordion把.menu-link换成.accordion-header把.submenu换成.accordion-bodyCSS和JS逻辑几乎不用改。区别只在于折叠面板通常不需要箭头旋转而是用加号减号图标。我在一个后台管理项目中就是用这套逻辑做了侧边栏折叠、设置面板展开、帮助文档折叠三个功能代码复用率很高。核心就是“点击切换类名CSS控制过渡”这个模式理解了之后可以套用到任何需要展开收缩的场景。实操心得不要一开始就想着做一个大而全的组件。先把最核心的展开收缩跑通再根据实际需求逐步加功能。我见过太多人一开始就设计复杂的配置项和API结果核心功能还没跑通就被各种边界情况卡住了。先让它跑起来再让它跑得好。6. 我踩过的坑与最后几句实在话这个菜单我前前后后改过好几版踩的坑主要集中在两个地方。一个是max-height的值一开始设了200px结果子菜单有6项展开到一半就停了后面的内容直接跳出来动画效果全毁了。后来改成800px才顺畅。另一个是移动端的点击延迟在旧款安卓机上特别明显点了菜单要等半秒才反应用户会以为没点上然后连续点好几次菜单就反复展开收缩。换成pointerdown事件后问题解决。还有个小细节箭头旋转的transform-origin默认是元素中心但如果箭头是用边框画的旋转中心可能会偏。我一般会显式设置transform-origin: center center确保旋转轴在正中间。最后说句实在话这个菜单的代码量不大但麻雀虽小五脏俱全。它涵盖了HTML语义化、CSS过渡动画、JS事件委托、移动端适配、无障碍访问这几个前端核心知识点。如果你能独立把这个菜单写出来并且能解释清楚每一行代码为什么这么写那你的前端基础就算过关了。后续想扩展成三级菜单、想加图标、想改成暗色主题都是在这个骨架上添砖加瓦的事。先把骨架搭稳比什么都重要。