details 元素被低估了十年:一个标签干掉手风琴、下拉菜单和代码块展开

2026-10-07 0 670

公司后台系统里的”折叠面板”是一个神奇的存在。从 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。

十次里有七八次,答案是”能”。

details 元素被低估了十年:一个标签干掉手风琴、下拉菜单和代码块展开
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 html details 元素被低估了十年:一个标签干掉手风琴、下拉菜单和代码块展开 https://www.taomawang.com/web/html/2904.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务