直接上手写吧,这个需求做后台管理系统、文档站、带分类导航的官网时几乎天天遇到。侧边栏手风琴加个选择小箭头,听起来简单,真做起来坑不少——展开动画怎么算高度、箭头朝向怎么联动、点击父级会不会误触子级、快速连点会不会动画打架,每一样都值得琢磨。这篇文章把原生JavaScript实现的完整思路、全部代码和踩坑记录都整理出来,前端新手能照着敲一遍跑通,有经验的也可以看看高度计算和事件委托那几段,算是常规文档里不会写的部分。
1. 项目概述与需求拆解
1.1 这个效果到底是什么
所谓侧边栏手风琴效果,简单说就是侧边导航里有一组可展开收起的菜单组:父级菜单点击之后,下面的子菜单滑出来;再点另一个父级,当前展开的自动收起,始终保持最多只有一个面板处于展开状态。这个交互在交互设计里叫手风琴(Accordion),因为它像手风琴的风箱一样,一个褶子拉开,另一个褶子必然合上。
配合"带选择小箭头"这个需求,每个父级菜单右侧还要有一个小箭头图标。收起状态箭头朝右(或朝下,看设计稿习惯),展开状态箭头旋转90度朝下(或朝上),用户扫一眼就知道哪些菜单能点、哪些菜单当前是张开的。这个箭头如果纯用CSS写死状态倒是容易,难点在于它要和展开收起动作联动,还得配合动画过渡,旋转不能突跳。
这类导航在后台管理项目里几乎是标配。我之前做过一个模拟项目X,左边栏一级菜单七八个,每个下面挂三到八个子页面,如果所有菜单全部平铺展开,整个侧边栏有一米多长,用户找功能全靠滚动,体验相当差。改成手风琴之后,同一屏最多看到两组菜单,导航清晰度提升非常明显。不过这个效果也有一点天然的妥协:从A子菜单跳到B子菜单,必须先收起A再展开B,多了半秒钟。如果你们的菜单层级很深、用户高频跨组切换,建议手风琴还是慎重,那种场景更适合多级展开都保留的方式。
1.2 为什么要用原生JS实现
现在做前端,随便一个项目大概率都带着Vue、React这类框架,或者用现成的组件库,里面手风琴组件现成就能用。那为什么还要花力气写原生JS版本?
两个原因。第一,很多轻量场景根本不需要框架。比如一个纯静态的文档站、一个内嵌在活动页里的帮助中心、一个GitHub Pages上的个人导航页,如果只是为了一个手风琴就引一套框架或者一个几十KB的UI库,代价远大于收益。原生JS版本整个加起来不到几KB,打开就渲染,不存在框架加载白屏的问题。
第二,理解原生实现能让你在框架里也游刃有余。手风琴的核心逻辑——管理当前打开项的索引、控制面板高度、处理动画结束边界——这套思想在Vue里就对应一个currentIndex变量加v-show或v-if,在React里就是useState加条件渲染。你如果只看过组件库的Collapse组件,不知道它内部干了什么,遇到定制需求(比如展开时先从接口拿数据、收起时清空内容)依然无从下手。把底层的原理吃透了,用框架只是换了一层语法壳子。
2. 整体设计与核心逻辑拆解
2.1 手风琴交互的几个关键状态
先把要管理的数据模型理清楚。一个典型的手风琴侧边栏,数据上就是两层结构:父级菜单项(有标题、有箭头、可能还有图标),子级菜单项(只是普通的可点击链接)。页面视觉上则是三层:.sidebar是整个侧边栏容器,.menu-item是每个父级项,.submenu是子菜单容器。
交互状态归结起来就三个:
- 哪个父级是激活状态(高亮背景或文字变色)
- 哪个面板当前展开(显示子菜单)
- 箭头朝哪个方向(跟随展开状态旋转)
这三个状态其实指向同一个数据源:当前展开项的标识。只要把"当前展开项"这一个值管好了,高亮和箭头都可以从这个值推导出来。很多初学者写的时候会在DOM上同时改好几个class,每个class各自为政,结果出现高亮说A展开了、箭头说B展开了、面板打开的是C这种混乱状态。所以设计时一定要明确:单一数据源,其他的状态一律用它派生。
2.2 展开收起的动画方案对比
面板展开收起的动画是手风琴最核心的技术点,方案大体有四种,我都试过,直接说结论。
第一种,display:none切换到display:block。简单但没有任何过渡,面板瞬间出现瞬间消失,体验生硬,直接排除。
第二种,max-height过渡。给面板设一个足够大的max-height,展开时从0过渡到这个值。代码非常好写,但有个老毛病:从0过渡到很大值(比如1000px)时,动画速度在视觉上前快后慢,过渡结束后会有一段"卡住不动但过渡还在跑"的时长,观感一般。而且精确计算动画时长很别扭。不推荐作为首选。
第三种,grid-template-rows: 0fr过渡到1fr。这是比较新的纯CSS方案,不需要JavaScript计算高度,配合min-height: 0就能实现真正的高度从0到auto的平滑过渡。浏览器支持已经相当好了,简洁优雅,唯一的代价是原理不太直观,部分老浏览器不认,在支持范围要求很严的项目里得谨慎。
第四种,JavaScript动态获取scrollHeight,再通过过渡到精确像素值。这是我实际项目里用得最多的方案,也是这篇博文写的方法。它动画匀速、结束干净,兼容性无死角,唯一要求是展开的瞬间必须拿到面板的实际高度。如果面板内容是动态加载的(比如菜单是接口返回的),需要在数据渲染完成之后再去读高度,这个时序问题下文会单独讲。
2.3 箭头联动的设计思路
箭头用一个独立的span元素,里面放一个SVG路径或者Unicode字符都行。它在CSS里默认有transition: transform 0.25s,收起状态rotate(0deg),展开状态rotate(90deg)或者rotate(180deg)——旋转角度取决于设计稿里箭头初始朝哪个方向。朝右的箭头(表示"可展开")转90度变朝下,朝下的箭头(表示"收起")转180度变朝上。
这里有一个小讲究:箭头朝向变化的同时,父级菜单的文字和背景颜色最好也同步变化,给用户的信息是"这组菜单当前是活的"。视觉反馈双通道——颜色加箭头旋转——用户不需要思考就能明白当前状态。另外箭头本身最好也有独立的pointer-events: none,避免点击箭头的命中区域和单击父级的事件冲突。
3. HTML结构与CSS样式详解
3.1 HTML结构的三个层级
先把结构写出来,这是整篇文章后面所有逻辑的基础。我习惯用语义化标签加BEM类名,既清楚又方便后面用querySelector选中节点。
<aside class="sidebar"> <ul class="menu"> <!-- 一级菜单1 --> <li class="menu-item">.sidebar { width: 260px; background-color: #f7f8fa; border-right: 1px solid #e5e7eb; height: 100vh; overflow-y: auto; } .menu { list-style: none; margin: 0; padding: 12px 0; } .menu-item { margin-bottom: 2px; } .menu-title { display: flex; align-items: center; gap: 8px; padding: 12px 16px; cursor: pointer; user-select: none; border-radius: 4px; margin: 0 8px; transition: background-color 0.2s; } .menu-title:hover { background-color: #eceef1; } .menu-icon { width: 18px; text-align: center; } .menu-text { flex: 1; font-size: 14px; color: #374151; } .menu-arrow { font-size: 12px; color: #9ca3af; transition: transform 0.25s ease; pointer-events: none; } .menu-item.active .menu-arrow { transform: rotate(90deg); } .menu-item.active .menu-text { color: #1f6feb; font-weight: 600; } .submenu { margin: 0; padding: 0; list-style: none; overflow: hidden; transition: height 0.3s ease; } .submenu-link { display: block; padding: 10px 16px 10px 42px; font-size: 13px; color: #4b5563; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: background-color 0.15s; } .submenu-link:hover { background-color: #e5e7eb; color: #1f6feb; }仔细看两个关键点。
一个是.submenu的transition: height 0.3s ease。因为子菜单高度变化不是通过display切换而是通过height属性从0过渡到具体像素值,浏览器才能驱动出动画。如果我把height设置到子菜单容器上时忘了overflow: hidden,就会出现过渡期间子菜单内容直接溢出来的效果,面板明明还是0高度,字已经飞在外面了。
另一个是.menu-item.active .menu-arrow。箭头旋转不是直接加在箭头自身class上,而是作为子选择器挂在父级.menu-item.active下。这样JavaScript里只需要切换父级的active类,箭头、文字颜色、加粗这三个视觉状态全部跟动,做到了前面说的单一数据源派生。
3.3 CSS里没写完的细节
还有几个样式上的经验补充。如果实际项目里子菜单数量特别多(我见过一个系统设置菜单挂了20个子项),0.3秒的过渡时长会显得拖沓,建议按内容量调整:子项少用0.2秒,子项多用0.35秒,视觉节奏更舒服。另外如果侧边栏整体很长且overflow-y: auto,展开面板时如果当前面板超出视口底部,用户看不到新展开的内容——这个体验问题有两种解法:让整体容器滚动到目标位置,或者干脆把侧边栏设计成height: 100vh但菜单本身节点也有滚动,展开时手动scrollIntoView。后面会在排查篇里写。
颜色值方面,我这里用的是偏中性的灰蓝配色。实际上手风琴项目大多不需要花哨,重点是把层级对比做清楚:父级标题比子级链接在视觉上更重(更深的颜色、可能加粗),子级更轻(浅灰、小字号)。如果设计稿要求父级有背景底色块(比如选中父级整个块高亮),直接给.menu-title加背景色即可,过渡动画同步。
4. JavaScript核心逻辑与完整代码实现
4.1 事件委托:只绑一个监听器
直接操作DOM,最直观的写法是给每个.menu-title都绑定一个addEventListener。但这种写法在菜单项数量多、或者菜单动态增删的时候有两个问题:一是监听器数量多,虽然几十个也谈不上性能瓶颈,但心理上就不清爽;二是动态新增的菜单项如果忘记绑事件,点击就完全没反应,排错成本高。
事件委托才是这类可交互列表的正确姿势。原理是事件冒泡:点击子元素时,事件冒泡到容器上,我们在容器层判断点击目标是否是需要的元素。这样无论菜单项是初始渲染还是后来动态添加,只要冒泡能到达容器,事件处理逻辑就能生效。
document.querySelector('.sidebar').addEventListener('click', function (event) { const title = event.target.closest('.menu-title'); if (!title) return; const menuItem = title.closest('.menu-item'); toggleMenuItem(menuItem); });这里用了closest方法,它从事件目标向上逐级查找匹配选择器的祖先节点。好处是哪怕用户点击的是箭头span、图标span还是文字span,都能正确找到对应的.menu-title,不需要给每个内部元素分别判断。常见的一个坑是,如果event.target恰好是li本身(比如点击了menu-item的空白间隙),closest找不到.menu-title就直接返回,不影响。
4.2 核心函数:展开与收起的完整逻辑
接下来是手风琴最核心的切换函数。我会把逻辑拆成几个小函数,每个函数只干一件事,排查问题时不用通读整个文件。
function toggleMenuItem(currentItem) { const isActive = currentItem.classList.contains('active'); // 1. 先收起当前所有展开的菜单 const activeItems = document.querySelectorAll('.menu-item.active'); activeItems.forEach(function (item) { if (item !== currentItem) { collapseItem(item); } }); // 2. 如果当前菜单本来就是展开的,点击后收起它 if (isActive) { collapseItem(currentItem); return; } // 3. 否则展开当前菜单 expandItem(currentItem); } function expandItem(item) { const submenu = item.querySelector('.submenu'); const targetHeight = submenu.scrollHeight; // 先把高度设为固定值,浏览器才能驱动过渡动画 submenu.style.height = targetHeight + 'px'; // 可访问性:同步展开状态 const title = item.querySelector('.menu-title'); title.setAttribute('aria-expanded', 'true'); item.classList.add('active'); } function collapseItem(item) { const submenu = item.querySelector('.submenu'); const currentHeight = submenu.offsetHeight; // 先把height从固定值改回0,触发过渡 submenu.style.height = '0px'; const title = item.querySelector('.menu-title'); title.setAttribute('aria-expanded', 'false'); item.classList.remove('active'); }这个方法里有几个值得展开说的点。
submenu.scrollHeight返回的是元素内容的完整高度,哪怕它的客户端高度是0。也就是说,在面板完全收起(height: 0)的情况下,用scrollHeight依然能读到内容撑开后的完整高度值,这是展开动画能算出目标高度的前提。如果面板里还有margin折叠、边框等干扰因素,scrollHeight会把这些算进内容高度,拿到数值后实际过渡看起来会稍微偏高或偏低,一般影响不大。如果确实出现偏差,比如多出几像素空白,可以在计算后减去补偿值。
把height从auto改成具体像素值的时候,过渡才能发生。第一帧浏览器还不知道目标高度,必须等style.height被设置为具体数值后,下一帧才能从旧值过渡到新值。但这里有个顺序陷阱:如果你先设置了height: auto,紧接着同一帧内又改成scrollHeight像素值,浏览器会当成同一次样式更新,不会触发动画。这就是为什么展开前不经过auto而是直接从0到scrollHeight;收起时也不需要先切auto,直接从当前像素值到0。
4.3 箭头旋转与状态同步的联动细节
前面已经提到,箭头旋转靠.menu-item.active下的CSS子选择器完成,所以JavaScript处理展开收起时只需要增删active类。这里有一个容易忽略的联动细节:展开一个菜单时,因为手风琴特性,其他菜单会被collapseItem收起来,它们的active类也被移除,箭头自动转回初始方向。这一整套联动全部由CSS派生状态完成,JavaScript没有直接操作任何一个箭头元素的样式。
这个设计的价值在需求变更时特别明显。比如某天产品经理说"收起状态的箭头从朝右改成朝下,展开时转180度朝上",你只需要改一行CSS的transform值,完全不用动JavaScript。如果当时在JS里用element.style.transform = ...硬编码了旋转角度,那就得把所有赋值点全找出来改一遍,还容易漏。
还有一个小细节我实际项目中吃过亏:如果transform的旋转角度和动画方向不配,会出现箭头"转了个弧线但是绕到背后再出来"这种视觉异常。解决方法是先把箭头初始方向定好,再确定旋转的终点。朝右箭头(rotate(0deg))展开时转90度变成朝下,方向是顺时针,这个自然顺畅。如果你的箭头是字符▼(朝下三角),展开时转到180deg变成朝上,视觉上是绕了半圈,其实也可以用,只是对用户来说"朝下收起、朝上展开"这种隐喻容易混淆,建议保持"箭头指向当前面板展开方向"这种直觉一致性。
4.4 初始化:默认展开项与URL哈希联动
实际项目里侧边栏不可能全部收起光秃秃地展示,通常要求首次进入页面时某个菜单默认展开,而且往往要跟当前路由联动:用户访问/content/articles,那内容管理这个菜单就应该自动展开并高亮当前子项。
function initSidebar() { const currentPath = window.location.pathname; const menuItems = document.querySelectorAll('.menu-item'); menuItems.forEach(function (item) { const links = item.querySelectorAll('.submenu-link'); let shouldExpand = false; let shouldHighlight = false; links.forEach(function (link) { if (currentPath.startsWith(link.getAttribute('href'))) { shouldExpand = true; shouldHighlight = true; link.classList.add('current'); return; } }); if (shouldExpand) { expandItem(item); } }); } initSidebar();初始化时先全部展开匹配路由的菜单(手风琴逻辑会自动把不匹配的收起来),再加子项高亮。startsWith判断路由层级是通常做法:访问/content/articles/detail时,/content/articles对应的链接会命中,文章列表这个子项高亮。
这里有一个浅坑:如果两个菜单的子项URL存在包含关系,比如系统设置里有/system/users、/system/users/roles,用startsWith会导致两个子项同时命中。解决办法是匹配到之后立即break或者按URL长度优先级排序,取最长匹配。代码里我用了return,但回调函数里的return只是结束当前迭代,并不会中断forEach,所以如果出现这种歧义场景,建议换成普通的for循环并在命中后break。这个后续细说。
4.5 动画结束后的收尾处理
高度过渡结束后有一个很容易被忽略的细节:面板的height此时停留在scrollHeight像素值上。如果用户调整浏览器窗口宽度导致面板内容换行、高度变化,又或者子菜单内容是异步加载的(比如点击父级时才从接口拉取子菜单列表),新增内容后height固定值并不会自动跟随内容变化,面板就会多出滚动条或者内容截断。
解决办法是在过渡结束后把height恢复为auto。监听transitionend事件:
submenu.addEventListener('transitionend', function (event) { if (event.propertyName === 'height') { submenu.style.height = 'auto'; } });这里监听的是height属性的过渡结束。之所以要在transitionend里判断event.propertyName === 'height',是因为面板里子链接如果有背景色过渡、字体颜色过渡,这些属性的transitionend也会在这个容器上冒泡,不限定height就会出现误操作。恢复成auto之后,面板高度就是内容自然撑开的,窗口变化、数据追加都不会露馅。而且因为动画已经播完了,从像素值瞬间切到auto不会产生视觉跳变。
这个收尾逻辑同样要考虑动态内容场景:展开面板时先展示加载中占位、等接口返回真实菜单后才算出真实高度。如果接口还没返回就执行了展开动画,scrollHeight读到的是占位符高度,等菜单列表渲染进去面板却没变高。这是手风琴搭配异步数据时的经典问题,排查篇会专门写。
5. 完整可运行示例与参数计算过程
5.1 把代码合到一起跑起来
前面分模块讲的是独立函数,实际跑起来还需要事件委托监听器和初始化逻辑。把它们整合成一个完整的HTML文件,方便直接验证。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>侧边栏手风琴效果示例</title> <style> /* 样式部分:见 3.2 节,此处整合省略局部注释 */ * { box-sizing: border-box; } body { margin: 0; font-family: system-ui, -apple-system, sans-serif; } .layout { display: flex; min-height: 100vh; } .main { flex: 1; padding: 24px; background: #fff; } </style> </head> <body> <div class="layout"> <aside class="sidebar"> <!-- HTML结构:见 3.1 节 --> <ul class="menu"> <li class="menu-item">function loadSubmenuAndExpand(item, apiUrl) { const submenu = item.querySelector('.submenu'); fetch(apiUrl) .then(function (res) { return res.json(); }) .then(function (menus) { renderSubmenuItems(submenu, menus); requestAnimationFrame(function () { submenu.style.height = submenu.scrollHeight + 'px'; }); }); }requestAnimationFrame在这里的作用是确保渲染完成后再读高度。即便数据已经追加到DOM,浏览器也需要一帧时间去计算布局,下一帧才能拿到准确的scrollHeight。
另一个隐蔽场景:面板当前height为auto时,我直接去读scrollHeight,这个值也可能是auto状态下的实际内容高度,通常没问题。但如果在height: 0时去读,得到的依然正确,因为scrollHeight在overflow: hidden下也能读到完整内容高度。两种状态读出来的结果应该一致,如果不一致,往往就是内容里有图片或异步字体导致高度在图片加载前后不同,需要监听图片的load事件再触发重新计算。
6.2 箭头旋转方向错乱或动画冲突
箭头方向错乱一般是CSS选择器写错,或者active类加错了元素。我在排查时第一件事就是打开Elements面板,看当前展开的menu-item是否带着active类,箭头span的计算样式里transform是否按预期变化。如果类是对的但箭头没转,那就是CSS选择器的问题——检查是不是写成了.menu-arrow.active而不是.menu-item.active .menu-arrow。
还有一个隐蔽问题:如果父级菜单本身有transition(比如背景色过渡),而箭头又有transform过渡,在性能受限的机器上同时触发多个属性动画可能出现轻微的掉帧甚至方向抖动。这倒不是逻辑错,只是性能问题。解法是给箭头和面板的过渡都加上will-change: transform(箭头)和will-change: height(面板),但注意will-change不能滥用,用完应该随手去掉,否则内存占用会上升。对于侧边栏这种一次性挂载且常驻的元素,保留will-change也不算大问题。
6.3 初始化后所有面板状态错乱
典型表现:页面加载时应该自动展开匹配路由的菜单,结果所有面板一会儿开一会儿关,或者高亮和展开不匹配。排查步骤优先看初始化函数执行的时机。如果initSidebar()被放在了DOMContentLoaded之前执行,比如script标签还在head里,此时querySelectorAll什么都选不中,初始化自然无效。解法是把script放到body底部(示例代码就是这么做的),或者显式监听DOMContentLoaded。
第二个可能原因是哈希值和链接href存在前缀匹配冲突。前面提过,用startsWith判断时两个菜单的子项URL有包含关系,会导致两个菜单同时命中。此时expandItem被连续调用两次,第二个菜单展开时第一个还没完成,手风琴的收起逻辑会让画面闪烁。切换成最长匹配策略:
let bestMatchLink = null; let bestMatchLength = 0; links.forEach(function (link) { const href = link.getAttribute('href'); if (hash.startsWith(href) && href.length > bestMatchLength) { bestMatchLength = href.length; bestMatchLink = link; } }); if (bestMatchLink) { expandItem(item); bestMatchLink.classList.add('current'); }第三个原因就比较蠢了,但也常见:初始化之后手动刷新了菜单数据,把原有的height: auto状态覆盖成0,导致打开状态异常。这类问题的核心就是状态管理的一致性,所有对DOM的修改尽量收口到expandItem和collapseItem两个函数里,不要在外面散落地改。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 点击菜单完全没反应 | 事件委托没绑定成功,或script在DOM前执行 | 检查.sidebar是否选中正确,控制台有没有报错 |
| 面板瞬间展开/收起,无动画 | height从auto直接切到像素,或transition没生效 | 查看计算样式中transition属性是否被覆盖 |
| 展开后内容截断 | scrollHeight读早了或height未恢复auto | 检查异步数据渲染时序、transitionend回调 |
| 箭头方向反了 | CSS里transform旋转角度不对 | 调整rotate值为90deg还是-90deg |
| 高亮菜单同时出现了多个 | startsWith匹配了包含关系 | 改成最长URL匹配并break |
| 快速连点出现动画闪跳 | 手风琴中途切换目标 | 正常现象,可加transition:none瞬间复位优化 |
| 侧边栏很长时展开下面菜单看不全 | 面板超出视口 | 展开时调用menuItem.scrollIntoView({ block: 'nearest' }) |
6.5 我踩过的一个典型坑
之前做过一个某跨平台系统的前端改造,侧边栏菜单从接口动态加载,每条菜单点击后先弹加载动画再渲染子项。当时我按常规思路先把expandItem做完了再去请求数据,结果接口较慢时面板先以加载占位的高度展开,数据回来之后高度又不更新,子菜单全堆在一起。
最后解决方案是调整了触发顺序:点击父级时先显示面板里的加载态简洁文案,同时把面板高度临时设成大概值(比如56px),等接口返回后渲染完整子菜单,再用requestAnimationFrame重算scrollHeight并设置面板高度。整个过程面板始终是展开状态,只是从"加载中的矮面板"变成"内容完整的高面板",动画是二次展开的,用户体验上完全自然,没有出现先开一个高度再跳到另一个高度的突兀感。
这个案例给我的教训是:手风琴的scrollHeight不是一次性读物,只要内容可能变化,就必须在每次内容变化后重新计算。把弹性的height: auto留在动画结束后,是保护这道防线的最好方式。
7. 扩展思路:从手风琴到更多侧边栏交互
手风琴只是侧边栏众多交互形态中的一种,做完基础版之后,有几个很自然的扩展方向值得考虑。
第一个是手风琴加记住状态。用户展开某组菜单、刷新页面后回到默认路由的展开状态,如果用户手动切换过,下次进页面该记住用户的选择。实现上就是在toggleMenuItem里往localStorage写一个sidebar-expanded-key,初始化时优先读这个值而不是路由匹配。注意给存储的key加上版本号或前缀,避免多个项目共用一个域名的localStorage时相互干扰。
第二个是支持多级展开的手风琴模式。有些设计系统里"手风琴"特指只保留一个展开项,但也有些后台需要"同一时刻可以展开多组菜单"。这个改动非常小:在toggleMenuItem里把"收起所有其他项"那段循环注释掉,就变成了多开模式。实际项目中建议把这个行为做成配置项,一个布尔值控制是否互斥,平时维护起来相当舒服。
第三个是箭头动画升级。现在的箭头用字符加CSS旋转,视觉效果朴素但可靠。如果产品想要更精致的箭头(比如一段带圆角的小折线),可以用一个内联SVG替换字符,旋转逻辑完全不变。SVG的transform旋转和HTML元素的transform在CSS书写上没有区别,改动成本几乎为零。
第四个是侧边栏整体收起为图标模式。很多系统的用户习惯是"平时侧边栏收成只有图标的一窄条,鼠标移上去展开"。手风琴和这种模式不冲突:窄条模式下所有面板收起只显示图标和箭头;展开为宽栏时恢复手风琴的展开状态。切换时的主要成本在重新计算面板高度——因为宽度变化会导致文本换行,从而影响每一项的scrollHeight。宽度动画结束后触发一次全量高度刷新就能解决。
我个人实际项目中的体会是,手风琴侧边栏看着简单,但它蕴含的单一数据源、状态派生、动画边界处理这三个思想,几乎能复用到所有交互组件上。把这三件事想通,再做下拉菜单、标签页、折叠面板,代码质量都会有明显提升。
另外再分享一个小技巧:写完这部分代码后建议直接用浏览器开发者工具的"设备工具栏"模拟窄屏,侧边栏在350px宽度下的菜单项高度通常比桌面端高一倍,很多手风琴在窄屏下展开时会因为文本溢出出现换行、导致高度计算出错。提前测到的话,后面省得返工。
这个效果后续如果还要扩展,优先级排序应该是:记住用户展开状态(体验提升立竿见影)、异步菜单加载时序优化(数据管理系统的标配)、窄屏适配(移动端后台访问越来越常见)。按这个顺序做,投入产出比最高。