手上这个后台项目里,弹窗大概有二十来个。早期的写法是给每个 trigger 按钮挂 data-dialog="xxx",在 document 上做一次事件委托,点击的时候 document.getElementById(...) 找目标再 showModal()。关闭按钮又是一套,data-close 再来一次委托。
这套东西能跑,但维护起来很难受:新增一个弹窗要在两处改代码,改错一个 id 就静默失败,控制台什么都不报。更要命的是点遮罩关闭——原生 <dialog> 根本没有这个行为,得自己监听 click 然后判断 event.target === dialog,还要小心别把内容区的点击也算进去。
后来发现 command 和 commandfor 这两个属性已经落地了,配合 closedby,上面这套委托逻辑可以整个删掉。我把其中一个模块改完,那部分 JS 从七十多行变成零。
先看老写法到底写了多少东西
<button data-open="detail">查看详情</button>
<dialog id="detail">
<h2>详情</h2>
<button data-close>关闭</button>
</dialog>
document.addEventListener("click", (e) => {
const opener = e.target.closest("[data-open]");
if (opener) {
document.getElementById(opener.dataset.open).showModal();
return;
}
if (e.target.closest("[data-close]")) {
e.target.closest("dialog").close();
return;
}
// 点遮罩关闭
if (e.target instanceof HTMLDialogElement) {
const r = e.target.getBoundingClientRect();
const inside = e.clientX >= r.left && e.clientX <= r.right
&& e.clientY >= r.top && e.clientY <= r.bottom;
if (!inside) e.target.close();
}
});
最后那段遮罩判断尤其别扭。<dialog> 的盒子本身只包裹内容区,遮罩是 ::backdrop 伪元素画出来的,点击遮罩时 event.target 是 dialog 元素本身,但点击 dialog 的 padding 区域时也是它。所以只能拿坐标反算,判断点击点是不是落在内容盒之外。这段代码我抄了无数遍,每次都要愣一下才想起来为什么这么写。
commandfor 把开合写进 HTML
换成声明式之后,同样的功能是这样表达的:
<button commandfor="detail" command="show-modal">查看详情</button>
<dialog id="detail">
<h2>详情</h2>
<button commandfor="detail" command="close">关闭</button>
</dialog>
commandfor 的值是目标元素的 id,command 是要执行的内置命令。点第一个按钮,浏览器自己调 showModal();点第二个,调 close()。一行脚本都不用写。
内置命令目前有六个,够覆盖绝大多数场景:
show-modal —— 以模态方式打开 dialog,等同于 showModal(),背景会变 inert、焦点被限制在框内。
close —— 关闭 dialog,等同于 close()。
request-close —— 发起关闭请求,会先触发 cancel 事件,可以被 preventDefault() 拦下来。适合“有未保存内容,确认一下再关”的场景。
show-popover、hide-popover、toggle-popover —— 管 popover 元素的显隐,和 dialog 是两套东西,但共用同一组属性。
这几个命令的语义差别很重要。close 是硬关,不给拦截机会;request-close 走的是和按 Esc 完全相同的路径,所以你在 cancel 事件里写的校验逻辑,对 Esc 和对这个按钮一样生效——这正是以前靠 JS 手写时最容易漏掉的一致性。
closedby:点遮罩关闭终于成了属性
前面那段坐标反算的代码,现在一个属性就顶掉了:
<dialog id="detail" closedby="any">...</dialog>
closedby 有三个取值:
none:只能由脚本或者 command="close" 关掉。按 Esc 不响应,点遮罩也不响应。适合那种必须走完流程才能消失的引导框。
closerequest:响应 Esc 和 request-close 命令,但点遮罩不会关。这是 <dialog> 的默认行为。
any:连点遮罩也关。日常用得最多的就是它。
需要留意的是默认值——不去写 closedby 的时候,<dialog> 一直是 closerequest 的行为,也就是说点遮罩关闭必须显式打开。这一点和很多人直觉相反,以为加了 showModal 就什么都能关。
焦点问题,声明式顺手就解决了
以前用 JS 打开 dialog,有个隐形的坑:关闭之后焦点去哪了?dialog.close() 并不会自动把焦点还给触发它的按钮,结果用户用键盘操作时,焦点掉回 <body>,Tab 一下从页面开头重新走。得自己记住 document.activeElement,关闭时手动 focus() 回去。
用 commandfor 的话,浏览器在关闭时会把焦点归还给那个带 command 的按钮,这是规范化过的行为,不用自己管。
打开时的初始焦点则是另一回事,它由 autofocus 决定:
<dialog id="rename" closedby="any">
<form method="dialog">
<label>新名称
<input name="title" autofocus>
</label>
<button value="ok">确定</button>
<button value="cancel" formnovalidate>取消</button>
</form>
</dialog>
没有 autofocus 时,浏览器会把焦点给框内第一个可聚焦元素。上面这个例子里,如果我不加 autofocus,焦点就落在输入框上——看起来也没什么问题,但顺序可能和你的预期不一样,尤其是框里先放了一个关闭用的图标按钮时。该标就标,别赌。
method=”dialog” 是另一条捷径
上面那段代码里 <form method="dialog"> 值得单独说一下。表单以 dialog 方式提交时,浏览器不会发请求,而是直接把 dialog 关掉,并且把被点击按钮的 value 写进 dialog.returnValue。
const dlg = document.getElementById("rename");
dlg.addEventListener("close", () => {
if (dlg.returnValue === "ok") {
// 这里才去读输入框的值、发请求
}
});
这套机制的好处是把“确定”和“取消”统一处理了——两种操作都走同一个 close 事件,靠 returnValue 区分,不用给两个按钮各绑一个监听。顺带还解决了回车提交的问题:单行输入框里按回车,默认就是提交表单,行为自然是对的。
注意“取消”按钮上的 formnovalidate。如果输入框有 required,不加这个属性的话,用户点取消也会被浏览器拦住说“请填写此字段”,体验非常怪。
五个让我返工的细节
按钮必须显式写 type。command 只在 <button> 上生效,而且如果这个按钮恰好在一个表单里,默认的 type="submit" 会先触发提交。我第一版把带 command 的关闭按钮放在 <form> 里没写 type,点一下表单提交了、页面刷新了、弹窗也没了——看起来像“关闭成功”,实际上整个页面重载了。老老实实写 type="button" 或者 type="submit",别省略。
commandfor 只认 id,不认选择器。写 commandfor=".detail" 完全不工作。这一点在组件化项目里会很难受,因为 id 全局唯一,同一个组件渲染两次就撞了。我的做法是让组件内部自己生成唯一 id(比如带一个自增后缀),把 commandfor 和 dialog 的 id 写成同一个变量。
命令名不要自己发明。command 目前只支持那几个预定义值,写 command="open" 不会报错也不会生效。以后可能会开放自定义命令(有个 command 事件可以监听),但在那之前,非内置的行为还是老老实实写 JS。
::backdrop 只能用伪元素改,加不了 class。想给不同弹窗配不同遮罩,只能靠继承下来的自定义属性或者 :has() 从 dialog 上往下传,不能像普通元素那样加个类名就改。
同时打开两个 dialog 时,第二个会盖在第一个上面,但第一个仍然是模态的。showModal() 会把除了自己以外的所有东西置为 inert,包括已经打开的那个 dialog。所以嵌套弹窗(比如从列表弹窗里再开一个详情弹窗)在第一个框里的焦点会被冻结。真需要嵌套的话,得先用 closedby="none" 把第一个手动关掉再开第二个,或者把第二个做成 popover 而不是 dialog。
旧浏览器的降级
commandfor 和 closedby 都还比较新,特性检测的方式是看原型上有没有对应的属性:
if (!("commandForElement" in HTMLButtonElement.prototype)) {
document.addEventListener("click", (e) => {
const btn = e.target.closest("button[commandfor]");
if (!btn) return;
const target = document.getElementById(btn.getAttribute("commandfor"));
if (!target) return;
const cmd = btn.getAttribute("command");
if (cmd === "show-modal") target.showModal();
else if (cmd === "close") target.close();
else if (cmd === "request-close") target.requestClose();
});
}
这段只在不支持的环境里执行。因为它是事件冒泡阶段的监听,而支持的环境里浏览器已经处理过了——不过为了保险,我在构造函数上加了 event.preventDefault() 去重其实没必要,因为不支持的环境里浏览器本来就不会做任何事。检测到位,两边互不干扰。
一个能直接跑的完整示例
下面这段是纯结构,没有任何样式和脚本,直接贴进 HTML 文件就能验证行为:
<button commandfor="dlg-a" command="show-modal">打开设置</button>
<button commandfor="dlg-b" command="show-modal">打开确认框</button>
<dialog id="dlg-a" closedby="any">
<form method="dialog">
<h2>显示设置</h2>
<p>
<label><input type="checkbox" name="compact"> 紧凑模式</label>
</p>
<p>
<label><input type="checkbox" name="motion"> 减弱动效</label>
</p>
<button type="submit" value="save">保存</button>
<button type="submit" value="cancel" formnovalidate>取消</button>
</form>
</dialog>
<dialog id="dlg-b" closedby="closerequest">
<h2>确定要删除吗?</h2>
<p>删除后不可恢复。</p>
<form method="dialog">
<button type="submit" value="delete">删除</button>
<button type="submit" value="keep" autofocus formnovalidate>保留</button>
</form>
</dialog>
<script>
const a = document.getElementById("dlg-a");
a.addEventListener("close", () => {
console.log("设置框返回:", a.returnValue);
});
const b = document.getElementById("dlg-b");
b.addEventListener("cancel", (e) => {
// 按 Esc 时这里可以先弹个二次确认
console.log("用户想关掉确认框");
});
</script>
几个可以对照验证的点:第一个 dialog 点遮罩会关(closedby="any"),第二个不会(closerequest);两个框都按下 Esc 会关;第二个框打开时焦点落在“保留”按钮上,因为写了 autofocus;任意一个关闭后,焦点回到最初那个触发按钮上。
第二个框的 cancel 监听是我故意留的——closedby="closerequest" 下,Esc 和 command="request-close" 都会先触发 cancel,在这个事件里 preventDefault() 就能把关闭拦下来。要做“有未保存内容,关之前提醒”的功能,这就是唯一正确的位置,别去动 close 事件,那时候已经晚了。
写到最后
这套属性解决的是“粘合代码”的问题,不是交互复杂度的问题。真正的业务逻辑——保存、校验、联动——一样得写 JS,只是不用再写那些把按钮和弹窗连起来的胶水了。胶水代码本身不难,但它数量多、容易漏、测试覆盖不到,永远是线上怪 bug 的温床,能交给浏览器处理的部分就该交出去。
顺带提一句,同一条路上还有个 interestfor,用来做悬停展开的提示、预览这类“轻量级关注”行为,语义上比 popover 更贴合,但目前落地范围还窄。等它稳下来,浮层相关的 JS 大概还能再砍一轮。

