原生对话框不用 JS 了:command、commandfor 与 closedby 实战

2026-10-31 0 844

手上这个后台项目里,弹窗大概有二十来个。早期的写法是给每个 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 大概还能再砍一轮。

原生对话框不用 JS 了:command、commandfor 与 closedby 实战
收藏 (0) 打赏

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

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

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

淘吗网 html 原生对话框不用 JS 了:command、commandfor 与 closedby 实战 https://www.taomawang.com/web/html/2986.html

常见问题

相关文章

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

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