手风琴菜单这个活儿,前端同学早晚都会碰到。做过几个管理后台之后,我发现自己反复在写同一段交互逻辑:侧边栏分组展开、点击切换、当前选中项高亮、小箭头跟着转。这个需求看着简单,但真做起来,坑还是有的——比如快速连点导致动画抖动、展开状态和选中状态搞混、菜单层级一深就不知道该监听谁。
正好借这个"Js实现侧边栏-手风琴效果-带选择小箭头"的项目,把整个实现思路完整拆一遍。从布局结构到交互逻辑,从箭头旋转到状态管理,我把能想到的细节都写出来,包括我实际踩过的坑和最后采用的解决方案。对刚接触前端或者想优化后台菜单交互的同学来说,这篇可以直接当参考。
1. 整体设计与思路拆解
1.1 这个效果到底在解决什么问题
侧边栏手风琴,本质是一个"互斥展开"的折叠菜单:同一时间只有一个分组处于展开状态,点击另一个分组时,当前展开的分组自动收起。这种交互模式在老式桌面软件里很常见,到了Web后台管理系统里,它依然是导航菜单的主流方案之一。
选择手风琴而不是多面板同时展开,是有原因的。后台菜单的特点是分组多、层级深,如果每个分组都可以同时展开,整个侧边栏会变得又长又乱,用户滚动半天找不到自己想要的入口。手风琴把可视范围控制在"一个分组+若干子项"的粒度,保证了侧边栏的清爽。代价是牺牲了一点操作效率——你不能再同时看两个分组的内容,但这个代价在后台场景里完全可以接受。
至于"带选择小箭头",它承担的是一个方向指示的功能。用户看到箭头朝右,就明白这个分组可以展开;箭头转成朝下,就明白分组已经展开。箭头状态和面板状态必须严格同步,否则视觉信号和实际行为不一致,会让用户觉得这个系统很"迟钝"。这是这次要实现的第二个核心点。
1.2 技术方案选型:为什么用原生JavaScript
实现手风琴的方式有不少:CSS里的details标签可以做到无脚本折叠,各种UI组件库直接提供开箱即用的Menu组件,jQuery时代的slideToggle也曾是标配。但这次题目要求的是"Js实现",我倾向于就用原生JavaScript写,不引框架、不依赖组件库。
这么选的原因很实际。第一,手风琴的核心交互逻辑其实非常轻量——无非是点击事件的监听、展开状态的管理、高度和箭头方向的切换,用原生JS写下来不到一百行,引一个组件库反而显得笨重。第二,后台管理系统经常是多个团队合作开发的,用原生JS实现意味着没有额外的框架约束,任何一个后来接手的人都能看懂、能改。第三,手风琴这个交互往往还需要跟项目里的其他逻辑配合(比如菜单高亮、路由跳转、权限过滤),组件库封装得越深,定制起来越痛苦。
CSS的details方案我也试过,简单场景下确实够用,但它的展开收起动画受限于浏览器内置行为,样式定制和状态控制都比较别扭。项目里要实现的箭头旋转和联动高亮,用details反而绕。原生JavaScript看着"原始",但胜在可控性最强。
1.3 从使用者视角反推设计要点
写代码之前,我先把自己当成真实用户,在脑海里点了几遍这个菜单。一轮下来,我总结出这几个关键体验点:
- 点击分组的标题区域(不只是那个小箭头),整个分组都应该响应。许多人习惯直接点文字去展开菜单,如果只有箭头能点,体验很割裂。
- 箭头方向和展开状态必须一一对应,切换过程要顺畅,不能闪跳。
- 当前选中的子菜单要有明显的标识,通常是背景色和文字颜色的变化,这样用户随时知道自己在哪个页面。
- 如果用户当前所在的页面位于某个子菜单里,刷新页面后,这个子菜单的分组应该自动展开。这是个容易忽略的细节,但不做的话,用户每次刷新都要重新找入口。
这四个要点,在后面的实现里我都会落实。
2. 布局结构与静态样式实现
2.1 HTML结构怎么搭才合理
基础HTML
要用语义化结构,侧边栏的DOM组织建议分组嵌套:每一组菜单是一个独立的手风琴面板,面板里放若干子项。下面是一份最基础的模板:
<aside class="sidebar"> <nav class="sidebar-nav"> <div class="menu-group"> <div class="menu-group__header"> <span class="menu-group__title">基础管理</span> <span class="menu-group__arrow">▶</span> </div> <ul class="menu-group__list"> <li class="menu-item"><a href="#">用户管理</a></li> <li class="menu-item"><a href="#">角色管理</a></li> <li class="menu-item"><a href="#">权限配置</a></li> </ul> </div> <div class="menu-group"> <div class="menu-group__header"> <span class="menu-group__title">内容运营</span> <span class="menu-group__arrow">▶</span> </div> <ul class="menu-group__list"> <li class="menu-item"><a href="#">文章列表</a></li> <li class="menu-item"><a href="#">分类管理</a></li> <li class="menu-item"><a href="#">评论管理</a></li> </ul> </div> <div class="menu-group"> <div class="menu-group__header"> <span class="menu-group__title">系统设置</span> <span class="menu-group__arrow">▶</span> </div> <ul class="menu-group__list"> <li class="menu-item"><a href="#">站点配置</a></li> <li class="menu-item"><a href="#">操作日志</a></li> </ul> </div> </nav> </aside>这个结构有几个讲究的地方:
menu-group__header和menu-group__list是同级的兄弟节点,没有把列表嵌进标题里。这个结构上的分离是有意的——点击事件绑在标题上,展开/收起操作的是列表,把两者的DOM层级拉平,操作起来最方便。
箭头放在标题的右侧,我这里用了字符实体▶做初始状态,后面会用CSS旋转配合JS切换is-expanded类来展示箭头的两态。
子菜单列表为什么用ul/li而不是一串div?其实都可以,但ul在语义上更贴合"菜单项集合"的概念,而且自带默认的缩进和列表样式控制能力,对可访问性也更好。
2.2 核心CSS样式与动画过渡
CSS核心样式
布局和显示是第一步,关键是展开收起要通过max-height做平滑过渡:
.sidebar { width: 240px; background: #fff; border-right: 1px solid #edf0f5; height: 100vh; overflow-y: auto; } .menu-group + .menu-group { border-top: 1px solid #f2f3f5; } .menu-group__header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; cursor: pointer; user-select: none; transition: background-color 0.2s ease; } .menu-group__header:hover { background-color: #f5f7fa; } .menu-group__title { font-size: 14px; color: #333; font-weight: 500; } .menu-group__arrow { display: inline-block; font-size: 12px; color: #999; transition: transform 0.25s ease; } .menu-group.is-expanded .menu-group__arrow { transform: rotate(90deg); } .menu-group__list { max-height: 0; overflow: hidden; padding: 0; margin: 0; list-style: none; transition: max-height 0.25s ease-out; } .menu-group.is-expanded .menu-group__list { max-height: 500px; transition: max-height 0.35s ease-in-out; } .menu-item a { display: block; padding: 10px 16px 10px 32px; font-size: 14px; color: #666; text-decoration: none; border-left: 3px solid transparent; transition: all 0.2s ease; } .menu-item a:hover { color: #333; background-color: #fafbfc; } .menu-item.is-active a { color: #1890ff; background-color: #e8f4ff; border-left-color: #1890ff; font-weight: 500; }这里最关键的是max-height技巧。元素的实际高度是不可预测的(子项数量不同),CSS动画又不能直接用height: auto做过渡,所以用max-height从0到500px模拟展开效果。500px是一个足够容纳大多数分组内容的上限,展开动画的视觉表现接近"从0到完全展开"。
但这就引出一个问题:如果某个分组的实际内容高度超过500px,动画会在中途截断——视觉上像"卡住"了一下,然后再瞬间达到全高。怎么处理这个边界?我在JS里留了一个接口动态重置max-height,后面会详细讲。
箭头旋转我用了transform: rotate(90deg)而不是替换成另一个字符。替换字符的缺点是缺少过渡动画,会显得生硬;旋转则能把"朝右/朝下"的切换做得跟面板展开一样顺滑。两态之间用0.25秒的ease曲线过渡,体感上刚刚好,太快显得突兀,太慢又拖泥带水。
2.3 子菜单的缩进与层级视觉
子菜单项的padding-left: 32px是刻意和分组标题拉开差距的。8 px的基准缩进,加上分组标题本身的16px内边距,组合起来形成清晰的层级感。很多设计规范在二级菜单缩进上使用20到40像素不等的值,我建议用设计稿的实际间距为准,但必须保证子项文字和分组标题文字之间有明显的左对齐差异。
左侧的3px竖条是"选中标识"的经典做法。子菜单项默认透明竖条,选中后变成主色竖条——这是很多成熟后台系统的标配样式。竖条+背景色+文字颜色三管齐下,即使色弱的用户也能靠竖条的位置和形状识别出选中项。这个设计细节也是"带选择小箭头"的一部分——箭头指示的是分组状态,竖条指示的是页面位置,两者各司其职。
3. JavaScript核心逻辑实现
3.1 点击事件的正确绑定方式
事件绑定的基本逻辑
原生JS的要点之一是事件委托。把点击监听绑定到nav容器上,而不是给每个menu-group__header单独绑定。事件委托的优势在动态菜单场景里非常明显——如果菜单列表是异步加载的(比如从接口拿权限后渲染),容器上的监听器依然能捕获到未来新增元素的点击,完全不需要重新绑定。
document.querySelector('.sidebar-nav').addEventListener('click', function (e) { const header = e.target.closest('.menu-group__header'); if (!header) return; const group = header.parentElement; toggleGroup(group); });closest方法会从触发事件的元素逐级向上查找匹配的选择器,这样用户无论是点标题文字、点箭头、还是点标题空白区域,都能正确落到对应的menu-group上。
3.2 手风琴互斥展开的核心函数
互斥展开的实现
手风琴和普通折叠菜单的区别在于"互斥"。展开某个分组之前,必须把其他所有分组都收起来。实现互斥有两种思路:
第一种是遍历所有兄弟分组,把它们的is-expanded类都移除,再给当前分组加上。这种写法的好处是简单直观,分组少的时候性能没有任何问题。
第二种是把当前展开的分组存到变量里,每次点击时先操作那个分组,再把变量更新成当前分组。
我通常用第一种,因为它的状态完全由DOM类的"真相"决定,不需要维护额外的JavaScript状态。代码如下:
function toggleGroup(group) { const isExpanded = group.classList.contains('is-expanded'); // 移除所有分组的展开状态 const allGroups = document.querySelectorAll('.menu-group'); allGroups.forEach(function (item) { item.classList.remove('is-expanded'); }); // 如果当前分组之前是收起状态,则展开它;如果之前是展开状态,点击后就全部收起 if (!isExpanded) { group.classList.add('is-expanded'); // 此时还需要同步箭头状态和选中项 updateActiveItem(group); } }注意这里有一个交互上的小取舍:当分组已经展开时,再次点击同一个分组,是收起还是保持展开?我选择了"收起"。这个交互更符合用户的直觉——再次点击展开的标题,自然期望它关掉。如果你希望分组一旦展开就锁定(比如某些强制导航场景),可以把if (!isExpanded)改成直接group.classList.add('is-expanded'),但那就不是完整的手风琴了。
3.3 箭头状态与展开状态的同步
箭头方向不要单独控制,它是is-expanded类的一个视觉投影。CSS那边已经写了.menu-group.is-expanded .menu-group__arrow { transform: rotate(90deg); },所以JS里只需要维护is-expanded类,箭头会自动跟着转。
这个设计是这次实现里最值得注意的一个思路——不要让JS去手动改箭头的样式,也不要维护两份状态(一份展开、一份箭头)。状态单一化可以彻底避免"面板已经展开了但箭头还朝右"这种不一致问题。很多初学同学会在JS里写arrow.style.transform = 'rotate(90deg)',这样做一时没错,但状态一多就容易漏掉同步。
3.4 刷新页面自动展开当前分组
这个功能需要配合"当前路由或当前页面"来判断。在一个纯前端Demo里,"当前页面"可以简化成当前URL的hash或者路径。我提供一个思路:把子菜单项的href和当前路径做匹配,命中之后,向上找到它所属的menu-group,给这个分组加上is-expanded,同时给这个子项加上is-active。
function restoreFromCurrentPage() { const currentPath = location.pathname + location.hash; const menuItems = document.querySelectorAll('.menu-item a'); menuItems.forEach(function (link) { if (link.getAttribute('href') === currentPath) { const menuItem = link.parentElement; menuItem.classList.add('is-active'); const group = menuItem.closest('.menu-group'); if (group) { group.classList.add('is-expanded'); } } }); }这样即使在深链刷新场景下(比如用户直接打开某个子页面的URL),侧边栏也能自动展开对应的分组并高亮当前子项。这个细节对后台系统尤为重要——不然每次F5之后,菜单都回到初始状态,用户又得重新找自己刚才在哪个页面。
配合初始化,整个JS入口大概是:
document.addEventListener('DOMContentLoaded', function () { restoreFromCurrentPage(); });点击切换到新菜单时,选中项的高亮也要跟着走。点击子菜单项时,先清零所有子项的高亮状态,然后给当前点击的项加上is-active,这个逻辑我在后面的实操心法里一起说。
4. 选择小箭头的视觉反馈与交互细节
4.1 箭头用字符还是SVG
箭头我在上面的示例里用了字符实体会▶,这是一种取巧的方式,优点是零依赖,字符跟随字体渲染,颜色可以用CSS直接控制。缺点是不同操作系统和浏览器上的字形渲染有细微差异,字符的基线位置也可能不太统一。
如果要追求更高的一致性,建议换成内联SVG箭头。SVG的好处是矢量渲染,任何屏幕下都清晰,而且可以用stroke属性控制颜色和线宽。下面是一个可用的三角形箭头SVG:
<svg class="menu-group__arrow" width="12" height="12" viewBox="0 0 12 12"> <path d="M4 2 L9 6 L4 10 Z" fill="#999"/> </svg>配CSS:
.menu-group.is-expanded .menu-group__arrow { transform: rotate(90deg); }SVG和字符的方案在交互层没有区别,旋转逻辑一模一样。如果你以后要适配暗色主题,SVG的fill属性可以通过CSS变量来切换,灵活度更高。
4.2 选中项与箭头的联动体验
有些后台菜单的箭头不仅表示"可展开",还承担"当前选中分组"的指示作用。我在多个项目里都做过类似的设计:分组未选中时箭头是灰色,分组展开且内部有选中子项时箭头变成主色。这样用户即使在没有展开其他分组的情况下,扫一眼侧边栏也能知道"我目前在哪个分组下"。
实现这个联动,可以在更新is-active的同时,检测所属分组的箭头颜色:
.menu-group:has(.menu-item.is-active) .menu-group__arrow { color: #1890ff; }:has选择器是现代浏览器都支持的新特性,如果项目兼容到较老的浏览器,可能需要退回到JS里手动给分组加一个has-active类。我实测下来,has写法在主流现代浏览器上没有问题,但如果你的项目还需要支持旧的浏览器内核,就用JS的类切换方案。
4.3 命中区域与光标反馈
之前我提到过整个标题区域都能点击,光标也要配合变化。CSS里我已经写了cursor: pointer,但还有两个细节值得注意。
第一个是user-select: none。如果加了这行,用户在菜单上快速连点时,不会因为鼠标选中文字而造成视觉上的高亮闪烁。在菜单这种高频点击区域,用户体验提升是明显的。
第二个是点击箭头的精准区域。箭头本身尺寸很小(12px左右),直接点击它很容易点偏。我通常是整个标题区域都是点击目标,这样用户不需要精准瞄准,也符合"点击标题任意位置展开"的直觉。
5. 实操过程与关键细节:从Demo到可复用组件
5.1 动态重置max-height,解决长分组截断问题
前面提到max-height: 500px是在静态CSS里写死的。如果某个分组里有15个甚至20个子项,500px就不够用了。展开动画进行到500px时会瞬间终止,看起来像动画"卡了一帧"。
我的处理思路是,在点击展开时,用JS测量当前分组列表的实际内容高度,然后把这个高度动态赋给max-height。CSS优先使用内联样式,因此这个动态值会覆盖样式表里的max-height: 500px。动画依然是平滑的,因为最终高度是实际高度,不会再截断。
function expandGroup(group) { const list = group.querySelector('.menu-group__list'); const actualHeight = list.scrollHeight; // 先设置一个过渡的起始值 list.style.maxHeight = '0px'; // 强制浏览器回流,确保过渡能正确触发 void list.offsetHeight; // 设置目标高度 list.style.maxHeight = actualHeight + 'px'; }这里有一个浏览器动画触发的知识点。连续修改两个样式值,浏览器可能会把两次操作合并,导致动画不触发。中间加一行void list.offsetHeight,强制浏览器完成一次样式计算和回流,这样第二次修改就能正确触发过渡。这也是我在实际开发中踩过的一个坑——刚开始写的时候动画不生效,排查了半天才发现是漏掉了强制回流。
收起的时候,把max-height重置为0。等过渡结束后,再把内联样式清掉,避免内联样式干扰后续的展开操作:
function collapseGroup(group) { const list = group.querySelector('.menu-group__list'); list.style.maxHeight = '0px'; // 可选:动画结束后清理内联样式 list.addEventListener('transitionend', function handler() { list.style.maxHeight = ''; list.removeEventListener('transitionend', handler); }); }scrollHeight测量的是内容实际总高度,不需要知道具体子项数量,用起来很省心。这个动态方案替代了静态CSS里的max-height上限逻辑,实际体验好很多。
5.2 选中高亮的唯一性维护
手风琴只允许一个分组展开,但选中高亮是另一套状态:无论分组收不收起,都应该有且只有一个子菜单项处于高亮状态。我在维护高亮时遵循一个原则——高亮状态不受展开状态影响,它单独维护。
点击子菜单项时的逻辑:
document.querySelector('.sidebar-nav').addEventListener('click', function (e) { const menuLink = e.target.closest('.menu-item a'); if (menuLink) { // 清除所有子项的高亮 document.querySelectorAll('.menu-item').forEach(function (item) { item.classList.remove('is-active'); }); // 当前点击的子项高亮 menuLink.parentElement.classList.add('is-active'); // 同步更新所属分组的箭头高亮(可选) const group = menuLink.closest('.menu-group'); if (group) { document.querySelectorAll('.menu-group').forEach(function (g) { g.classList.remove('has-active'); }); group.classList.add('has-active'); } } });由menu-item(li标签)来持有is-active类,而不是直接加在<a>标签上。这样CSS选择器可以写.menu-item.is-active a,避免<a>自带的默认样式干扰。如果直接把类加在a上,那border-left和background也可以写,但后续如果想在li层面做其他处理,或者配合伪元素,结构上的余地会小很多。
5.3 支持键盘操作与基础无障碍
很多人做菜单交互会忽略键盘可达性。手风琴菜单要满足基本的键盘操作,至少需要做到:
- 分组标题可以聚焦(
tabindex="0"或使用button标签) - 回车或空格触发展开收起
- 当前选中的子菜单项可以通过Tab键进入
最简单的改造是把分组标题从div换成button(或者加上role="button"和tabindex="0")。用button的语义最好,浏览器自带聚焦和按键触发能力。样式上需要重置button的默认边框和背景:
.menu-group__header { display: flex; align-items: center; justify-content: space-between; width: 100%; border: none; background: none; padding: 14px 16px; cursor: pointer; font: inherit; color: inherit; text-align: left; }配合键盘事件:
menuHeader.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleGroup(menuHeader.parentElement); } });这一步做不做,取决于项目对可访问性的要求。但即使不要求,使用button也比div更稳妥,因为屏幕阅读器能正确朗读出"按钮"这个角色,用户能理解它可点击。
5.4 将实现封装成可复用函数
在真实项目里,侧边栏可能不止一个,菜单数据也可能来自接口。如果只是写一个一次性脚本,后面维护会很难受。我建议把手风琴的核心逻辑封装成一个简单的初始化函数,接收容器选择器和配置参数:
function initAccordion(containerSelector, options) { const container = document.querySelector(containerSelector); if (!container) return; const defaultOptions = { activeClass: 'is-active', expandedClass: 'is-expanded', headerSelector: '.menu-group__header', groupSelector: '.menu-group', listSelector: '.menu-group__list' }; const settings = Object.assign({}, defaultOptions, options); container.addEventListener('click', function (e) { const header = e.target.closest(settings.headerSelector); if (!header) return; const group = header.parentElement; const isExpanded = group.classList.contains(settings.expandedClass); container.querySelectorAll(settings.groupSelector).forEach(function (item) { item.classList.remove(settings.expandedClass); const list = item.querySelector(settings.listSelector); if (list) list.style.maxHeight = '0px'; }); if (!isExpanded) { group.classList.add(settings.expandedClass); const list = group.querySelector(settings.listSelector); if (list) { list.style.maxHeight = list.scrollHeight + 'px'; } } }); }封装之后,线上使用只需要一行:
initAccordion('.sidebar-nav');后续如果菜单结构变了(比如从ul/li变成div层级),只需要调整传入的选择器即可。这种程度的封装对中小项目已经足够,没有必要上升到类或组件的复杂度。
6. 常见问题与排查技巧实录
6.1 点击箭头没反应,但点文字有反应
这个问题常见于箭头被单独包裹了一层元素,closest('.menu-group__header')匹配到了外层标题,但事件冒泡路径中出现了stopPropagation,或者某个中间的兄弟元素遮挡了点击。排查步骤先看控制台有没有JS报错,再看箭头元素是否被其他元素覆盖(z-index问题),最后检查事件监听里是否有e.stopPropagation。
我的经验是:菜单里尽量不要写stopPropagation,事件委托本身就是靠冒泡工作的,中途掐断会带来一堆隐藏问题。
6.2 展开动画不生效,面板瞬间打开或关闭
动画不生效通常是两个原因。一是max-height和目标高度之间没有过渡所需的"中间状态"——最常见的就是修改样式时没有触发回流,浏览器直接把两次修改当成了一次,表现为瞬间变化。二是有其他CSS规则把transition覆盖掉了,比如全局样式里写了* { transition: none; },或者某个更具体的选择器优先级更高。
排查时用开发者工具选中正在动画的元素,查看计算样式里的transition-property和transition-duration是否正常。如果一切正常还是瞬间切换,就按我前面写的,在两段样式修改之间加void element.offsetHeight强制回流。
6.3 多个分组同时展开,手风琴失效了
如果发现点击新分组时,旧分组没有被收起,多半是querySelectorAll('.menu-group')和当前容器的作用域不匹配。如果页面里有多个侧边栏或者复用了同一套样式类名,querySelectorAll会命中所有匹配元素,操作范围超出了当前容器。
解决方法是把操作的上下文限定在当前的container内。我在封装版本的initAccordion里就是用的container.querySelectorAll(settings.groupSelector),这样即使页面上有其他菜单也不会互相干扰。
6.4 刷新后展开状态丢失
前面写了restoreFromCurrentPage函数,但常见的问题是——如果当前路径匹配的逻辑没有写好,或者路径带了查询参数导致精确匹配失败,就会导致刷新后找不到对应菜单。我建议匹配时忽略查询参数,或者允许最后一个路径段匹配:
const currentPath = location.pathname.replace(/\/$/, ''); const linkPath = link.getAttribute('href').replace(/^\//, '').replace(/\/$/, '');具体匹配规则可以根据项目的路由风格来定,但要确保和链接生成的规则完全一致,否则就会出现"菜单看起来是死的"的体验。
6.5 快速连点时面板和箭头状态不一致
用户快速连续点击多个分组时,多个展开/收起的过渡动画可能同时进行。在这种场景下,前一个分组的transitionend事件可能还没来得及执行,新的点击已经让状态变化了。解决思路是给每个分组记录一个"点击锁",或者每次操作前先清理所有分组正在进行的transitionend监听。
实际项目里我用的最简单方案是:展开时立即更新类名和箭头状态(类名的变化是瞬时的),max-height动画只是视觉效果。这样即使动画被中断,状态始终是对的,箭头和面板的展开状态就不会出现矛盾。
7. 一点实操心法分享
写这个手风琴效果的过程中,有一个体会比较深:状态管理越简单,出bug的概率越低。手风琴看起来简单,但如果把"展开状态""箭头方向""选中项""自动恢复"当成四个独立的状态去分别维护,代码很快就会失控。这次实现的思路是把箭头的视觉完全交给CSS派生,JS只管一个is-expanded类和一个is-active类。状态源变少了,联动也就顺了。
另外一个实用的技巧是,调试动画时建议用开发者工具的性能录制功能,看每一帧的样式计算情况。手风琴展开动画的一次样式计算量很小,但如果菜单项非常多,且样式里写过重的box-shadow或filter,动画就可能出现掉帧。菜单这类高频交互组件,CSS能用transform和opacity实现的效果,尽量别用height和top硬调,可以把展开动画改为transform: scaleY配合transform-origin: top,不过max-height方案胜在实现简单,满足大多数场景。
最后再分享一个规划上的建议。如果你是在新项目里第一次做侧边栏,可以先把这个手风琴的交互逻辑单独抽成一个模块,不要跟具体的路由、权限耦合在一起。好的菜单交互是通用的,把它做干净了,后续接真实数据也好,换主题也好,都能省下不少事。