上个月接手一个后台项目,设置页面里塞了三个交互:折叠分组、删除确认弹窗、以及一堆鼠标悬停才出现的小提示。它们分别来自三个不同的 UI 库,加起来压缩后一百多 KB,还带来两套完全不同的弹层层级逻辑。两个弹层同时打开的时候,谁盖住谁全靠 z-index 猜。
后来翻了下这几个交互的规范,发现浏览器早就有对应的原生元素了。这篇文章就把这套设置面板重写一遍,只用 details、dialog 和 Popover API,顺带记录兼容性和实际遇到的问题。代码都是可以直接跑的。
一、折叠分组:details 的 name 属性
以前做手风琴,要么自己写一套 open/close 状态,要么让 UI 库接管 DOM。现在只需要给同一组 details 加一个相同的 name 值,浏览器会自动保证同时只展开一个。
<div class="settings-group">
<details name="settings" data-group="settings" open>
<summary>账号与安全</summary>
<p>绑定手机号、修改登录密码、开启二次验证。</p>
</details>
<details name="settings" data-group="settings">
<summary>通知偏好</summary>
<p>站内信、邮件、短信三类通知可以分别开关。</p>
</details>
<details name="settings" data-group="settings">
<summary>数据与导出</summary>
<p>按月份导出订单明细,导出任务在后台排队执行。</p>
</details>
</div>
几个容易忽略的点:
summary必须是details的第一个子元素,否则浏览器不会把它当标题渲染,而是自己塞一个默认的「详细信息」。open是布尔属性,写open="false"依然是展开状态,要关闭就直接删掉这个属性。- 组内互斥只对同一个
name值生效,跨组的details互不影响。
二、旧浏览器的降级处理
name 属性在 2024 年之后的主流版本里才陆续支持,某些还在维护的旧版浏览器里,三个分组会同时展开。这不是致命问题,但体验不一致,所以加一段很短的降级代码就够了。
const supportsNameGroup = 'name' in HTMLDetailsElement.prototype;
if (!supportsNameGroup) {
document.querySelectorAll('details[data-group]').forEach((item) => {
item.addEventListener('toggle', () => {
if (!item.open) return;
const group = item.dataset.group;
document
.querySelectorAll(`details[data-group="${group}"]`)
.forEach((other) => {
if (other !== item) other.open = false;
});
});
});
}
用 toggle 事件而不是监听 summary 的点击,是因为 details 的展开状态也可能被键盘操作改变,监听点击会漏掉回车触发的场景。
三、删除确认:dialog 元素
模态框是自研代码里最容易出事的地方:焦点不锁、Esc 关不掉、点遮罩不关、关闭后焦点不知道飞哪去。dialog 配合 showModal(),这几件事浏览器都替你处理了。
<dialog id="confirm-delete" aria-labelledby="confirm-title">
<form method="dialog">
<h2 id="confirm-title">确认删除这条记录?</h2>
<p>删除后不可恢复,关联的导出任务也会一并取消。</p>
<button value="cancel">取消</button>
<button value="confirm">删除</button>
</form>
</dialog>
关键在于 form method="dialog"。表单提交时浏览器不会发请求,而是关闭对话框,并把被点击按钮的 value 写进 dialog.returnValue。省掉了自己维护按钮状态的那段代码。
const dialog = document.querySelector('#confirm-delete');
const deleteBtn = document.querySelector('#delete-record');
deleteBtn.addEventListener('click', () => {
dialog.showModal();
});
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'confirm') {
// 真正执行删除
removeRecord();
}
// 无论确认还是取消,returnValue 都会保留,下次打开前可以手动清空
dialog.returnValue = '';
});
这里有两个实际踩过的坑:
showModal()和show()不是一回事。show()只是把元素显示出来,没有遮罩、没有焦点陷阱、也不会让页面其他内容变成inert。做模态框必须用showModal()。- 对已经打开的对话框再调一次
showModal()会抛InvalidStateError。如果打开动作可能被重复触发(比如按钮在动画期间被连点),先判断dialog.open。
如果业务要求「点 Esc 不能关」,就在 cancel 事件里阻止默认行为:
dialog.addEventListener('cancel', (event) => {
if (isUploading()) {
event.preventDefault();
}
});
四、轻提示:Popover API
设置页里那些「鼠标移上去才出现的小气泡」,以前要么用 title 属性(样式完全不可控、触摸设备不显示),要么自己写一套定位和外部点击关闭。Popover API 把这些都标准化了。
<button popovertarget="tip-export">导出的数据包含哪些字段?</button>
<div id="tip-export" popover>
<p>包含订单号、支付时间、金额、退款状态,不包含收货地址和手机号。</p>
<button popovertarget="tip-export" popovertargetaction="hide">知道了</button>
</div>
不需要写一行 JavaScript。点击按钮打开,再点一次关闭,点击页面其他位置也会自动关闭。最后一条是 popover 默认模式(auto)才有的行为,如果显式写成 popover="manual",点外部不会关闭,得自己调 hidePopover()。
auto 模式还有一层「轻量弹层」的语义:同一时间只会显示一个 auto 类型的 popover,打开新的会自动关掉旧的。这一点和模态对话框不同,对话框是可以叠加的。
需要动态控制时,用 togglePopover():
const tip = document.querySelector('#tip-export');
document.querySelector('#help-link').addEventListener('click', (event) => {
event.preventDefault();
tip.togglePopover();
});
togglePopover() 可以传 true 或 false 表示强制显示/隐藏,不传则切换。注意它和 showModal() 一样,对不支持该 API 的环境调用会直接报错,所以升级前的项目要先做一次特性检测:
if (typeof HTMLElement.prototype.togglePopover === 'function') {
// 走原生实现
} else {
// 退回自己的浮层组件
}
五、三个组件放进同一个页面时的注意事项
单独用都没问题,凑在一起就有细节了。
1. 弹层嵌套不要跨越 dialog 边界
在 dialog 内部放一个 popover 是可以的,但反过来不行——把 dialog 放进一个 popover 里,然后调用 showModal(),会出现焦点被重复锁定的情况,表现是 Tab 键在弹窗里循环,却按顺序跳过了浏览器地址栏。需要嵌套时,用 dialog 套 dialog,或者把 popover 移到 dialog 内部。
2. dialog 打开时,背景确实滚不动了,但你要自己处理滚动条
showModal() 会让背景内容变成 inert,但不会锁住 body 的滚动。内容很长的页面里,对话框打开后滚轮依然能滚背景,视觉上很别扭。比较稳妥的做法是在 close 和打开时切换一个控制滚动的类,或者直接在 open 属性上用 CSS 选择器控制。
3. 用 showModal 之后不要再手动加遮罩层
有些老代码习惯在弹窗外面套一层半透明 div。用了 dialog 之后这层 div 反而会挡住对话框的点击区域,因为 ::backdrop 已经存在了。要改背景颜色,直接改 ::backdrop 的样式,别加节点。
4. 屏幕阅读器对 popover 的展开状态支持还不统一
按钮上的 popovertarget 在规范里有隐式的展开状态语义,但不同屏幕阅读器的读法差异比较大。如果提示内容超过两句话,或者里面有可操作的按钮,建议改回 dialog,因为对话元素有明确的 role="dialog" 和标题关联,读屏体验稳定得多。
六、完整的最小示例
把上面的部分拼起来,一个能跑的设置面板大概是这样:
<section>
<h2>设置</h2>
<details name="settings" data-group="settings" open>
<summary>账号与安全</summary>
<p>绑定手机号、修改登录密码、开启二次验证。</p>
<button id="delete-record">删除账号数据</button>
</details>
<details name="settings" data-group="settings">
<summary>通知偏好</summary>
<p>站内信、邮件、短信三类通知可以分别开关。</p>
</details>
<details name="settings" data-group="settings">
<summary>数据与导出</summary>
<p>
按月份导出订单明细。
<button popovertarget="tip-export">包含哪些字段?</button>
</p>
<div id="tip-export" popover>
<p>包含订单号、支付时间、金额、退款状态。</p>
</div>
</details>
</section>
<dialog id="confirm-delete" aria-labelledby="confirm-title">
<form method="dialog">
<h2 id="confirm-title">确认删除?</h2>
<p>删除后不可恢复。</p>
<button value="cancel">取消</button>
<button value="confirm">删除</button>
</form>
</dialog>
配套的脚本只有十几行,负责三件事:打开对话框、读取 returnValue、给不支持 name 分组的环境补一段互斥逻辑。没有状态管理,没有层级计算,也没有额外的 DOM 包装节点。
七、什么时候还是该用组件库
说句公道话,原生方案不是万能的。下面这几种情况,自己写反而更累:
- 需要下拉选择、日期选择这类复杂交互,浏览器目前没有对等元素。
- 设计稿要求 popover 带箭头、带偏移定位(比如贴着按钮上方 8px 处),原生 popover 的定位靠 CSS anchor 相关属性,兼容性还在推进中,做全量适配成本不低。
- 需要和已有设计系统的主题变量对齐,而系统里已经沉淀了几十个弹层组件。
但如果只是折叠、确认、轻提示这三类,原生元素已经足够,而且免去了卸载时清理事件监听、清理挂载节点这些麻烦事。少装一个依赖,也少一个半夜升级版本时的心跳。

