HTML command 与 dialog 实战:确认弹窗不用再写事件委托

2026-10-09 0 475

去年维护一个后台系统,翻到公共 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-popover
  • show-popover
  • hide-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 的写法。改完之后你会发现,剩下的弹窗往往也顺手能改。

HTML command 与 dialog 实战:确认弹窗不用再写事件委托
收藏 (0) 打赏

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

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

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

淘吗网 html HTML command 与 dialog 实战:确认弹窗不用再写事件委托 https://www.taomawang.com/web/html/2913.html

常见问题

相关文章

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

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