去年维护一个后台系统,翻到公共 JS 里有一个叫 showConfirm() 的函数,返回 Promise。打开一看,三百多行:动态创建 dialog 节点、挂到 body 上、调 showModal()、监听 close、把 returnValue 转成 resolve 值、再 remove() 掉。每次弹窗都是一次 DOM 创建和销毁,而且列表里的删除按钮、详情页的删除按钮、批量操作的删除按钮,各自绑了一份事件委托,名称还不统一。
这套写法现在可以整段删掉。dialog 元素加三个属性——command、commandfor、closedby——弹窗的打开、关闭、点外部关闭全部交给浏览器,HTML 里写清楚关系,JS 只处理「用户点了确定之后干什么」。
这篇文章用一个删除确认框和一个「未保存修改拦截」的编辑器弹窗,把这三件事走一遍。中间的坑我都标出来了,有几个调了一下午才想明白。
dialog 已经自带了四件事
很多人对 dialog 的印象还停留在「一个居中的盒子」,其实它在浏览器原生层面解决了四个以前要写一大堆代码的问题。
- top layer 渲染。dialog 被
showModal()打开后会进入浏览器的顶层渲染层,任何父元素的overflow: hidden、z-index: 99999都管不到它,也不会被裁掉。以前为了这个得用 portal 把节点挪到 body 下面。 - 焦点陷阱。打开后按 Tab 只会在弹窗内部的元素间循环,不会跑到底下的页面。这个以前要写一个库。
- 背景 inert。弹窗打开时,页面其他内容自动变成不可交互状态,鼠标点不到,屏幕阅读器也不会读。
- Esc 关闭。按 Esc 自动调用关闭流程,并且会触发
cancel事件给你拦截的机会。
这四条加起来,等于省掉了一个浮层库。剩下的就是「怎么打开」和「怎么关上」——以前用 showModal() 和 close(),现在连这两个方法调用都可以省了。
command 和 commandfor:把打开动作写进 HTML
先看改造后的删除确认框。整段 HTML:
<button type="button"
commandfor="confirm-delete"
command="show-modal">
删除
</button>
<dialog id="confirm-delete" closedby="closerequest">
<p>删除后无法恢复,确定继续吗?</p>
<form method="dialog">
<button value="cancel">取消</button>
<button value="ok">确定删除</button>
</form>
</dialog>
三处关键点。
commandfor 的值是目标 dialog 的 id。它建立了一条「按钮和弹窗」的引用关系,不需要通过 JS 查询、不需要闭包捕获元素。
command 的值是动作名。dialog 相关的内置命令有三个:show-modal、close、request-close。前两个分别对应 showModal() 和 close(),第三个下面单独讲。
value="ok" 在提交按钮上。配合 form method="dialog",点击后表单不会提交到服务器,而是关闭 dialog 并把 returnValue 设成按钮的 value。这是 dialog 内表单的标准用法。
剩下的 JS 只有一件事:等关闭发生,看用户按了什么。
const dlg = document.getElementById('confirm-delete');
dlg.addEventListener('close', () => {
if (dlg.returnValue === 'ok') {
deleteRecord();
}
// Esc 关闭时 returnValue 是空字符串,
// 很多人忘了处理这一支,代码会静默走完什么都不做
});
对比一下改造前的三百行,这里有六行。
command 的取值不止三个
除了 dialog 那三个,popover 也有一套:
toggle-popovershow-popoverhide-popover
更实用的是自定义命令:任何以两个短横线开头的值,比如 command="--archive",浏览器不会做任何预设处理,但会在按钮上触发一个 command 事件。
<button type="button"
commandfor="record-42"
command="--archive">
归档
</button>
<script>
document.addEventListener('command', (e) => {
if (e.command !== '--archive') return;
const row = document.getElementById(e.source.commandForElement.id);
archiveRow(row);
});
</script>
这里 e.source 是触发按钮,e.command 是命令名。可以只用一个全局监听器处理全站所有归档按钮——因为命令名和目标是写在 HTML 上的,JS 里不需要再判断是哪个按钮、操作哪一行。这就是声明式最有价值的地方:以前那堆「事件委托 + closest 找行 + dataset 取 id」的代码,全没了。
closedby:控制用户能怎么关
这个属性只加在 dialog 上,取值有三个。
closerequest 是默认值。Esc 键可以关,点击背景不能关。适合有表单填写的弹窗——用户误点一下背景关掉了,填的内容全丢,体验很差。
any。Esc 和点击背景都能关。适合信息展示类、日志详情类的弹窗,用户随手点任何地方都想关掉。
none。都不能关。只能通过代码或者内部按钮关掉。适合必须做选择的场景,比如「保存 / 放弃」二选一,用户不能什么都不做就跑掉。
<dialog id="must-choose" closedby="none">
<form method="dialog">
<p>有未保存的修改,请选择:</p>
<button value="save">保存</button>
<button value="discard">放弃</button>
</form>
</dialog>
注意这里没有「取消」,因为用户根本关不掉。Esc 按下去什么都不会发生,点背景也什么都不会发生。整个流程必须选一条路走完。
requestClose 和 cancel:拦截未保存的修改
这是我认为 dialog 最有意思的一处设计。它把「关闭请求」和「真正关闭」拆成了两步。
当用户按 Esc、点背景(closedby="any" 时)、或者点击带 command="request-close" 的按钮——这三种动作都叫「close request」。这时候浏览器不会直接把 dialog 关掉,而是先在 dialog 上触发一个 cancel 事件。你可以 preventDefault() 拦住它。
const editor = document.getElementById('editor');
editor.addEventListener('cancel', (e) => {
if (!hasUnsavedChanges()) return; // 没改动就直接让它关
e.preventDefault(); // 别关
// 换成「有未保存内容」这个弹窗
document.getElementById('must-choose').showModal();
});
这样一来,「按 Esc 想直接关掉但内容没保存」这个场景就有一个标准的处理位置,不需要在 keydown 里判断 keyCode 是不是 27,也不需要自己处理点背景的坐标计算。
值得单独说的一句:command="request-close" 走的是同一条路径,也会触发 cancel。也就是说,弹窗上那个「关闭」按钮,如果写成:
<button type="button"
commandfor="editor"
command="request-close">
关闭
</button>
它和按 Esc 完全等价。用户点这个按钮,会先被拦截问一句「要保存吗」,而不是啪一下关掉了。而这个行为不需要在按钮的 click 里写一行额外逻辑。
如果写成 command="close",就是强制关闭,跳过拦截。两个命令的语义差别很清晰:close 是「我确定要关」,request-close 是「用户想关,让程序决定」。
两个弹窗的时候按 Esc 会怎样
上面的代码里,「有未保存内容」那个弹窗是叠在编辑器弹窗上面打开的。这时候按 Esc,浏览器只会关掉最上面那个(must-choose),下面那个 editor 纹丝不动。
这是正确的行为,但要注意 close 事件的触发顺序:先是最上层的 must-choose 触发 close,再是它从 top layer 里移除。中间有一小段时间是两个 dialog 都开着的状态。如果你的 close 监听器里做了「关闭后清空编辑器」之类的操作,可能会误伤。
我的做法是给每个 dialog 的 close 监听器加上「确认是自己触发的」判断:
mustChoose.addEventListener('close', () => {
if (mustChoose.returnValue === 'save') {
saveEditor();
editor.close(); // 显式关掉下面那个
}
// 'discard' 分支不做事,让下面的 cancel 处理器决定是否拦截
});
显式调用 close() 而不是指望它「跟着一起关」,比揣测浏览器的行为可靠得多。
转场动画要写 allow-discrete
dialog 的进出场动画有一个共同点:它会在 display: none 和 display: block 之间切换。而 display 默认是离散属性,不支持过渡,所以你得显式告诉浏览器允许它参与。
dialog {
opacity: 0;
transform: translateY(8px);
transition: opacity .2s, transform .2s, display .2s allow-discrete;
}
dialog[open] {
opacity: 1;
transform: none;
}
/* 进场动画的起始状态,没有这段的话打开是瞬间出现的 */
@starting-style {
dialog[open] {
opacity: 0;
transform: translateY(8px);
}
}
dialog::backdrop {
opacity: 0;
transition: opacity .2s, display .2s allow-discrete;
}
dialog[open]::backdrop { opacity: 1; }
@starting-style {
dialog[open]::backdrop { opacity: 0; }
}
@starting-style 这一段是必需的。它告诉浏览器「元素刚进入 DOM 或者刚变成可见时的初始样式是什么」,这样过渡才有起点可算。第一次写动画的时候我只写了 [open] 那部分,结果打开是瞬间出现,关闭是平滑消失——现在回看那段调试过程挺蠢的,但确实是想通 @starting-style 的用途之后才解决。
七个踩过的坑
一、忘了写 type=”button”
带 command 的按钮如果放在 <form> 里,不写 type 的话默认是 submit。点一下就会提交表单,然后才执行命令。表现是页面上看起来弹窗弹出来了,但同时网络请求也发出去了。
养成习惯,凡是不做提交的按钮一律写 type="button",不要指望浏览器体贴。
二、同一个 dialog 连续 showModal 会抛异常
showModal() 在 dialog 已经处于打开状态时,会抛一个 InvalidStateError。以前用工具函数每次创建新节点不会遇到,改成复用同一个 dialog 之后就撞上了。
如果某个地方可能会重复打开,包一层判断:
if (!dlg.open) dlg.showModal();
注意 command="show-modal" 走的是内置路径,浏览器自己处理这个状态,不用你操心。只有手动调用 showModal() 的时候才需要加判断。
三、Esc 关闭时 returnValue 是空的
用户按 Esc 关掉弹窗,returnValue 是一个空字符串,不是 'cancel',也不是 null。
很多人在 close 监听器里写 if (dlg.returnValue === 'ok') { 做事情 },这时候逻辑是对的——什么都没发生。但如果写成 if (dlg.returnValue !== 'cancel') { 做事情 },就会在 Esc 关闭时误触发一次。
我的处理方式是在构造函数里先把 returnValue 初始化成 'cancel',让默认值和 Esc 关闭的结果保持一致,逻辑判断就不会有分支差异。
四、点背景的检测方式
正常来说,closedby="any" 已经帮你处理了点背景关闭。但如果你要在这个基础上加「点击后先跑一段动画再关」之类的自定义逻辑,就需要自己判断点击的是不是一个真正的背景点击。
标准做法是看事件目标:
dlg.addEventListener('click', (e) => {
// 只有点到 dialog 元素本身才是背景点击,
// 点到里面的内容,e.target 是那个内容
if (e.target !== dlg) return;
// 处理背景点击
});
注意 ::backdrop 不是 DOM 节点,它的点击事件也是派发到 dialog 上的,所以判定条件只需要看 e.target。但是——如果 dialog 本身有 padding,点击 padding 区域也会被判定为背景点击,因为 e.target 就是 dialog。这种情况想让 padding 区域算「内部」,只能在外面包一层 div,把 padding 挪到 div 上。
五、autofocus 在再次打开时不一定生效
弹窗里放了 <input autofocus>,第一次打开时它会自动获得焦点,很好用。但如果用户关掉再打开,某些浏览器会记住上次的焦点,autofocus 不再复用。
稳妥的做法是打开后手动处理。但 command 属性不会触发任何「打开」事件给你监听,所以得换一个思路——监听 command 事件,在事件处理里等一帧再 focus:
document.addEventListener('command', (e) => {
if (e.command !== 'show-modal') return;
const dlg = e.target.commandForElement;
if (!dlg) return;
queueMicrotask(() => {
// 等浏览器自己的焦点处理跑完
dlg.querySelector('input')?.focus();
});
});
用 queueMicrotask 而不是 setTimeout(0),是因为浏览器设置初始焦点是同步完成的,微任务足够等在这一步之后。
六、dialog 和 transform 的关系很怪
因为 dialog 在 top layer 渲染,它不受任何祖先元素的 transform 影响。这一点和普通的 absolute 定位元素完全不同,后面那种会被祖先的 transform 影响成为新的包含块。
我遇到的具体表现是:外面有一个滑入动画的抽屉容器,里面写了一个 dialog。抽屉滑入时 dialog 的位置不跟着动,看起来像是悬浮在半空。解决办法只有一个——把 dialog 直接放到 body 下面,不要嵌在有 transform 的容器里。这正是当年 portal 要解决的问题,dialog 用另一种方式解决了,但同时也要求你把它放在顶层。
七、旧版浏览器上的行为差异
dialog 元素本身支持得很好,但 closedby 和 command 都比较新。旧浏览器上表现为:点了带 command 的按钮,什么都不会发生,弹窗不出现。
特性检测用 JS:
const supportsInvokers = 'commandForElement' in HTMLButtonElement.prototype;
const supportsClosedBy = 'closedBy' in HTMLDialogElement.prototype;
回退方案不复杂,把内置命令翻译成对应的方法调用:
if (!supportsInvokers) {
document.addEventListener('click', (e) => {
const btn = e.target.closest('button[commandfor]');
if (!btn) return;
const dialog = document.getElementById(btn.getAttribute('commandfor'));
if (!dialog) return;
const cmd = btn.getAttribute('command');
if (cmd === 'show-modal') dialog.showModal();
else if (cmd === 'close') dialog.close();
else if (cmd === 'request-close') dialog.requestClose();
});
}
自定义命令 --foo 的回退可以直接派发一个合成事件,让监听器不需要写第二套:
btn.dispatchEvent(new CustomEvent('command', {
bubbles: true,
detail: {
command: cmd,
source: btn,
// 手动补上原生的那些字段
},
}));
这个小回退脚本我放进了公共文件里,在任何地方都能用得上,跟 dialog 本身没关系。
什么时候可以放心用
dialog 元素这段不用犹豫,直接用。它的浏览器覆盖率已经很好了,而且在不支持的环境下它会降级成一个可见的普通元素,不会破坏页面。
command 和 closedby 要看你项目面向的浏览器分布。如果是内部后台、公司内网、给固定设备用的系统,可以放心用,配合上面的回退脚本更稳。如果是面向大众的 C 端项目,建议先用特性检测,把声明式写法和 JS 回退都准备好,等覆盖率上来了再把回退删掉。
有一类场景我一直没有切过去:需要完全控制关闭动画、需要在关闭时同步返回一个 Promise、需要跨 iframe 传递结果的弹窗。这些还是用 JS 工具函数做更直接。dialog 的 close 事件是同步的,但你没法像 Promise 那样在关闭后立刻拿到结果继续往下链式写代码。如果你习惯了 await showConfirm() 那种写法,声明式弹窗反而会觉得拧巴。
收尾
把那个三百行的 showConfirm() 删掉之后,整个项目里弹窗相关的 JS 从 500 多行变成了 80 行左右。省下来的全是「找到元素、绑事件、判断目标、清理节点」这些和业务无关的代码。
真正让我觉得值的不是代码行数,是行为变得可预期。以前弹窗和触发它的按钮之间的对应关系藏在闭包里,改动的时候得顺着事件往上找。现在打开编辑器,搜一下按钮上的 commandfor,一秒就能跳转过去。声明式的价值就在这里:关系写在你最可能寻找的地方,而不是写在运行时才连起来的代码里。
如果你想试一下,不用一次改完。挑一个最常出问题的弹窗——那种「用户按 Esc 关掉后数据丢了」「点背景关掉了但提示没出现」这类——先把它改成 dialog + closedby + command 的写法。改完之后你会发现,剩下的弹窗往往也顺手能改。

