公司后台系统里的”折叠面板”是一个神奇的存在。从 2018 年就有了第一版,用 jQuery 写的 $.slideToggle()。2020 年迁到 Vue 之后改成自研的 Accordion.vue,两百多行,还要配一个 AccordionItem.vue 负责单项的展开状态。2023 年又因为无障碍问题加了一堆 aria-expanded、role="button"、键盘事件监听。
结果上个月我在做代码清理的时候,突然发现一件尴尬的事——<details> 元素从 2011 年 Chrome 12 就开始支持了,这十三年里,我们那套折叠组件做的事情,浏览器原生全都做完了,而且做得更好。
更让人意外的是,2024 年浏览器又给 details 加了一套新能力,包括互斥展开的 name 属性、可以做动画的 ::details-content 伪元素。这些东西组合起来,能覆盖的组件类型比我想象的多得多。
这篇文章就把这次重写的全过程记录下来。三套组件、不到 100 行代码、零 JS 动画。
details 元素的基础
先看最朴素的写法:
<details>
<summary>点击展开</summary>
<p>这里是展开之后看到的内容。</p>
</details>
浏览器默认会渲染成一个带三角箭头的标题,点击三角或者标题,内容展开或收起。整个交互不需要一行 JS。
原生带来的是这些能力:
- 鼠标点击
summary触发展开/收起 - Tab 键能聚焦到
summary上,Enter 和 Space 都能触发 - 屏幕阅读器读出来是”展开/收起的按钮”这类原生语义
- 支持
Ctrl+F页面内搜索时自动展开包含结果的面板 open属性控制初始展开状态
第五章往往是最容易被忽略的一条。用户用浏览器的”查找”功能搜索页面文字时,传统 JS 折叠面板里的内容默认被 display: none 隐藏,搜索会搜不到。而 details 元素的内容即使收起也是”collapsed”状态,浏览器能找到里面的文字,会自动展开让用户看到匹配结果。这条对长文档、帮助中心、API 文档这类场景的用户体验提升巨大。
open 属性可以写在 HTML 上:
<details open>
<summary>默认展开的段落</summary>
<p>一进来就能看到。</p>
</details>
也可以从 JS 里切换:
const details = document.querySelector('details')
details.open = true // 展开
details.open = false // 收起
open 是一个布尔属性,读写都很直接。
手风琴:用 name 属性做互斥展开
以前做手风琴效果——也就是”同时只允许一个面板展开”——必须用 JS。监听每个面板的 toggle 事件,展开一个时把其他都关掉。这段逻辑写了十几年,各种坑都踩过了:事件循环里死锁、关闭时不触发动画、快速点击状态紊乱。
2024 年浏览器给 details 加了一个 name 属性。同一个 name 的多个 <details> 会自动互斥展开,跟单选框的行为一样。
<details name="faq">
<summary>如何注册账号?</summary>
<p>点击右上角的注册按钮,填写手机号...</p>
</details>
<details name="faq">
<summary>忘记密码怎么办?</summary>
<p>在登录页点击"忘记密码",通过手机验证码找回...</p>
</details>
<details name="faq">
<summary>如何注销账号?</summary>
<p>在"设置 - 账号安全"页面...</p>
</details>
这段 HTML 渲染出来就是标准的 FAQ 手风琴。用户点开第二个的时候,第一个会自动关闭。整个过程不需要一行 JS。
想让某个面板默认展开,给它加 open 属性就行:
<details name="faq" open>
<summary>如何注册账号?</summary>
<p>...</p>
</details>
name 属性的浏览器支持情况需要关注一下。Chrome 120(2023 年 12 月)、Safari 17.2(2023 年 12 月)、Firefox 130(2024 年 9 月)才支持。这已经是相对近的特性了,2024 年之前的老版本浏览器会忽略 name,退化成”可以同时打开多个”的行为。这个降级方案是安全的——功能能用,只是互斥效果没有了。
做下拉菜单
第二个可以扔掉的组件是下拉菜单。以前不管是自己写还是用库,都要处理:点击外部关闭、焦点管理、菜单项的键盘导航。
用 details 也能实现,虽然不如专门的 popover 属性自然(这是另一个话题),但胜在兼容性好、结构简单:
<details class="dropdown">
<summary>用户菜单</summary>
<ul>
<li><a href="/profile" rel="external nofollow" >个人资料</a></li>
<li><a href="/settings" rel="external nofollow" >设置</a></li>
<li><button>退出登录</button></li>
</ul>
</details>
这已经是一个能用下拉菜单了。点击 summary 展开,再点一下收起。走键盘的话,Tab 到 summary 上,Enter 打开,然后 Tab 就进入菜单项了。
需要一点 CSS 才能让它看起来像一个真正的下拉菜单(绝对定位、添加阴影、调整三角箭头方向)。但交互的逻辑已经不用写了。
唯一的缺口是”点击外部关闭”。原生的 details 不会在点击外部时自动收起。如果想加上这个行为,监听一下 document 的 click 事件就好:
document.addEventListener('click', (e) => {
document.querySelectorAll('details.dropdown[open]').forEach((d) => {
if (!d.contains(e.target)) {
d.open = false
}
})
})
这段代码十行,比库里的下拉菜单组件(通常一两百行)轻得多。而且这样做还保留了 details 原生的所有无障碍能力。
做代码块展开
第三个经典场景是”代码块折叠”。日志、配置、文档页面里常见,一段长代码默认只显示前 10 行,点击”展开全部”再显示剩下的。
<details class="code-block">
<summary>查看完整代码(共 47 行)</summary>
<pre><code>function longFunction() {
// 一堆代码
}</code></pre>
</details>
不用写 JS,浏览器原生就能处理。而且这比”点击展开”的按钮方案更好——因为 Ctrl+F 可以直接定位到里面的内容。
有人会想”那能不能只显示前 N 行,展开之后显示全部?”可以借助 CSS 的 line-clamp 或者干脆用两块内容(一块是摘要块、一块是完整块)。但更简单的方式是接受默认显示短代码、点击展开显示长代码的策略,别做行数截断——因为行数截断会破坏”Ctrl+F 能搜到”的好处。
动画:::details-content
原生 details 一直有个体验问题:展开和收起是瞬间的,没有动画。以前要做展开动画,只能自己写 JS 控制高度变化,很麻烦。
2024 年浏览器引入了 ::details-content 伪元素,代表 details 里除了 summary 之外的所有内容。有了这个伪元素,动画就好做了:
details::details-content {
block-size: 0;
overflow: hidden;
transition: block-size 0.3s ease, content-visibility 0.3s;
transition-behavior: allow-discrete;
}
details[open]::details-content {
block-size: auto;
}
这段 CSS 里有两个关键的现代特性。
content-visibility + allow-discrete。收起状态下,内容在渲染层是 content-visibility: hidden(从根本上不参与布局和渲染)。allow-discrete 让这个离散的属性切换也能被 transition 处理,展开过程中慢慢”出现”。没有它的话,切换是瞬间的。
block-size: auto 的过渡。从 0 到 auto 的过渡,以前是不支持的(因为 auto 不是具体值)。2024 年浏览器引入了 interpolate-size: allow-keywords,让 auto 可以被插值。设置之后的写法:
:root {
interpolate-size: allow-keywords;
}
details::details-content {
block-size: 0;
overflow: hidden;
transition: block-size 0.3s ease, content-visibility 0.3s;
transition-behavior: allow-discrete;
}
details[open]::details-content {
block-size: auto;
}
interpolate-size: allow-keywords 是一句全局声明,加在 :root 上让它作用于整个文档。加完之后 height: auto 这类值就能和其他数值之间做过渡了。这个特性是 2024 年底才进入稳定阶段的,之前需要给元素一个具体的 max-height 才能过渡。
整个动画的实现就这几行 CSS,不需要任何 JavaScript。以前那种用 JS 测量内容高度、设置 inline style、再监听 transitionend 清掉 style 的做法,全都不需要了。
如果浏览器的 interpolate-size 支持还不完整(比如一些较旧版本的 Firefox),可以退化成用 grid-template-rows 的技巧:
details::details-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
details[open]::details-content {
grid-template-rows: 1fr;
}
details::details-content > * {
overflow: hidden;
}
这套方案兼容性好一点,效果也接近。我的做法是两套都写,用 @supports 特性检测,能用新的就用新的。
监听展开和收起事件
有些时候还是要写点 JS,比如展开之后懒加载内容、上报埋点、滚动到可见位置。这时候用 toggle 事件:
const details = document.querySelector('#myDetails')
details.addEventListener('toggle', (e) => {
if (details.open) {
console.log('展开了')
} else {
console.log('收起了')
}
})
注意 toggle 事件不冒泡。如果想在 document 上做事件委托,得加 capture: true:
document.addEventListener('toggle', (e) => {
if (e.target instanceof HTMLDetailsElement) {
console.log('某个 details 被切换了:', e.target)
}
}, true)
用 capture 阶段捕获事件,可以在文档层面统一处理所有 details 的展开行为。这个细节我第一次用的时候找了半天,因为冒泡阶段一直收不到事件。
五个踩过的坑
坑一:summary 必须放在 details 里的第一个位置。如果 summary 前面有别的内容,浏览器不会把它当作标题渲染,只会渲染成一个普通元素,三角箭头也不会出现。这个限制很强硬,是 HTML 规范要求的。
<!-- 正确 -->
<details>
<summary>标题</summary>
<p>内容</p>
</details>
<!-- 错误,summary 前的文字会被忽略 -->
<details>
<p>说明文字</p>
<summary>标题</summary>
<p>内容</p>
</details>
坑二:不要给 summary 设置 display: block。我们做自定义样式的时候,把 summary 的 display 从默认的 list-item 改成了 block(为了去掉三角箭头),结果发现键盘 Enter 键触发不动了。查了半天,是因为 display 改成 block 之后,浏览器认为 summary 不再是一个可交互的按钮角色。正确做法是移除三角箭头用 list-style: none,或者用 ::-webkit-details-marker { display: none; },但保留默认的 display。
summary {
list-style: none;
cursor: pointer;
}
summary::-webkit-details-marker {
display: none;
}
这样三角箭头消失了,但键盘交互和屏幕阅读器语义都保留完整。
坑三:toggle 事件在页面加载时也可能触发。如果一个 details 带 open 属性,不管是在 HTML 里写的还是 JS 早期设的,toggle 事件可能在监听器注册之后才被派发一次。如果监听器里有副作用(比如打点上报),会导致多算一次。稳妥的做法是在初始化的时候读一次 open 状态,把它作为基线,后续的 toggle 才认为是”用户操作”。
let initialised = false
details.addEventListener('toggle', () => {
if (!initialised) return // 忽略初始化时的 toggle
// ...
})
// 页面加载完再打开"用户操作"通道
queueMicrotask(() => { initialised = true })
坑四:details 里面的内容仍在 DOM 里。展开和收起只是视觉上隐藏,DOM 从来没被移除过。这意味着如果里面有 <video autoplay>、<iframe>、或者进行中的表单,即使”看不见”它们也还在跑。想彻底暂停,得用 content-visibility(渲染层不渲染)或者干脆在 toggle 事件里手动控制。
details::details-content {
content-visibility: hidden;
}
details[open]::details-content {
content-visibility: visible;
}
加上 content-visibility: hidden 之后,收起状态下的内容不但不渲染,浏览器也不会为它维护布局信息,性能会好一些。这个属性跟动画过渡结合时记得加 transition-behavior: allow-discrete。
坑五:details 嵌套时 toggle 事件的目标。嵌套的 details(一个 details 里还有另一个 details),toggle 事件会由内层向外层冒泡。但前面说了 toggle 不冒泡——实际上它能被 capture 阶段捕获到。如果在内层展开了,外层的 toggle 监听器可能会在 capture 阶段也被调用。这时候 e.target 是内层的 details,你得根据 target 判断是不是自己处理的。
outerDetails.addEventListener('toggle', (e) => {
if (e.target !== outerDetails) return // 忽略来自内层的 toggle
// 处理自己的展开收起
}, true)
这个坑比较隐蔽,不写嵌套结构就遇不到。我们给帮助中心做多级目录的时候撞上了,因为内层展开上报了一次日志、外层又上报一次,用户操作日志里全是重复的。
浏览器支持现状
基础能力不用说,2011 年就有了,可以放心用。新特性的支持情况需要分清:
| 特性 | Chrome | Safari | Firefox |
|---|---|---|---|
details + summary 基础 |
12 | 6 | 49 |
name 属性(互斥) |
120 | 17.2 | 130 |
::details-content |
131 | 18.2 | 尚未支持 |
interpolate-size |
129 | 17.5 | 尚未支持 |
content-visibility |
85 | 18 | 125 |
可以看到,name 属性已经算是相当稳的特性了。但 ::details-content 和 interpolate-size 还比较新,Firefox 目前还没跟上。做动画的时候要有降级方案。
我一般这么处理:基础功能(互斥、折叠)用 name 属性,能用的地方就用,不支持的地方退化成”可以同时展开多个”,不影响功能。动画部分用 @supports 包起来,不支持的地方就接受”瞬间展开”的效果。
@supports (interpolate-size: allow-keywords) {
:root {
interpolate-size: allow-keywords;
}
details::details-content {
block-size: 0;
transition: block-size 0.3s, content-visibility 0.3s;
transition-behavior: allow-discrete;
}
details[open]::details-content {
block-size: auto;
}
}
这样浏览器支持就往前走,不支持也不至于功能不可用。
重构之后的成果
整个后台系统的三套折叠组件全部换成了 details,代码量对比:
| 场景 | 重写前 | 重写后 |
|---|---|---|
| 手风琴 | Vue 组件 218 行 | HTML + 20 行 CSS |
| 下拉菜单 | Vue 组件 186 行 + 使用方 12 行 | HTML + 30 行 CSS + 10 行 JS |
| 代码块展开 | Vue 组件 142 行 | HTML + 15 行 CSS |
总共删掉了一千行左右的组件代码,加上配套的单元测试大概删了两千行。
另一个更大的收益是无障碍体验。以前那个自研的 Accordion 组件,光是处理 aria-expanded、aria-controls、role 这些属性就写了几十行,还是挡不住各种坑。换成 details 之后,屏幕阅读器识别和一键操作都是浏览器保证的,不用我们再操心。
发布之后,客服反馈里”帮助中心里搜不到我想要的答案”这类问题几乎消失了。这跟之前提到的”Ctrl+F 能搜到 details 里的内容”有关系——用户之前的搜索行为,其实被 JS 折叠面板挡住了。
什么时候不用 details
不是所有折叠场景都适合用 details。下面几种情况还是得自己实现。
- 复杂的展开动画。比如展开时内容要有一个从模糊到清晰的过程,或者展开过程中有别的元素跟着位移。这种用 details 的
::details-content伪元素做不出来的效果,还是上 JS。 - 需要精细控制展开时机。比如一个折叠面板要在展开前先请求数据,展开之后延迟 300 毫秒再开始动画。这种时序控制用 details 做起来反而绕。
- 用来做 tab 切换。details 不是 tab 元素,用它模拟 tab 会丢失 tab 该有的无障碍语义。tab 就该用
role="tablist"那套,或者用 radio + label 的经典方式。 - 强视觉定制的场景。details 里的
summary有些默认的渲染特性不好绕过(比如默认的三角箭头、默认的块级布局),如果设计稿要求的样子离原生太远,用 div + JS 反而简单。
但绝大多数”折叠面板”、”FAQ”、”下拉列表”、”代码块展开”的场景,details 是完全够用的。以前我们不用它,纯粹是因为不知道它能做到而已。
写在最后
这次重构让我想起一件事。前几年我在做无障碍改造的时候,某个资深前端说了一句话:”做前端做久了,反而容易忘了 HTML 本身有多强。”我当时没太在意,觉得自己也算写得规范。但这几年陆续发现,自己在用 JS 硬实现很多 HTML 早就做好的东西——折叠面板、模态框、焦点管理、下拉菜单、日期选择。
HTML 不只是一个”标签集合”,它其实是浏览器引擎对外暴露的一整套交互原语。每个标签背后都对应着一堆原生的行为、语义、无障碍、键盘交互。details 只是其中一个被忽略得比较久的例子。
以后遇到”要做个 XX 组件”的时候,我现在的习惯是先问一句:HTML 里有没有原生能做这件事的?如果有,先看它能不能用,不行再上 JS。
十次里有七八次,答案是”能”。

