资讯动态

原生JS手风琴效果实现:高度过渡与状态管理详解

发布时间:2026/10/24 4:52:04 来源:尧图企业网站定制
手风琴效果这个交互说它是前端入门必练的老三样之一一点不为过——另外两个是轮播图和选项卡。但有意思的是很多人写了三五年业务代码回头再手写一个手风琴依然会在高度过渡、事件委托、状态同步这几个地方卡壳。我见过太多项目里直接引一个UI库的Collapse组件了事能用是能用可一旦设计稿要求展开时图标旋转90度、内容区高度自适应、同时只允许一个面板展开、点击已展开的面板要能收起就得翻文档改配置改到最后还不如自己撸一个来得痛快。这篇东西就是围绕原生JS实现手风琴效果这个主题展开的。我会拿一个旅游类页面常见的目的地攻略折叠面板作为模拟场景从零把结构、样式、交互逻辑一层层拆开讲清楚。适合两类人看一类是刚学完DOM操作、想找个完整案例练手的朋友另一类是有一定经验、但想搞清楚高度过渡到底该怎么写才不抖的老手。全程不依赖任何框架和第三方库纯原生JS加CSS代码可以直接复制到本地跑起来。我先把结论摆前面手风琴效果的核心难点根本不在点击展开这个动作本身而在于高度从0到auto的过渡以及多个面板之间的状态互斥管理。把这两件事想明白了剩下的都是体力活。下面按我实际开发的顺序往下走。1. 先想清楚手风琴到底在解决什么交互问题1.1 从旅游攻略页面的信息密度说起假设你手上有一个目的地详情页里面塞了景点介绍交通指南美食推荐住宿建议注意事项五个板块。如果全部平铺展开用户得一直往下滚滚到第三屏可能就忘了上面看过什么如果全部收起只留标题用户又得一个个点开才能判断哪个板块有自己要的信息。这两种极端都不好。手风琴效果本质上是给信息做分层收纳标题行始终可见充当目录内容区按需展开控制单屏信息量。用户扫一眼五个标题就知道页面大概有什么点开哪个看哪个。这个交互模式在移动端尤其吃香因为屏幕窄、滚动成本高折叠能显著降低用户的浏览负担。我在实际项目里总结出一个判断标准当一组内容的标题本身就能传达足够信息、且用户通常只需要看其中一两个板块时就适合用手风琴。反过来如果用户需要对比着看多个板块的内容那手风琴就是灾难——他得反复展开收起体验极差。旅游攻略页属于前者用户点开美食推荐看完就走不需要同时盯着交通指南。1.2 手风琴和选项卡、折叠面板的区别这三个东西经常被混为一谈我梳理一下边界。选项卡Tab是同一块内容区域切换不同内容视觉上只有一个容器手风琴是多个容器纵向排列展开的容器把下面的内容往下推折叠面板Collapse则是手风琴的泛化版本允许多个同时展开而手风琴通常约定同时只展开一个。这个区别直接决定了代码结构。选项卡只需要一套内容容器切换时替换innerHTML或者切换display手风琴需要N套独立的内容容器每个容器有自己的高度状态。所以手风琴的DOM结构一定是多个并列的item每个item内部包含标题和内容两部分这个结构定下来后面的逻辑才有地方挂。提示如果产品经理跟你说要手风琴效果但允许同时展开多个别慌那就是Collapse。把互斥逻辑去掉就行代码结构完全一样只是点击时不再关闭其他面板。1.3 为什么坚持用原生JS而不是引组件库这个问题我被问过很多次。组件库的Collapse确实开箱即用但它带来的隐性成本不少一是包体积为了一个折叠效果引入整个组件库不划算二是定制成本设计稿里的动画曲线、图标旋转角度、边框样式往往和组件库默认值对不上改起来要覆盖一堆内部类名三是排查成本出问题时得先搞清楚组件内部怎么实现的再定位是自己的配置问题还是组件的bug。原生实现的好处是每一行代码你都知道它在干嘛。高度怎么算的、过渡加在哪个元素上、事件绑在哪里全在掌控之中。而且手风琴这个交互的复杂度真的不高核心代码一百行以内能写完完全没必要为它引入依赖。我个人的习惯是交互逻辑超过三百行、涉及复杂状态机的才考虑引库手风琴这种量级自己写更划算。2. 结构先行HTML骨架怎么搭才不留后患2.1 一个item的语义化结构先看单个面板的结构。我的习惯是用一个外层容器包住所有item每个item内部再分标题区和内容区div classaccordion idaccordion div classaccordion-item button classaccordion-header typebutton span classheader-text景点介绍/span span classheader-icon/span /button div classaccordion-body div classaccordion-content 这里是景点介绍的正文内容…… /div /div /div !-- 其余item结构相同 -- /div这里有几个细节值得说道。标题用button而不是div是为了天然获得键盘可访问性——用户按Tab能聚焦、按回车能触发不用额外写keydown监听。typebutton是为了防止它出现在form里时误触发提交这个坑我踩过页面莫名其妙刷新查了半天才发现是按钮默认type是submit。内容区我用了两层嵌套外层.accordion-body负责高度过渡和溢出隐藏内层.accordion-content负责装实际内容并撑开自然高度。为什么要分两层因为高度过渡需要一个明确的从0到某个值的过程而内容高度是动态的直接给.accordion-body设height: auto是没法过渡的auto不是可插值的数值。分两层之后内层自然撑开外层通过JS读取内层高度再赋值过渡就有了明确的数值目标。这个设计是后面所有高度逻辑的基础务必理解透。2.2 为什么内容区要包一层而不是直接放文字有人会问直接在.accordion-body里放文字不行吗行但你会失去对内边距的控制精度。假设内容区需要padding: 16px如果padding加在.accordion-body上那么当面板收起、高度设为0时padding依然占着32px的垂直空间面板收不干净会留一条缝。把padding挪到内层.accordion-content上外层只负责高度裁剪收起时高度归零padding跟着被裁掉视觉上才干净。这个细节在调试时特别容易被忽略。我第一次写手风琴时就犯过这个错收起后面板之间总有一条细线查了半小时才发现是padding在作祟。所以记住一条原则负责高度动画的容器不要加padding和border这些装饰性属性统统放到内层。2.3 数据驱动的结构生成思路如果面板数量是固定的五个手写HTML没问题。但实际项目里攻略板块往往是从接口拿的数据数量不固定。这时候更合理的做法是用JS动态生成结构const sections [ { title: 景点介绍, content: 这里是景点介绍的正文内容…… }, { title: 交通指南, content: 这里是交通指南的正文内容…… }, { title: 美食推荐, content: 这里是美食推荐的正文内容…… }, { title: 住宿建议, content: 这里是住宿建议的正文内容…… }, { title: 注意事项, content: 这里是注意事项的正文内容…… } ]; const accordion document.getElementById(accordion); sections.forEach((item, index) { const itemEl document.createElement(div); itemEl.className accordion-item; itemEl.innerHTML button classaccordion-header typebutton span classheader-text${item.title}/span span classheader-icon/span /button div classaccordion-body div classaccordion-content${item.content}/div /div ; accordion.appendChild(itemEl); });动态生成的好处是结构统一、便于后续批量绑定事件。但要注意如果content里包含用户输入的富文本直接拼innerHTML有XSS风险实际项目里得做转义或者用textContent。这里为了演示简洁先这么写心里要有数。3. CSS部分高度过渡的坑几乎全在这里3.1 基础样式与过渡属性的选择先把静态样式铺好.accordion { width: 100%; max-width: 640px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; } .accordion-item .accordion-item { border-top: 1px solid #e0e0e0; } .accordion-header { display: flex; justify-content: space-between; align-items: center; width: 100%; padding: 16px 20px; background: #fafafa; border: none; font-size: 16px; font-weight: 600; color: #333; cursor: pointer; text-align: left; } .accordion-body { height: 0; overflow: hidden; transition: height 0.3s ease; } .accordion-content { padding: 16px 20px; font-size: 14px; line-height: 1.7; color: #666; }关键在.accordion-body那三行height: 0是初始收起状态overflow: hidden保证内容不溢出transition: height 0.3s ease定义过渡。这里过渡的属性必须是height不能写all——写all会把padding、border、color全带上虽然本例里这些属性没变化但一旦后续加了hover变色之类的效果就会产生意料之外的动画排查起来很烦。过渡属性永远写具体这是好习惯。3.2 高度从0到auto为什么不能直接过渡这是手风琴最经典的坑必须单独拎出来讲。CSS的transition只能对可计算的数值做插值height: auto不是一个确定的数值浏览器无法计算从0到auto的中间状态所以过渡会直接失效——面板瞬间弹开没有动画。解决办法有三种我逐一分析。第一种是JS读取scrollHeight再赋值。展开时先设height: auto或者临时设成auto读一次scrollHeight拿到内容真实高度再设回0然后下一帧设成那个高度值过渡就能跑起来。收起时把当前高度设回0。这是最通用、兼容性最好的方案也是我下面要详细展开的。第二种是max-height方案。把高度过渡改成max-height从0过渡到一个足够大的值比如1000px。这个方案代码简单但有个明显缺陷如果内容实际高度只有200pxmax-height过渡到1000px那么动画前80%的时间用户几乎看不到变化因为高度早就超过内容了视觉上就是先快后慢的诡异节奏。而且内容超过预设值时会被裁掉。不推荐。第三种是grid方案。用grid-template-rows: 0fr过渡到1fr这是比较新的写法现代浏览器支持不错代码也优雅。但兼容性上对老版本浏览器不友好如果项目要求兼容较老的环境就得放弃。我一般在新项目里会考虑老项目还是老老实实用scrollHeight。3.3 图标旋转与状态类名的配合标题右侧的号展开时应该旋转成×或者旋转90度变成−。用CSS的transform配合状态类名最省事.header-icon { display: inline-block; font-size: 20px; transition: transform 0.3s ease; } .accordion-item.active .header-icon { transform: rotate(45deg); }旋转45度正好变成×视觉上表达点击可收起比换字符更平滑。这里的状态类名.active加在.accordion-item上而不是加在header或body上是为了让一个类名能同时控制图标旋转、标题背景色、内容区高度等多个子元素的样式逻辑更集中。这个类名同时也会成为JS判断当前面板是否展开的依据一举两得。4. JS核心逻辑状态管理与高度计算4.1 事件委托为什么绑在容器上而不是每个标题上五个面板就有五个标题按钮如果给每个按钮单独addEventListener代码也能跑。但事件委托更优雅而且动态增删面板时不用重新绑定const accordionEl document.getElementById(accordion); accordionEl.addEventListener(click, function (e) { const header e.target.closest(.accordion-header); if (!header) return; const item header.parentElement; toggleItem(item); });closest方法从点击目标向上找最近的.accordion-header这样即使用户点的是标题里的文字span或者图标span也能正确找到按钮。如果不用closest而用e.target.classList.contains(accordion-header)点到内部span时就失效了这是新手常犯的错。事件委托还有一个隐性好处整个容器只挂一个监听器内存占用小而且未来如果面板是异步加载进来的新面板自动就有交互不用额外处理。4.2 展开与收起的高度计算完整实现核心的toggle函数长这样function toggleItem(item) { const body item.querySelector(.accordion-body); const isActive item.classList.contains(active); if (isActive) { // 收起先把当前高度固定住再设回0 body.style.height body.scrollHeight px; // 强制重排确保浏览器记录了当前高度 body.offsetHeight; body.style.height 0px; item.classList.remove(active); } else { // 展开先设auto读真实高度再归零下一帧设为目标高度 body.style.height auto; const targetHeight body.scrollHeight; body.style.height 0px; body.offsetHeight; body.style.height targetHeight px; item.classList.add(active); } }这段代码有几个关键点必须讲透。第一收起时为什么要先设一次scrollHeight因为展开状态下body的height是具体像素值比如320px但用户可能在展开期间改变了窗口大小或者内容动态变化了实际高度可能已经不是320px。先读一次scrollHeight拿到当前真实高度并赋值再设回0过渡的起点才是准确的。如果直接设0起点是旧的像素值可能产生跳变。第二body.offsetHeight这行是干嘛的这是强制重排force reflow。浏览器对样式修改是批量处理的如果你连续写height 0px然后马上height 320px浏览器可能把这两次修改合并只应用最后一次过渡就失效了。读取offsetHeight会强制浏览器立即计算布局把前面的修改落实下来这样后续的修改才能形成过渡的起点和终点。这行代码看起来莫名其妙但删掉它动画大概率会失效是必须保留的。第三展开时先设auto再读scrollHeight。如果body当前是收起状态height: 0直接读scrollHeight能拿到内容高度吗能因为scrollHeight反映的是内容实际占据的高度不受height: 0影响。但为了保险起见先设auto再读能应对某些特殊布局下的计算偏差。读完立刻设回0是为了让过渡有起点。4.3 互斥逻辑同时只允许一个展开手风琴的手风琴三个字精髓就在互斥——展开一个其他自动收起。实现方式是在展开当前项之前先遍历所有已展开的项并收起function toggleItem(item) { const body item.querySelector(.accordion-body); const isActive item.classList.contains(active); // 先收起所有其他展开项 const allItems accordionEl.querySelectorAll(.accordion-item.active); allItems.forEach(other { if (other ! item) { const otherBody other.querySelector(.accordion-body); otherBody.style.height otherBody.scrollHeight px; otherBody.offsetHeight; otherBody.style.height 0px; other.classList.remove(active); } }); // 再处理当前项 if (isActive) { body.style.height body.scrollHeight px; body.offsetHeight; body.style.height 0px; item.classList.remove(active); } else { body.style.height auto; const targetHeight body.scrollHeight; body.style.height 0px; body.offsetHeight; body.style.height targetHeight px; item.classList.add(active); } }这里有个细节收起其他项时也要走先固定当前高度再归零的流程不能直接设0否则其他项的收起动画会跳变。另外如果用户点击的是已经展开的项那它应该收起此时不需要展开任何新项所以互斥逻辑里要排除当前项本身other ! item。4.4 过渡结束后的收尾把height设回auto展开动画结束后body的height是一个固定像素值。这带来一个问题如果用户在这个状态下改变了窗口宽度内容重新排版实际高度可能变了但height还锁在旧值上内容就会被裁掉或者留白。解决办法是监听transitionend事件动画结束后把height设回autobody.addEventListener(transitionend, function handler(e) { if (e.propertyName ! height) return; if (item.classList.contains(active)) { body.style.height auto; } });只在展开状态设回auto收起状态保持0。这样既保证了动画期间有明确数值可过渡又保证了动画结束后高度自适应。e.propertyName ! height这个判断是为了过滤掉其他属性的过渡事件比如图标旋转也会触发transitionend冒泡上来会干扰。注意transitionend事件在过渡被中断时不会触发。如果用户在动画还没结束时快速点击其他面板当前面板的过渡被打断收尾逻辑就不会执行height可能停在中间值。稳妥的做法是在toggle开始时先清理掉可能残留的定时器或状态或者干脆在每次toggle前手动把目标元素的height重置。这个边界情况在实际使用中偶尔会遇到尤其是用户手速快的时候。5. 实测中遇到的几个意外情况和处理办法5.1 快速连点导致的高度错乱这是我在真机上测出来的问题。用户快速连续点击同一个标题展开动画还没跑完就触发了收起此时body的height停在中间值比如150px收起逻辑读scrollHeight拿到的是内容真实高度320px设成320px再归零视觉上会先弹到320px再收起出现明显跳变。处理思路是在每次toggle开始时先把目标元素的height重置到当前计算高度让过渡起点始终准确function getCurrentHeight(el) { return el.getBoundingClientRect().height; }用getBoundingClientRect().height能拿到元素当前渲染的实际高度包括动画中间态。在toggle开始时先body.style.height getCurrentHeight(body) px再走后续逻辑起点就准了。这个细节不处理的话快速点击时体验会很差但慢速点击完全看不出来所以很容易被忽略。5.2 内容里有图片时的异步高度问题如果面板内容包含图片而图片没有设置固定宽高那么展开时scrollHeight读到的是图片加载前的高度图片加载完成后实际高度变大内容就被裁掉了。这个坑在旅游攻略页特别常见因为攻略里全是图。解决办法有两个。一是给图片预设宽高比容器用aspect-ratio或者padding-top百分比占位保证图片加载前后高度一致。二是在图片的load事件里重新计算高度const imgs body.querySelectorAll(img); imgs.forEach(img { if (!img.complete) { img.addEventListener(load, () { if (item.classList.contains(active)) { body.style.height auto; } }); } });我一般两个都用能预设宽高比的预设实在预设不了的比如用户上传的图尺寸不定就靠load事件兜底。纯靠load事件的问题是图片加载慢时用户会看到内容跳一下体验不好所以预设宽高比是首选。5.3 无障碍访问键盘操作和aria属性前面说了标题用button天然支持键盘但光这样还不够。屏幕阅读器需要知道这个按钮控制的是哪块内容、当前是展开还是收起。补上aria属性button classaccordion-header typebutton aria-expandedfalse aria-controlspanel-1 idheader-1 span classheader-text景点介绍/span span classheader-icon aria-hiddentrue/span /button div classaccordion-body idpanel-1 roleregion aria-labelledbyheader-1 div classaccordion-content……/div /divaria-expanded在toggle时同步更新aria-controls指向内容区的idaria-labelledby反向指回标题。图标是纯装饰加aria-hiddentrue让屏幕阅读器跳过。这些属性不影响视觉但对使用辅助技术的用户是刚需。我在实际项目里养成了习惯凡是可交互的组件先把aria属性写全再写逻辑这样不会漏。5.4 移动端点击延迟与触摸反馈移动端浏览器为了判断用户是点击还是双击缩放历史上会有300ms的点击延迟。现在主流浏览器在设置了正确的viewport meta后基本消除了这个延迟但为了保险可以在header上加touch-action: manipulation.accordion-header { touch-action: manipulation; }这个属性告诉浏览器这里只涉及点击不用等双击判断能进一步降低响应延迟。另外移动端点击时最好有视觉反馈加个:active状态.accordion-header:active { background: #f0f0f0; }别小看这个反馈它让用户确认我点到了尤其在网络慢、动画有延迟的情况下能显著降低重复点击的概率。6. 从能跑到好用几个提升质感的细节6.1 缓动曲线的选择对体感的影响transition: height 0.3s ease里的ease是默认缓动先快后慢。对于展开动画这个曲线其实挺合适——快速展开让用户立刻看到内容末尾减速显得柔和。但收起动画用ease会显得拖沓因为收起时用户希望赶紧收走末尾减速反而让人等。我的做法是展开和收起用不同的曲线.accordion-body { transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1); }cubic-bezier(0.4, 0, 0.2, 1)是Material Design常用的标准曲线起步稍慢、中段快、末尾缓整体感觉比较跟手。如果追求更利落的收起可以在收起时临时改transition为更短的时长但这样代码复杂度上升收益有限我一般统一用一条曲线就够了。0.3s这个时长也是经验值短于0.2s显得仓促长于0.4s显得拖沓0.3s是大多数场景的甜点区。6.2 多个手风琴共存时的作用域隔离一个页面里可能有好几个手风琴比如攻略区一个、FAQ区一个它们之间应该互不干扰。如果JS里直接用全局的document.querySelectorAll(.accordion-item)就会把两个手风琴的项混在一起互斥逻辑会跨组生效点FAQ区的项把攻略区的项收起了这就乱套了。正确做法是把逻辑封装成一个函数作用域限定在传入的容器内function initAccordion(container) { container.addEventListener(click, function (e) { const header e.target.closest(.accordion-header); if (!header) return; const item header.parentElement; // 这里的查询都基于container天然隔离 const allItems container.querySelectorAll(.accordion-item.active); // ……后续逻辑 }); } document.querySelectorAll(.accordion).forEach(initAccordion);这样每个手风琴实例独立管理自己的状态互不干扰。这个封装思路也方便后续扩展比如给每个实例传不同的配置是否允许同时展开多个、动画时长等。6.3 初始状态默认展开第一项还是全部收起这是个产品决策问题但从技术实现角度也有讲究。如果默认展开第一项那么页面加载时第一项的body应该是展开状态height为autoitem带active类。但这里有个时序问题如果内容里有图片加载时高度不确定设auto没问题但如果用JS在DOMContentLoaded时去读scrollHeight设固定值可能读到的是图片加载前的高度。我的建议是默认展开的项初始height直接设auto不要设固定像素值。因为默认展开不需要动画设auto最稳妥内容怎么变高度都自适应。只有用户交互触发的展开才需要走读高度-设固定值-过渡的流程。这个区分能避免很多初始渲染的坑。6.4 性能考量will-change要不要加will-change: height能提示浏览器提前为高度变化做准备理论上能提升动画性能。但height属性的动画本身就会触发重排reflowwill-change对重排的优化有限而且滥用will-change会占用额外内存。我的实测结论是手风琴这种小规模动画加不加will-change体感差异几乎为零不建议加。真正需要关注性能的是动画期间有没有触发不必要的重排比如在transition期间修改其他元素的布局属性那才是性能杀手。如果确实遇到动画卡顿比如低端安卓机更有效的优化是减少同时动画的元素数量——互斥逻辑保证了同时只有一个在展开、一个在收起已经控制得不错了。再不行就把动画时长缩短或者降级为无动画的直接切换。7. 把代码组装起来完整可运行的版本7.1 完整JS代码把前面的片段整合成一个可直接运行的版本(function () { function initAccordion(container) { const items container.querySelectorAll(.accordion-item); // 初始化aria属性 items.forEach((item, index) { const header item.querySelector(.accordion-header); const body item.querySelector(.accordion-body); const panelId panel- index - Math.random().toString(36).slice(2, 7); const headerId header- index - Math.random().toString(36).slice(2, 7); header.id headerId; header.setAttribute(aria-expanded, item.classList.contains(active) ? true : false); header.setAttribute(aria-controls, panelId); body.id panelId; body.setAttribute(role, region); body.setAttribute(aria-labelledby, headerId); // 默认展开项设auto if (item.classList.contains(active)) { body.style.height auto; } // 过渡结束收尾 body.addEventListener(transitionend, function (e) { if (e.propertyName ! height) return; if (item.classList.contains(active)) { body.style.height auto; } }); }); container.addEventListener(click, function (e) { const header e.target.closest(.accordion-header); if (!header) return; const item header.parentElement; toggleItem(item, container); }); } function getCurrentHeight(el) { return el.getBoundingClientRect().height; } function collapseItem(item) { const body item.querySelector(.accordion-body); const header item.querySelector(.accordion-header); body.style.height getCurrentHeight(body) px; body.offsetHeight; body.style.height 0px; item.classList.remove(active); header.setAttribute(aria-expanded, false); } function expandItem(item) { const body item.querySelector(.accordion-body); const header item.querySelector(.accordion-header); body.style.height auto; const targetHeight body.scrollHeight; body.style.height 0px; body.offsetHeight; body.style.height targetHeight px; item.classList.add(active); header.setAttribute(aria-expanded, true); } function toggleItem(item, container) { const isActive item.classList.contains(active); // 互斥收起其他项 container.querySelectorAll(.accordion-item.active).forEach(other { if (other ! item) collapseItem(other); }); if (isActive) { collapseItem(item); } else { expandItem(item); } } document.querySelectorAll(.accordion).forEach(initAccordion); })();这段代码用IIFE包起来避免污染全局initAccordion接收容器参数实现作用域隔离collapseItem和expandItem拆开便于复用和测试。aria属性在初始化时统一设置id用随机后缀避免多个手风琴之间的id冲突。7.2 一个容易忽略的细节transitionend的重复绑定注意上面代码里transitionend监听是在初始化时给每个body绑一次的。如果后续有动态增删面板的操作新增的面板需要重新走一遍初始化。我的建议是把单个item的初始化逻辑也抽成函数新增面板时调用一次即可。这个设计在面板数量固定的场景下用不上但一旦涉及动态内容就有价值。另外transitionend在height从auto设回auto时不会触发因为值没变所以不用担心收尾逻辑被反复执行。但如果同一个body上同时有height和transform的过渡比如图标旋转绑在body上transitionend会触发两次靠e.propertyName过滤就能解决。7.3 测试清单上线前该验证哪些场景我在交付前会过一遍这个清单基本能覆盖绝大多数问题测试场景预期表现常见问题点击标题展开平滑展开图标旋转无动画直接弹开漏了强制重排点击已展开标题平滑收起收起后留缝padding加错层展开A后点BA收起B展开动画同步A收起跳变没先固定高度快速连点同一标题高度不错乱高度跳到中间值没读当前高度展开状态下改窗口宽度内容不被裁高度锁死没设回auto内容含未加载图片图片加载后高度自适应内容被裁没监听load键盘Tab回车操作正常展开收起无法聚焦用了div而非button页面多个手风琴互不干扰跨组互斥作用域没隔离这张表里的每一项我都实际踩过至少一次尤其是快速连点和改窗口宽度这两项不专门测很容易漏。8. 还能怎么扩展手风琴跑通之后往几个方向扩展都很自然。一是加动画时长配置把0.3s抽成参数不同场景用不同节奏二是支持同时展开多个把互斥逻辑做成开关本质上就变成了Collapse组件三是加懒加载面板首次展开时才去请求内容适合内容重的场景四是加嵌套手风琴里再套手风琴这时候作用域隔离的设计就派上用场了每层独立管理自己的状态。我个人在实际项目里最常用的是第一个和第三个扩展。时长配置是因为设计稿经常对动画节奏有要求抽成参数后改起来方便懒加载则是因为旅游攻略的内容往往来自接口一次性全请求太浪费展开时再拉更合理。这两个扩展都不复杂在现有代码上各加十几行就能实现。最后分享一个我调试高度动画的小技巧在Chrome DevTools里选中.accordion-body然后在Elements面板右侧的Styles里临时把transition改成height 3s动画放慢十倍任何跳变、起点不准的问题都会暴露得一清二楚。调完记得改回去。这个笨办法比盯着0.3s的动画猜问题高效多了。

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

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

免费获取报价 →
↑