做到第十四节,前面积累的选择器、事件、DOM 操作这些基本功,终于要派上真正的用场了。选项卡效果和可折叠功能,看起来就是两个常见的 UI 小效果——一个负责点标签切内容,一个负责展开收起段落——但把它们从头完整实现一遍,你碰到的其实是前端交互组件最核心的一批难题:状态怎么存、事件怎么绑、动画为什么失灵、键盘怎么走、同一个页面放两套会不会打架。这篇文章把这两个组件彻底拆开,从标记结构讲起,到动画的底层原理,最后给出一套能直接复用的封装写法。不管你是跟着课程进度学到这里,还是写过 demo 但对设计取舍没想清楚,都能直接拿走用。
1. 选项卡和折叠面板,都是在给页面"减负"但别混着用
1.1 空间不够时,两种组件各自解决什么问题
我把两个组件放在一起讲,是因为它们解决的问题在表面上完全一样:页面内容太多,一屏放不下。但往深一层看,它们的交互语义是不同的。
选项卡解决的是"同一区域里有多套互斥视图"的问题。用户点"参数",看到参数页;点"评价",看到评价页——两个视图在同一个上下文里是平级替换的,不存在谁先谁后。典型场景是电商详情页的"参数 / 评价 / 售后"、设置中心的分类页签、后台管理系统的指标切换。
折叠面板解决的是"文档太长,需要按需展开"的问题。内容本身具有先后阅读顺序,用户不需要一次看全,于是默认收起,点开某一段再看。典型场景是帮助中心的 FAQ、筛选条件区、移动端"更多选项"、多级菜单。
用一张表把这几个维度对照一下,选型时基本不会犹豫:
| 对比维度 | 选项卡 | 折叠面板 |
|---|---|---|
| 交互本质 | 切换视图 | 展开 / 收起 |
| 同一时刻可见内容 | 一个面板 | 可以多个,也可以只开一个 |
| 内容之间的关系 | 平级互斥 | 有先后顺序的段落 |
| 用户的心智 | "我在换页面看" | "我在把文档翻开" |
| 动画要求 | 可有可无 | 展开 / 收起的过渡是体验一部分 |
1.2 心智模型不同,实现细节也跟着不同
这一点常被课程忽略,但它是决定代码写法的关键。用户操作选项卡时,潜意识里是在"切换页面",所以切换结果必须稳定:切走再切回来,面板里填了一半的表单不能丢,滚动位置最好也能保持。而用户操作折叠面板时,是在"翻开一段文档",所以展开收起的过程最好有平滑动画,给手指一个明确的反馈。
还有一个交互决策经常做错:什么时候用手风琴(一次只开一项)?答案是内容彼此排他的时候。比如"同一套参数的不同分类"适合手风琴;而 FAQ 里每个问题和答案并没有排他关系,强行做成手风琴只会让用户多点一次关闭,纯属增加成本。
想清楚这两条,再往下写代码,每一步都是有目的的,而不是照着别人的教程抄样式。
2. 选项卡实现从标记开始:语义、状态与事件代理
2.1 先把 HTML 结构写对,再谈样式和交互
我见过很多选项卡 demo 是三个 div 加一个 onclick,能跑,但换到真实项目就开始出问题。建议从一开始就按 WAI-ARIA 的 Tabs 模式去写标记:
<div class="demo-tabs" id="demoTabs"> <div class="demo-tabs__list" role="tablist" aria-label="商品信息切换"> <button class="demo-tabs__tab" id="demoTab1" role="tab" aria-selected="true" aria-controls="demoPanel1">商品参数</button> <button class="demo-tabs__tab" id="demoTab2" role="tab" aria-selected="false" aria-controls="demoPanel2" tabindex="-1">用户评价</button> <button class="demo-tabs__tab" id="demoTab3" role="tab" aria-selected="false" aria-controls="demoPanel3" tabindex="-1">售后服务</button> </div> <div class="demo-tabs__panels"> <div class="demo-tabs__panel" id="demoPanel1" role="tabpanel" aria-labelledby="demoTab1"> <!-- 参数内容 --> </div> <div class="demo-tabs__panel" id="demoPanel2" role="tabpanel" aria-labelledby="demoTab2" hidden> <!-- 评价内容 --> </div> <div class="demo-tabs__panel" id="demoPanel3" role="tabpanel" aria-labelledby="demoTab3" hidden> <!-- 售后内容 --> </div> </div> </div>要点拆开来说:
- 标签应该用
<button>,而不是 div 加点击。button 天然支持键盘 Enter/Space 触发、焦点可见、读屏可识别; aria-controls建立"标签控制哪个面板"的关联,aria-labelledby反过来让面板知道自己的标题是谁;- 未激活的面板用
hidden属性,而不是display:none的内联样式。hidden 的语义更干净,读屏也会正确跳过。
tabindex="-1"是为了后面键盘操作做准备:Tab 键只在激活标签上落焦,方向键负责在标签之间移动。这个细节放到第五节展开。
2.2 状态是"谁被选中",事件委托到容器层
选项卡的核心状态只有一个:当前选中的是哪个标签。最自然的存法就是aria-selected。切换逻辑可以压缩成两句话:改标签状态、改面板显示。但事件绑定方式值得多说一句——不要每个标签各自 addEventListener,把事件委托到tablist容器上:
const tabList = document.querySelector('#demoTabs [role="tablist"]'); const tabs = Array.from(tabList.querySelectorAll('[role="tab"]')); function activateTab(targetTab) { tabs.forEach((tab) => { const isTarget = tab === targetTab; tab.setAttribute('aria-selected', isTarget ? 'true' : 'false'); tab.tabIndex = isTarget ? 0 : -1; }); const targetPanelId = targetTab.getAttribute('aria-controls'); tabList.parentElement.querySelectorAll('[role="tabpanel"]').forEach((panel) => { panel.hidden = panel.id !== targetPanelId; }); } tabList.addEventListener('click', (event) => { const tab = event.target.closest('[role="tab"]'); // 已经激活的标签不需要重复操作 if (tab && tab.getAttribute('aria-selected') !== 'true') { activateTab(tab); } });事件代理的好处有几个:一是容器的监听器数量固定,不管标签是三个还是三十个,开销都不变;二是以后动态往列表里追加标签,不需要重新绑定事件;三是用closest('[role="tab"]')处理,就算标签内部有图标、有子元素,点到的目标也能正确归位。
2.3 切走再切回来,面板内容不能丢
这是很多教程 demo 和真实项目差别最大的地方。demo 里经常这么写:点标签就panel.innerHTML = something,把内容重新渲染一遍。放到真实页面里,问题立刻出现——用户切到"用户评价"填了个表单,切到"商品参数"看一眼再切回来,内容没了。
正确做法是:面板 DOM 一直在页面里,只切换hidden。这样输入内容、滚动位置、已经加载完的图片统统不会丢。如果面板内容确实需要懒加载,用一个loaded标记记录,只在第一次激活时拉数据,之后切走只隐藏不销毁。
2.4 滑动指示条:很多人忽略的 offset 计算
选项卡最常见的视觉增强是底部滑动指示条。实现起来不复杂,但有一个坑。指示条用绝对定位放在 tablist 内,切换时把它的宽和位置改成当前标签的宽和位置:
.demo-tabs__list { position: relative; display: flex; border-bottom: 1px solid #eee; } .demo-tabs__indicator { position: absolute; left: 0; bottom: -1px; height: 2px; background: #2f6fed; transition: transform 0.25s ease, width 0.25s ease; }const indicator = document.querySelector('.demo-tabs__indicator'); function moveIndicator(tab) { if (!indicator) return; indicator.style.width = tab.offsetWidth + 'px'; indicator.style.transform = 'translateX(' + tab.offsetLeft + 'px)'; }坑在于:offsetLeft是相对最近的定位祖先计算的,所以 tablist 必须是position: relative,而且里面的标签不能再有自己的定位上下文。另外窗口 resize 之后,标签的宽度和位置会变,指示条不会自己跟上,要监听 resize 重新计算一次。指示条本身用transform做位移,比直接改left性能好,动画也更顺。
3. 可折叠功能的三条实现路线,怎么选看需求
3.1 原生 details/summary:零 JavaScript 但有边界
HTML 原生就提供了折叠能力:
<details class="fold" open> <summary>什么是七天无理由退货</summary> <div class="fold__content"> <p>商品完好且不影响二次销售,自签收次日零时起计算……</p> </div> </details>details 的默认行为已经帮你做好了:点 summary 展开收起、open属性表示状态、键盘可以操作。但样式上要处理两件事。一是默认的三角箭头在不同浏览器里长得不一样,统一去掉,换成自己的图标:
.fold summary { list-style: none; cursor: pointer; } .fold summary::-webkit-details-marker { display: none; } .fold summary::after { content: '+'; transition: transform 0.2s ease; } .fold[open] summary::after { transform: rotate(45deg); }二是原生的展开收起没有平滑动画——浏览器是把内容直接显示/隐藏的。想加动画,要用到::details-content(属于较新的能力),或者干脆用第四节讲的方案自己控制。
details还有一个值得一提的新特性:name属性。给同一组 details 写上相同 name,它们之间会自动互斥,一次只开一个,相当于原生的手风琴:
<details name="faq" open>...</details> <details name="faq">...</details>主流浏览器已经逐步支持,但考虑到旧版本,生产环境如果要全员兼容,互斥逻辑还是自己写更稳妥。
3.2 checkbox Hack:纯 CSS 实现,但更适合教学
用 label 控一个隐藏 checkbox,再用:checked配合兄弟选择器控制内容区,理论上可以用纯 CSS 做折叠:
<div class="mini-collapse"> <input class="mini-collapse__check" type="checkbox" id="mini1"> <label class="mini-collapse__head" for="mini1">筛选条件一</label> <div class="mini-collapse__body"> <p>筛选内容……</p> </div> </div>.mini-collapse__check { position: absolute; opacity: 0; pointer-events: none; } .mini-collapse__body { max-height: 0; overflow: hidden; transition: max-height 0.25s ease; } .mini-collapse__check:checked ~ .mini-collapse__body { max-height: 300px; }这套方案的优点是零 JS,把 checkbox 换成 radio 还能实现"一次只开一个"。但缺点也明显:label 的键盘焦点样式处理起来别扭,:checked 状态对读屏不友好,radio 一旦选中就没法再点回去实现"全部收起"。我的结论是,它适合用来理解 CSS 选择器能力,真实项目我不会用它承载正式交互。
3.3 JavaScript 控制:把开关状态握在自己手里
需要动画、需要互斥、需要和业务状态联动时,还是回到 JS。结构上用 button 做头部,内容区用一个内层包裹,方便后面做动画:
<div class="jsc-item"> <button class="jsc-item__head" id="jscHead1" aria-expanded="false" aria-controls="jscBody1">收货与发票说明</button> <div class="jsc-item__body" id="jscBody1" role="region" aria-labelledby="jscHead1"> <div class="jsc-item__inner"> <p>关于发货时间、运费和发票类型……</p> </div> </div> </div>document.querySelectorAll('.jsc-item').forEach((item) => { const head = item.querySelector('.jsc-item__head'); head.addEventListener('click', () => { const willOpen = !item.classList.contains('is-open'); item.classList.toggle('is-open', willOpen); head.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); }); });如果要做手风琴模式,在展开前把兄弟节点先收起来:
if (willOpen) { item.parentElement.querySelectorAll('.jsc-item.is-open').forEach((other) => { if (other !== item) { other.classList.remove('is-open'); other.querySelector('.jsc-item__head').setAttribute('aria-expanded', 'false'); } }); }状态控制就这么多。真正的问题是"内容区高度怎么平滑过渡",这个坑值得单独开一节说。
三条路线怎么选,我给一个简单的判断表:
| 需求 | 推荐路线 |
|---|---|
| 静态页面,只要基本折叠 | details/summary |
| 需要平滑动画、互斥、业务联动 | JS + class 控制 |
| 纯学习 CSS 选择器 | checkbox Hack |
| 旧浏览器兼容且要求零依赖 | JS + max-height 退化 |
4. 高度动画的本质问题:为什么内容展开总是一跳一跳
4.1 浏览器为什么不能过渡 height: auto
折叠动画的核心是"高度从 0 到实际内容高度"的过渡。但你去写transition: height 0.3s,把一个元素从height: 0变成height: auto,浏览器不会给你插帧动画,而是直接跳到最终状态。
原因在于 CSS 过渡要求起止值都是可计算的确定数值。auto不是一个数值,它是布局引擎根据内容算出来的结果,在计算完成之前谁也不知道是多少。打个比方:你跟司机说"开到终点",司机能算油费;你说"开到大概那么远的地方",司机就没办法提前估。浏览器同理,过渡动画需要在开始前知道差值,auto给不了这个信息。
4.2 max-height 方案:能用但时间感不对
最常见的妥协是用max-height来模拟。内容区设置max-height: 0,展开时给一个足够大的值:
.collapse__body { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; } .collapse.is-open .collapse__body { max-height: 600px; }这样确实有动画,但有两个问题。第一,时间感是错的:内容实际高度 300px,过渡却会从 600px 开始往回收,展开过程看起来"先快后慢"的比例不对。第二,数值要靠估算:万一内容超过 600px,就会被裁掉;设成 2000px 又会让收起的动画显得迟钝。demo 里没问题,真实内容不可控时很容易踩。
4.3 grid-template-rows:目前最省心的标准解法
现代浏览器可以直接用网格行高过渡:
.jsc-item__body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.3s ease, visibility 0.3s ease; visibility: hidden; } .jsc-item__inner { overflow: hidden; min-height: 0; } .jsc-item.is-open .jsc-item__body { grid-template-rows: 1fr; visibility: visible; }原理是:0fr和1fr在网格布局里都能被解析成实际像素长度,过渡引擎有具体的起点和终点可以插值。内层必须overflow: hidden并且min-height: 0,否则内容会把 0fr 撑开。这个方案的好处是不需要 JS 测量高度,内容变化(比如展开后加载了图片)也不会把动画搞坏。
注意:grid 方案的收起状态下,内容虽然视觉上没了,但对读屏来说它还在页面上。如果产品对无障碍有要求,记得配合
visibility: hidden或inert一起使用,上面代码里我已经把 visibility 放进去了。
4.4 scrollHeight + transitionend:精确但需要善后
如果浏览器兼容性要求特别老,可以用 JS 测量真实高度:
function openCollapse(item) { const body = item.querySelector('.jsc-item__body'); const inner = body.querySelector('.jsc-item__inner'); body.style.height = inner.scrollHeight + 'px'; item.classList.add('is-open'); body.addEventListener('transitionend', function onEnd(event) { if (event.propertyName !== 'height') return; // 动画结束后改成 auto,保证后续内容增长不会被截断 body.style.height = 'auto'; body.removeEventListener('transitionend', onEnd); }); } function closeCollapse(item) { const body = item.querySelector('.jsc-item__body'); // 先锁定当前高度,再触发到 0 的过渡 body.style.height = body.scrollHeight + 'px'; requestAnimationFrame(() => { body.style.height = '0px'; item.classList.remove('is-open'); }); }这个方案精确,但善后工作多:动画结束后要改成auto,否则窗口 resize 或内容变化时高度会被写死的 px 卡住;展开前如果图片还没加载完,scrollHeight会少算,展开完图片加载后内容被截断,需要在图片load事件里重算一次。相比之下,grid 方案省心得多,我现在的项目默认都是 grid,只有要兼容到很老的浏览器时才用 scrollHeight 方案。
5. 无障碍支持:键盘操作和读屏反馈,这部分占组件一半的工作量
5.1 选项卡的键盘交互:用方向键而不是 Tab 键翻页
很多人写完选项卡,用鼠标点没问题,但键盘一进来就露馅。按 Tab 键时焦点在三个标签之间跳,每个标签都要按一下,这种体验是错的——Tabs 模式的标准行为是:Tab 键把焦点带进 tablist 时,直接落在当前激活的标签上,标签之间用方向键左右移动,Home/End 跳到首尾。
实现方式是"roving tabindex":激活标签是tabindex="0",其余标签是tabindex="-1",方向键移动焦点并同时触发切换:
tabList.addEventListener('keydown', (event) => { const currentIndex = tabs.indexOf(document.activeElement); if (currentIndex === -1) return; let nextIndex = -1; if (event.key === 'ArrowRight') nextIndex = (currentIndex + 1) % tabs.length; if (event.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + tabs.length) % tabs.length; if (event.key === 'Home') nextIndex = 0; if (event.key === 'End') nextIndex = tabs.length - 1; if (nextIndex === -1) return; event.preventDefault(); tabs[nextIndex].focus(); activateTab(tabs[nextIndex]); });关键点有两个。一是event.preventDefault()必须写,不阻止的话方向键会触发页面滚动,同时焦点移动会乱套,视觉和逻辑状态就会脱节;二是切换动作要把aria-selected和tabindex的更新放在同一个函数里,绝对不能只改样式不改状态,否则读屏反馈和键盘焦点会各说各话。代码写顺手之后,这一步特别容易漏,因为鼠标点击时看起来一切正常,键盘一进来就露馅。
5.2 折叠面板的状态反馈:button、aria-expanded、aria-controls
折叠面板的无障碍核心是三件套:头部用真实 button;aria-expanded告诉读屏当前是展开还是收起;aria-controls指向内容区。内容区加role="region"和aria-labelledby,让读屏用户知道"这个区域是哪一块"。
有一个容易被忽略的点:如果收起时内容只是高度为 0、没有被hidden或inert,读屏仍然可能读到底下的内容,用户会听到一段"看不见"的文本。用 grid 方案后,要么像我上面那样配合visibility: hidden,要么在收起完成后的 transitionend 里给内容区加inert。没有动画需求时,直接用hidden属性最简单。
还有一点是焦点样式不要随便去掉。很多人为了"美观"写outline: none,这对键盘用户是灾难。至少要用:focus-visible给一个明显的替代样式。
5.3 动效偏好:尊重用户的系统设置
最后是容易被忽略的prefers-reduced-motion。用户在系统里开启"减少动态效果"时,页面里那些展开动画、指示条滑动动画都应该退化成瞬间切换:
@media (prefers-reduced-motion: reduce) { .jsc-item__body { transition: none; } .demo-tabs__indicator { transition: none; } }这个媒体查询不费事,但对特定用户群体影响很大。做这类交互组件时顺手写上,是专业度最直观的体现。
6. 组装成可复用组件:ID 冲突、高度失效和封装思路
6.1 页面里出现两套组件,先解决 ID 和选择器冲突
课程 demo 通常只有一个选项卡,但真实页面里往往同时存在商品信息选项卡、订单状态选项卡、多个折叠面板。这时候最容易翻车的就是 ID 冲突——aria-controls靠 ID 关联面板,如果两套组件里都用panel-1,读屏和getElementById会找到错的那个。
解决办法是给每个组件实例一个独立的 ID 前缀,或者干脆用相对 DOM 查找,不依赖全局 ID。另一个坑是选择器作用域:querySelectorAll('[role="tab"]')必须在当前组件根节点内部查,不要用全局查询。封装时把根节点作为参数传进去,所有查询都从根节点出发。
6.2 展开后高度被撑破:发生在异步内容加载时
用 scrollHeight 方案时最容易遇到一个场景:折叠面板默认收起,用户点开,里面是一段异步加载的内容(比如评论列表),内容回来之前scrollHeight测出来的高度是 0 或很小,等数据渲染完,面板就长高了,把固定高度撑破。
解决思路有几种。第一种是 grid 方案天生不怕这个问题,因为1fr会跟随内容自动调整;第二种是在内容加载完后再调一次重新计算;第三种是给异步内容一个预留高度,避免首屏塌陷。我的建议还是:只要浏览器支持,优先 grid 方案,把"内容会变"这个不确定性交给布局引擎。
6.3 把选项卡封装成一个工厂函数
组件化不一定非要引入框架,原生一个工厂函数就够了。思路是把"一个组件实例"需要管理的状态和操作收在一起:
function createTabs(root) { if (!root) return null; const tabList = root.querySelector('[role="tablist"]'); const tabs = Array.from(tabList.querySelectorAll('[role="tab"]')); function activate(targetTab) { tabs.forEach((tab) => { const isTarget = tab === targetTab; tab.setAttribute('aria-selected', isTarget ? 'true' : 'false'); tab.tabIndex = isTarget ? 0 : -1; }); const panelId = targetTab.getAttribute('aria-controls'); root.querySelectorAll('[role="tabpanel"]').forEach((panel) => { panel.hidden = panel.id !== panelId; }); } tabList.addEventListener('click', (event) => { const tab = event.target.closest('[role="tab"]'); if (tab && tab.getAttribute('aria-selected') !== 'true') { activate(tab); } }); // 键盘逻辑同上一节,这里略 return { activate, tabs }; } createTabs(document.querySelector('#productTabs')); createTabs(document.querySelector('#orderTabs'));折叠面板也可以照这个思路封装。组件化不是为了显得高级,而是当一个组件在页面里出现第三个以上实例时,散落的事件绑定和维护成本会迅速失控。把状态和 DOM 操作收进工厂函数,管理成本就降下来了。
6.4 初始状态和首屏动画:别让页面一进来就开始过渡
最后一个细节:首屏加载时,如果默认展开的面板带有过渡动画,用户会看到一次尴尬的"展开表演"。解决方法是初始状态在 HTML 里写死(要么加is-open类,要么加open属性),JS 初始化时只绑定事件、不额外触发动画。可以用一个>
网站安全感决定转化率:从HTTPS到信任设计的完整优化指南
说个我最近在做转化率复盘时的真实感受:很多团队把全部精力都砸在引流上,付费投放、内容营销、社交媒体运营一轮接一轮地搞,流量也确实拉起来了,但订单就是上不去。后来我们把站内行为数据一层层拆开看,才发现问题根本…
STM32+ESP8266用MQTT协议接入阿里云物联网平台实战详解
简介:面向STM32F103C8T6单片机开发者,这份工程源码完整演示了通过ESP8266模块以AT指令实现MQTT协议、接入阿里云物联网平台的全流程,重点解决设备端快速上云与数据上下行的问题。代码中已配置好服务器地址、客户端标识、用户名及加密密钥&…
RAG从入门到工程实践:文档切分、向量检索与生成优化的完整指南
1. 大模型“一本正经胡说八道”的背后:RAG到底治什么病我最早接触到 RAG(检索增强生成,Retrieval-Augmented Generation)这个概念,是在给一个内部客服问答项目做大模型落地评估的时候。当时我们拿通用大模型直接对接用…
给 Claude Code 接入实时搜索:MCP 协议与 Google Search 配置实战
1. 为什么我要给 Claude Code 接上实时搜索能力用 Claude Code 写代码的朋友大概率都遇到过这个场景:你让它帮你查一个库的最新版本 API 用法,它一本正经地给你编了一段看起来很像那么回事、但实际早就废弃的代码。这不是它笨,而是它的知识截…
深度学习学习率预热(Warmup)原理与工程实践指南
1. 什么是学习率预热(Warmup)?它为什么不是“可有可无”的花架子学习率预热(Learning Rate Warmup)——这个词在深度学习训练日志里出现的频率,可能比“loss nan”还高,但真正搞懂它在做什么、为…
FlatScience靶场实战:从目录遍历到权限提升的Web安全全链路解析
1. FlatScience靶场的本质:一个被低估的Web安全教学入口“攻防世界 WEB FlatScience”——这个标题在初学者眼里可能只是刷题列表里又一个带英文名的CTF题目,但在我带过的几十期Web安全实训中,它几乎总是第一个让学员从“背命令”转向“想逻辑…