前端做交互有个默认预设:只要涉及状态切换,就得写 JavaScript。弹窗要开关、折叠面板要互斥、点遮罩要关闭——每一样都对应一段监听器。这个预设在过去是对的,最近几年开始松动。
HTML 逐渐长出了一些”直接描述交互”的属性:commandfor 让按钮和它控制的东西建立关联,<details> 的 name 让同组折叠面板互斥,<dialog> 的 closedby 直接声明”点哪里算关闭”。它们的共同点是,把原本属于脚本的状态机挪进了标记里。
这篇文章做三个能直接用的组件,每个都配一段老写法的对照,最后把几个不踩不知道的地方列出来。先说清楚:这不是”以后不用写 JS”的宣言,能用标记表达的东西用标记表达,剩下的照样交给脚本,分工而已。
一、commandfor:按钮自己知道该干什么
先看最经典的弹窗触发。老写法大概长这样:
<button id="openBtn" type="button">删除账号</button>
<dialog id="confirmDlg">
<p>这个操作不可撤销,确定继续?</p>
<button id="cancelBtn" type="button">取消</button>
<button id="okBtn" type="button">确定删除</button>
</dialog>
<script>
const dlg = document.getElementById('confirmDlg');
document.getElementById('openBtn').addEventListener('click', () => {
dlg.showModal();
});
document.getElementById('cancelBtn').addEventListener('click', () => {
dlg.close();
});
document.getElementById('okBtn').addEventListener('click', () => {
dlg.close('confirmed');
});
</script>
三个按钮、三个监听器,一个都不能少。换成 commandfor 和 command 之后:
<button commandfor="confirmDlg" command="show-modal">删除账号</button>
<dialog id="confirmDlg">
<p>这个操作不可撤销,确定继续?</p>
<button commandfor="confirmDlg" command="close" value="cancel">取消</button>
<button commandfor="confirmDlg" command="close" value="confirmed">确定删除</button>
</dialog>
一行脚本都不需要。拆开来看:
commandfor写的是目标元素的 id,作用和for之于label类似——声明”我控制它”。command声明动作。show-modal对应showModal(),close对应close(),另外还有show-popover、hide-popover、request-close等取值。- 按钮上的
value会作为close()的参数传出去,于是dialog.returnValue就是"cancel"或"confirmed"。这一点很关键,它让你能区分用户是怎么关掉对话框的,而不用额外维护一个变量。
读取 returnValue 还是在 close 事件里,这一小段脚本省不掉:
document.getElementById('confirmDlg').addEventListener('close', (e) => {
if (e.target.returnValue === 'confirmed') {
// 执行真正危险的删除逻辑
}
});
注意这里省掉的是”把用户操作转成方法调用”那一层,不是全部逻辑。危险操作本身当然还得写代码,只是代码从事件绑定里解脱出来,变成一段”当对话框关闭时怎么处理结果”的纯逻辑。读起来也清楚得多。
和 popovertarget 的分工
如果你之前用过 Popover API,可能会觉得这两个东西撞车了——popovertarget 不也能让按钮开关一个元素吗?区别在语义和模态性:
popovertarget只能操作带popover属性的元素,切换的是显示与隐藏,不锁定页面。适合气泡卡片、菜单。commandfor可以操作任何元素,配合<dialog>用show-modal时会进入模态状态——焦点被约束在对话框内,背景内容对辅助技术不可见。适合确认框、表单弹窗这类必须打断用户的场景。
一句话概括:能被打断的地方用 popover,必须打断用户的地方用 dialog + show-modal。选错了不会报错,但键盘用户会骂人。
二、details 的 name:互斥折叠终于不用写脚本了
FAQ 页面、设置面板、移动端的筛选条件——这类”一次只展开一个”的折叠组,之前的方案要么在点击时遍历兄弟节点关掉其他项,要么用 radio + label + CSS 变通。两种都很别扭。
现在只要给它加个 name:
<div class="faq">
<details name="faq" open>
<summary>发货要多久?</summary>
<p>下单后 24 小时内发出,偏远地区顺延一天。</p>
</details>
<details name="faq">
<summary>支持哪些支付方式?</summary>
<p>目前支持微信、支付宝和银行卡。</p>
</details>
<details name="faq">
<summary>怎么申请退换货?</summary>
<p>在订单页点击「申请售后」,填写原因后提交。</p>
</details>
</div>
name 值相同的 <details> 属于同一组,展开其中一个,其余的会自动收起。这个机制和 radio 的 name 分组是同一个思路——浏览器帮你维护”同组只有一个选中”的约束。
这里有几个细节值得说清楚。
第一个 <details> 上的 open 属性是有意的。 很多 FAQ 页面默认全部收起,用户进来看到一排标题不知道点哪个。默认展开第一条,既是内容的预览,也顺便告诉用户”这东西是可以点的”。加 open 之后它自动成为该组的初始展开项,不用写脚本去激活。
name 的作用域是整个文档,不是父元素。 这意味着两个相隔很远、但 name 撞车的折叠组会被强行绑在一起。命名时最好带上语义前缀,比如 name="faq-shipping"、name="settings-privacy",别图省事都叫 "group"。这个问题在单页应用里尤其容易踩到——不同路由渲染出的组件用了同一个名字,切页面后行为就开始诡异。
嵌套的 <details> 不受影响。 外层折叠项的展开状态不会因为内层的 name 分组而改变。这一点和某些人的直觉相反,但确实是标准行为:分组只在同一层级的兄弟之间生效。
toggle 事件的时机
如果你需要在展开时懒加载内容,会监听 toggle 事件:
document.querySelectorAll('details[name="faq"]').forEach((el) => {
el.addEventListener('toggle', (e) => {
if (e.target.open) {
// 此刻 open 已经是新状态
}
});
});
要注意 toggle 在互斥收起时也会触发——用户点开第二项,第一项收起,第一项同样会触发一次 toggle,其 open 为 false。所以代码里必须判断 e.target.open,否则你会收到一堆”收起”通知,误以为是用户手动关的。
三、closedby:点遮罩关闭,一个属性的事
用 <dialog> 的人几乎都写过这段代码:
dlg.addEventListener('click', (e) => {
if (e.target === dlg) dlg.close();
});
原理是点击对话框的”背景板”时,事件目标的 target 正好是 <dialog> 本身(因为内容区域是它的子元素)。这招能用,但会带来一个副作用:用户在对话框里按住鼠标拖选文字,松开时鼠标落在遮罩上,事件目标就变成了 dialog,弹窗被关掉。用户只是选了个字,内容没了。
closedby 属性把这件事交给浏览器判断:
<dialog id="termsDlg" closedby="any">
<h2>服务条款更新</h2>
<p>我们更新了第 4.2 条关于数据保留期限的说明……</p>
<button commandfor="termsDlg" command="close">我知道了</button>
</dialog>
三个取值:
none:只能通过按钮或脚本关闭,Esc 无效。用在必须做出选择的场景。closerequest:默认值。响应 Esc,但不响应点击遮罩。any:Esc 和点击遮罩都关闭。
浏览器会正确区分”拖选结束后松手”和”在遮罩上单击”——pointerdown 与 pointerup 落在不同元素时不算作点击遮罩。那个”选中文字被动关窗”的老毛病,就这么没了。
顺便把 commandfor 一起用上,整个对话框只有一处需要脚本(处理关闭后的结果),按钮部分完全声明式。两个特性配在一起,比单独用任何一个都顺。
需要拦截关闭动作怎么办
表单验签之类的场景,用户还没填完就点了遮罩,你希望拦住。这时监听 cancel 事件:
dlg.addEventListener('cancel', (e) => {
if (!formIsValid()) {
e.preventDefault(); // 阻止关闭,让用户继续填
}
});
cancel 事件在 Esc 和点击遮罩时都会触发,被 preventDefault() 后,对话框保持打开。这样处理比自己在 click 里判断要可靠得多,因为它是浏览器在关闭流程中的标准钩子,不会被”点击穿透”之类的问题绕过去。
四、进阶:content-visibility 做可搜索的折叠面板
有一类折叠内容很特殊:默认收起,但用户用浏览器自带的页内查找(Ctrl+F)时,能搜到里面的文字,并且自动展开定位。以前的 display: none 做不到这点——隐藏的内容不参与查找,用户搜不到,自然也就没法定位。
用 content-visibility 就可以:
<article class="doc-section">
<h2>常见错误码</h2>
<div class="foldable">
<p>错误码 E1001 表示网络超时,请检查代理配置。</p>
<p>错误码 E1002 表示鉴权失败,请重新登录。</p>
</div>
</article>
.foldable {
content-visibility: hidden;
/* 折叠时的高度可以先占位,防止布局跳动 */
contain-intrinsic-size: auto 120px;
}
.doc-section.is-open .foldable {
content-visibility: visible;
}
处于 content-visibility: hidden 状态的元素,不在主线程渲染,但内容仍然保留在 DOM 里,可以被查找命中。用户在浏览器里搜索”E1002″,浏览器会自动滚动到这段折叠内容并把内容揭示出来(Chrome 和 Safari 都已经支持这个行为)。
开启的动作还是得写一点脚本——监听 beforematch 事件,给外层加上开合状态的 class:
document.querySelectorAll('.foldable').forEach((el) => {
el.addEventListener('beforematch', () => {
el.closest('.doc-section').classList.add('is-open');
});
});
beforematch 是专门为这个场景设计的:浏览器在揭示隐藏内容之前触发它,你在这里把状态切换好,接下来的滚动定位就是基于展开后的布局算的。不加这一步的话,浏览器滚动到的位置会按折叠时的高度计算,落点在错误的地方。
另外提一句 hidden="until-found",它和 content-visibility: hidden 关系密切——某种程度上它就是后者的语义化封装。区别在于:hidden="until-found" 需要你在 beforematch 里主动 removeAttribute('hidden') 才能真正显示内容,而 content-visibility 配合 class 切换更符合现有的样式体系。项目里已经有 .is-open 这套状态类的话,用 content-visibility 更顺手。
五、几个不踩不知道的坑
1. commandfor 的目标必须在同一棵 DOM 树里
被 commandfor 引用的元素如果藏在 Shadow DOM 内部,从外部用 id 引用是找不到的。反过来也一样。做 Web Component 时要注意这层边界——组件内部的控制按钮和对话框应该一起封在 shadow root 里。
2. details 只作用于 open 属性,不改 display
很多人想给收起的 <details> 加个过渡动画,结果发现怎么调都生硬。原因是浏览器对未展开的 <details> 的内容不是用 display: none 简单藏起来的,摘要之外的部分根本不参与布局。想加展开动画,得在 ::details-content 伪元素上做,或者改用 grid-template-rows: 0fr → 1fr 那套方案包一层。这是老问题,name 属性没有改变它。
3. closedby="any" 不等于”点任何地方都关”
它只响应点击对话框自身背景(也就是 ::backdrop 覆盖的区域),点击页面其他地方——如果对话框不是模态的——不会关闭。想实现”点页面任意处关闭”,还是得自己写逻辑。别把这两个概念混了。
4. 互斥的 details 里塞表单会有意外
展开的折叠项里填了内容,用户点开另一项,前一项被收起。被收起的内容不会从 DOM 中移除,表单值还在,数据不会丢。但如果你的代码依赖 toggle 事件做清理,得考虑这种”被动收起”的情况(前面提到的 e.target.open 判断就是为它准备的)。
5. 键盘顺序别被 commandfor 打乱
commandfor 只是建立了行为关联,不改变 DOM 顺序,也不改变 tab 焦点顺序。按钮写在页面顶部、对话框在页面底部,这在视觉上没问题,但读屏用户按 tab 遍历时,顺序仍然是 DOM 顺序。如果按钮和目标的逻辑关系很强,考虑把目标元素在 DOM 里挪近一些,或者用 aria-describedby 建立语义上的说明关系。
六、渐进增强:先保证能用,再谈体验
这三个特性的降级表现差别挺大,得分开对待:
details name:不支持时,多个折叠项可以同时展开,功能完全正常,只是失去了互斥效果。这个降级几乎可以不管——用户反而可能更喜欢能同时展开。dialog closedby:不支持时,点遮罩和 Esc 都不关窗,用户只能找关闭按钮。所以对话框里必须始终放一个显式的关闭按钮,别依赖遮罩关闭作为唯一出口。commandfor:不支持时按钮就是个普通按钮,点了什么都不会发生。这是三者里最危险的降级,因为它是功能性的。
针对 commandfor,可以写一小段兜底,只在需要时加载:
if (!('commandForElement' in HTMLButtonElement.prototype)) {
document.querySelectorAll('[commandfor]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = document.getElementById(btn.getAttribute('commandfor'));
if (!target) return;
const cmd = btn.getAttribute('command');
if (cmd === 'show-modal' && target.showModal) target.showModal();
else if (cmd === 'close' && target.close) target.close(btn.value);
});
});
}
这段代码只在老浏览器里执行,新浏览器里一行都不会跑,所以不用担心重复触发的问题——新浏览器有原生行为,老浏览器走兜底,互不干扰。
七、什么时候还是老老实实写 JS
这些标记特性解决的是”标准交互模式”,一旦超出模式边界,就该收手:
- 需要动画化的展开收起。
<details>的状态切换是瞬间的,要做高度过渡得自己包一层容器,本质上绕开了它的渲染方式。这时候不如直接用 div + 状态类。 - 对话流程有分支。比如”点确认后变成第二个对话框,再根据选择跳到第三步”。这种多步状态机用
commandfor硬拼会很乱,写个状态对象清楚得多。 - 需要记录用户的操作路径。埋点、A/B 测试、撤销历史——这些要求你对每一次开关有完全的控制权,声明式属性在这方面的可观测性不如手写监听器。
- 团队里有大量老代码继承。一个新写的组件用上了
commandfor,旁边五个老组件还是 jQuery 那套,两套心智模型混在一起,维护成本反而更高。渐进式引入,从新页面上手更现实。
判断标准其实不复杂:如果你的交互能一句话描述成”点这里,就打开/关闭那个”,交给标记;一旦这句话里出现了”如果””否则””但是”,写脚本。 这条线守住了,代码该短的地方短,该清楚的地方也不会为了炫技而变得难读。
把上面三个案例串起来看,你会发现一个共同点:它们做的事一直是那些——开关弹窗、互斥折叠、点击外部关闭。变的是这件事该由谁来负责。挪回标记之后,省下的不只是几十行脚本,还有那些藏在事件绑定里、下次改需求时才会被翻出来的隐式状态。

