HTML 原生对话框体系:dialog、closedby、command 与 inert 的完整实战

2026-10-15 0 286

前阵子帮一个后台管理系统做无障碍审计,工具跑完给了一份列表,红字最多的两页,都栽在同一个地方——弹窗。审核意见写得很直白:焦点跑到了弹窗外面、Esc 键关不掉、关闭之后焦点丢了、背景内容还能用键盘 Tab 到。

这些弹窗都是自己用 div 加上绝对定位写的,加上 z-index 堆得老高,看起来跟原生的没差。但无障碍工具一测就现原形。改的时候才发现,手写弹窗的那些坑,全部都是浏览器本来就帮你处理好的事——只是我们一直没用原生的 <dialog>。

这篇把 dialog 的相关新特性翻一遍,做一套真正能用在实际项目里的原生模态体系。

先说说手写弹窗到底要处理多少事

为了有个对照,先把手写弹窗要关心的东西列一遍:

  • 键盘焦点必须锁在弹窗内,Tab 到最后一个元素要跳回第一个
  • 弹窗打开时,背景内容要挡住交互,阻止屏幕阅读器读到
  • Esc 键关闭,但要不要关、开启关闭之前要做什么校验,都得自己写
  • 关闭之后焦点得还给触发元素,不然键盘用户会”找不着北”
  • 点击遮罩关闭,但点击弹窗内部不能误触
  • 弹窗打开时禁止 body 滚动,关闭时恢复,还要处理滚动位置偏移
  • 多个弹窗叠加时的层级和关闭顺序
  • 弹窗内容动态变化时,焦点边界也要跟着更新

列表写到这里,就能理解为什么很多团队都用一个几百行的第三方弹窗库了。问题是,这些事浏览器本来就会做。

dialog 元素的三种状态

<dialog> 默认是一个隐藏的元素,它有三种状态:

<dialog id="confirm">
  <p>确定要删除这条记录吗?</p>
</dialog>

第一种是关闭状态。元素带 display: none,不占位,不参与无障碍树。

第二种是非模态打开,靠 open 属性:

<dialog open>...</dialog>

或者 dialog.show()。所谓”非模态”就是它只是显示出来,不会把背景挡掉,用户仍然可以操作页面其他部分。这种模式适合做浮动面板、引导提示一类的轻量场景。

第三种是模态打开,必须用 showModal():

const dlg = document.getElementById('confirm')
dlg.showModal()

模态打开是 dialog 真正厉害的地方。它一次性做了几件事:把弹窗放到顶层(top layer),自动加一个半透明背景遮罩 ::backdrop,把焦点移到弹窗内部第一个可聚焦元素,给弹窗外的所有内容加上 inert 效果——也就是背景彻底不可交互、屏幕阅读器跳过、鼠标点击也无效。

以前我们为了模拟这个 inert 效果,写过一个”监听 tab 键手动兜底焦点”的函数,代码一百多行,还有边界 bug。用 dialog 就是一行 showModal()。

::backdrop 和样式钩子

遮罩层由浏览器提供,通过 ::backdrop 伪元素定制:

dialog::backdrop {
  background: rgba(15, 23, 42, 0.45);
  backdrop-filter: blur(2px);
}

有一点必须知道:::backdrop 的样式随着元素进入 top layer 才存在。你写 dialog::backdrop { background: red },弹窗关闭时这条规则是不匹配的。这也意味着你不能通过 ::backdrop 做”淡出动画”,因为关闭的那一刻它就不存在了。

顺带提一句 @starting-style,用来做入场动画的:

dialog[open] {
  opacity: 1;
  transform: scale(1);
  transition: opacity 0.2s ease, transform 0.2s ease, display 0.2s allow-discrete;
}

@starting-style {
  dialog[open] {
    opacity: 0;
    transform: scale(0.96);
  }
}

关键在 transition 里那个 display 0.2s allow-discrete。display 本身是个不能过渡的属性,但是加了 allow-discrete 之后,让浏览器在过渡结束的那一刻才切换它的值,从而让整个 opacity 过渡有地方播放。不加这句,关闭的时候弹窗会瞬间消失,入场动画白做。

入场动画这块内容不少,展开讲就跑偏了,这里点到为止。真正撑起这套体系的是下面几个属性。

closedby:让 Esc 和点击遮罩的行为可配置

模态弹窗默认就有两个关闭方式:Esc 键,以及点击 ::backdrop 区域。以前很多项目想要”点击遮罩不关闭”(比如要求用户必须做出选择才能继续),只能监听 click 事件判断点击坐标,代码长且脆。

现在一个属性搞定:

<dialog id="must-choose" closedby="none">
  <p>请选择一项操作后再继续。</p>
  <button value="ok">我知道了</button>
</dialog>

closedby 有三个值:

  • any:Esc 和点击遮罩都能关,模态弹窗的默认值
  • closerequest:只允许 Esc 关闭,点击遮罩无效
  • none:两个都不行,必须通过弹窗内的按钮关闭

有一种场景经常用到 closerequest:弹窗里有用户填了一半的表单,点击遮罩很容易误触导致数据丢失,但 Esc 是一个明确的”我要关闭”的操作,允许保留。这个分寸感用之前的手写方案很难拿捏,现在一个值就够了。

还有一种更细粒度的控制,通过监听 cancel 事件来实现:

dialog.addEventListener('cancel', e => {
  if (formDirty()) {
    // 阻止 Esc 关闭,改为弹一个二次确认
    e.preventDefault()
    showSaveConfirm()
  }
})

cancel 事件在浏览器准备关闭弹窗时触发,此时 preventDefault() 就能把关闭动作拦下来。这是数据丢失防护的核心写法。过去手写弹窗做不到这么优雅——要么是拦不住,要么是拦住了但关不掉之后的流程没设计。

command 与 commandfor:声明式控制

以前打开弹窗必须写 JS:

<button id="open-btn">打开</button>
<dialog id="my-dialog">...</dialog>

<script>
  document.getElementById('open-btn').addEventListener('click', () => {
    document.getElementById('my-dialog').showModal()
  })
</script>

有了 command 和 commandfor,这两行 JS 可以彻底省掉:

<button commandfor="my-dialog" command="show-modal">打开</button>

<dialog id="my-dialog">
  <button commandfor="my-dialog" command="close">关闭</button>
</dialog>

commandfor 指向目标的 id,command 说明要做什么动作。目前支持的值有:

  • show-modal:模态打开
  • show-popover / hide-popover / toggle-popover:控制 popover
  • close:关闭最近的 dialog
  • request-close:请求关闭,走和 Esc 一样的取消流程,可被 cancel 事件拦截
  • show / hide / toggle:控制非模态 dialog

close 和 request-close 的区别很重要。close 是直接关,跳过 cancel 事件;request-close 会先走一遍取消流程。如果需要”关闭前先确认”这类逻辑,必须用 request-close,否则 cancel 事件根本不触发。

这个属性的价值不只是省代码,更重要的是让触发关系写在 HTML 里。以前打开弹窗的按钮和弹窗本身经常不在同一个模块里,靠 JS 手动查找元素,重构的时候一改 id 就崩。现在 HTML 里明明白白写着”这个按钮负责打开那个弹窗”,一眼就能看清楚。

对无障碍尤其有帮助:屏幕阅读器可以读出这两个元素之间的关系,不再需要手写 aria-haspopup 和 aria-controls。

inert:让背景彻底”隐身”

showModal() 会自动给弹窗之外的交互内容加 inert 效果,这一点前面提过。但有一种情况需要手动加:非模态弹窗。

比如做一个侧边抽屉,用户可能希望抽屉打开时还能滚动主内容看上下文——这时候用非模态是对的。但抽屉本身如果遮挡了某个按钮,用户 Tab 过去会引发困惑,就可以针对性地给那个按钮加 inert:

<button id="danger-action" inert>删除账号</button>

<script>
  drawer.addEventListener('close', () => {
    document.getElementById('danger-action').removeAttribute('inert')
  })
</script>

inert 是个布尔属性,有两种常见用法。第一种是像上面这样写在 HTML 里,运行时通过 JS 切换;第二种是作为全局属性,动态控制整个区域的启用状态(比如表单提交中禁止操作)。

被 inert 包裹的内容,表现包括:不能点击、不能聚焦、从无障碍树里移除、也不能被搜索/翻译之类的功能命中。这几点都比简单的 pointer-events: none 更彻底——后者只是挡住了鼠标,键盘用户依然能 Tab 进去,屏幕阅读器也照样读出来,属于”假禁用”。

顺带说一句:<dialog>、<fieldset disabled>、<input disabled> 这些原生机制本身就带有类似 inert 的行为。能用一个属性解决的,不要自己去实现一层。

案例一:带校验的删除确认框

先把前面几块拼成一个完整的确认框:

<dialog id="del-confirm" closedby="closerequest" aria-labelledby="del-title">
  <form method="dialog">
    <h2 id="del-title">删除确认</h2>
    <p>删除后无法恢复,确定继续吗?</p>

    <div>
      <button value="cancel">取消</button>
      <button value="confirm">确认删除</button>
    </div>
  </form>
</dialog>

几个点在代码里藏着。

<form method="dialog"> 让表单提交自动关闭弹窗,关闭时 dialog.returnValue 会被设成提交按钮的 value。这是 dialog 里最容易被忽略但最实用的技巧——不用在按钮上绑点击事件了。

const dlg = document.getElementById('del-confirm')
const trigger = document.getElementById('delete-btn')

trigger.addEventListener('click', () => {
  dlg.showModal()
})

dlg.addEventListener('close', () => {
  if (dlg.returnValue === 'confirm') {
    actuallyDelete()
  }
  // 关闭后把焦点还给触发按钮
  trigger.focus()
})

焦点归还这一步不能省。虽然浏览器在关闭 dialog 时会尝试把焦点还给打开它的元素,但如果触发用的是 commandfor,或者关闭之后触发按钮被重新渲染成了别的 DOM 节点,焦点就可能落到 body 上。手动 focus() 一次,稳。

closedby="closerequest" 这个选择是刻意的。删除操作点击遮罩关闭很容易让人以为”点错了没删”,但实际上正在关闭,反而容易造成误操作。用 Esc 关闭更明确一些。

还有一个细节:这里写了 aria-labelledby 指向标题元素。<dialog> 本身没有隐式的标题关联,屏幕阅读器不会自动读出标题,得手动指定。这是无障碍审计里高频的扣分项,值得养成习惯。

按钮里的 <button> 没写 type="button",也没有问题——在 <form method="dialog"> 里,默认行为就是提交并关闭。但如果这个表单外面还叠了别的表单结构(比如弹窗里再嵌一个 form),那就得显式写 type,避免顺序搞混。

案例二:带表单的对话框,提交后不关闭

第二种常见需求:弹窗里有表单,提交时要校验,校验失败的话不能关掉,得留在原地给用户提示。

<dialog id="profile-edit">
  <form id="profile-form">
    <h2>编辑个人资料</h2>

    <label for="nickname">昵称</label>
    <input id="nickname" name="nickname" required maxlength="20">

    <label for="bio">简介</label>
    <textarea id="bio" name="bio" maxlength="200"></textarea>

    <div>
      <button type="button" commandfor="profile-edit" command="close">取消</button>
      <button type="submit">保存</button>
    </div>
  </form>
</dialog>

<script>
const form = document.getElementById('profile-form')
const dlg = document.getElementById('profile-edit')

form.addEventListener('submit', async (e) => {
  e.preventDefault()

  const fd = new FormData(form)
  const payload = Object.fromEntries(fd)

  // 提交过程中禁用再次点击
  const submitBtn = form.querySelector('[type="submit"]')
  submitBtn.disabled = true

  try {
    await saveProfile(payload)
    dlg.close()
  } catch (err) {
    showInlineError(form, err.message)
    submitBtn.disabled = false
  }
})
</script>

这里有几个关键点。

表单不写 method=”dialog”。写了的话点保存会立即关闭弹窗,跳过我们的校验和提交逻辑。取消按钮改用 command="close" 显式关闭。

preventDefault 是必须的。哪怕不用 dialog 的提交行为,浏览器也会尝试用传统方式提交,页面会刷新。很多项目里弹窗表单提交一次页面跳走,就是这个原因。

提交中禁用按钮。这一步平时不太注意,但用户很容易在保存等待期间连点几次,导致重复提交。禁用按钮比加 loading 图标更彻底。

错误信息写在表单内部。不用全局 toast 去提示,因为焦点在弹窗里,toast 可能在页面顶部看不到。更稳妥的做法是把错误信息插到对应输入框下方,并把焦点移到出错字段上。

function showInlineError(form, message) {
  let tip = form.querySelector('.form-error')
  if (!tip) {
    tip = document.createElement('p')
    tip.className = 'form-error'
    tip.setAttribute('role', 'alert')
    form.prepend(tip)
  }
  tip.textContent = message

  // 把焦点移到出错的位置
  const firstInvalid = form.querySelector('[aria-invalid="true"]')
  if (firstInvalid) firstInvalid.focus()
}

role="alert" 让屏幕阅读器在错误出现时立即朗读出来,键盘用户不用自己去翻找错误提示在哪里。

案例三:侧边抽屉

抽屉类组件要不要用模态,取决于产品需求。如果打开抽屉时用户还能操作主页面,用非模态更合适:

<dialog id="side-drawer" popover>
  ...
</dialog>

等等,这里用了 popover 而不是 <dialog> 的模态能力——这是另一个选择。

抽屉场景其实有两条路。

路线一:用 <dialog> 的非模态模式。通过 show() 打开,此时没有遮罩,背景可交互。适合需要在抽屉里做操作同时参照主内容的情况。

<dialog id="filter-drawer">
  <h2>筛选条件</h2>
  <input type="checkbox" id="in-stock">
  <label for="in-stock">仅看有货</label>

  <button commandfor="filter-drawer" command="close">收起</button>
</dialog>

<script>
document.getElementById('open-filter').addEventListener('click', () => {
  document.getElementById('filter-drawer').show() // 非模态
})
</script>

路线二:用 popover。popover 天生就是悬浮面板的语义,不需要 show(),通过 popovertarget 或 command="toggle-popover" 控制。它的好处是自动有”点击外部关闭”的行为,响应速度也更快。

怎么选,看两点。第一,如果面板里要做表单交互,用户可能在里面停留一段时间,用 dialog 更稳;如果只是展示一段说明,用 popover。第二,如果需要多个抽屉同时打开(比如一个筛选一个详情),popover 的”轻量关闭”会干扰,得设成 popover="manual",这时候 dialog 反而更合适。

两者在层级上都走 top layer,不受 overflow: hidden 的裁剪,这一点对抽屉很关键——侧边栏如果挂在某个卡片下面,用普通的 position: fixed 会被裁掉。

几个踩过的坑

1. 页面加载时 dialog 一闪而过

有人为了调试方便,直接在 HTML 里写了 <dialog open>,然后在 JS 里调 showModal()。这在慢网络下会导致弹窗先以非模态形式闪一下,然后才变成模态。

正确做法是不要在 HTML 里写 open,全部通过 showModal() 或 command="show-modal" 控制。

2. 嵌套 dialog 时的层级

从 dialog 里再打开一个 dialog,新的会叠加在上面,这是浏览器行为,没问题。但要注意:关闭内层 dialog 不会自动恢复外层的焦点。外层如果监听 close 事件去处理后续动作,这个时候可能会触发两遍(一次是内层关闭,一次是外层真正关闭)。加分判断:

outerDlg.addEventListener('close', e => {
  // 只有外层自己关闭才处理
  if (e.target !== outerDlg) return
  ...
})

3. 弹窗打开期间的滚动问题

原生 modal dialog 会自动阻止背景滚动,不需要写 overflow: hidden。但有一个例外:如果弹窗内容本身高度超过视口,需要滚动,默认它会溢出。要给它加滚动样式:

dialog[open] {
  max-height: 85vh;
  overflow: auto;
}

用 vh 还是 dvh?移动端建议用 dvh,因为地址栏显示/隐藏时 vh 是不变的,会导致弹窗底部被挡住。dvh 跟着视口变化,虽然会有一点抖动,但内容不会丢。

4. 表单提交后自动关闭的坑

前面提到 <form method="dialog"> 会在提交时关闭弹窗,但有一个隐藏行为:提交过程中如果 close 事件被 preventDefault() 拦下,弹窗会关闭失败,但 returnValue 已经被设置了。下次打开时 returnValue 还是上一次的值,容易误判。

稳妥做法是在 close 事件处理完之后,手动重置一次:

dlg.addEventListener('close', () => {
  const val = dlg.returnValue
  // 用完立刻清掉,避免下次打开误读
  dlg.returnValue = ''
  handleResult(val)
})

5. command 属性对旧浏览器的兼容

command 和 commandfor 是最近才落地的,稍旧一点的浏览器不认识。所以如果目标是广覆盖,别把功能完全押在这个属性上。

降级方式:对不支持的环境,用 JS 手动接管按钮的点击,行为跟 command 保持一致。判断方式是 'command' in HTMLButtonElement.prototype,或者更粗暴一点,直接在 HTML 上加一个 data-fallback="open" 之类的标记,然后在启动脚本里扫一遍。

降级怎么写

dialog 元素本身的支持度已经非常广,基本不用担心。真正需要判断的是 closedby、command、@starting-style 这几个新东西。

@supports (command: show-modal) {
  /* 用 command 属性实现 */
}

@supports (closedby: none) {
  /* 靠 closedby 控制关闭行为 */
}

@supports (transition-behavior: allow-discrete) {
  /* 关闭时也能淡出 */
}

对不支持 closedby 的环境,用 JS 兜底:监听 click 事件,判断是不是点在了 backdrop 上:

dialog.addEventListener('click', e => {
  // click 事件的 target 是 dialog 本身,说明点在遮罩上
  if (e.target !== dialog) return
  if (dialog.dataset.closedby === 'none') return
  dialog.close()
})

这个判断之所以成立,是因为 dialog 内部的所有内容都在子元素里,点击内部时 e.target 是子元素。点击 backdrop 时事件会冒泡到 dialog,此时 e.target 恰好是 dialog 本身。

但这种兜底和原生 closedby 的行为有细微差别:JS 版本没法区分”点击遮罩关闭”和”点击对话区域内的空白处”,而原生版本能精确识别。所以能用原生尽量用原生,兜底只是给旧环境一条活路。

另外提示一点:closedby 只对模态 dialog 生效。非模态 dialog 本身没有遮罩,这个属性没有意义。

把这套体系落到项目里

给团队做规范的时候,我列了几条硬性约定:

  • 所有弹窗必须用 <dialog>,不允许手写 div 模拟
  • 模态场景必须用 showModal(),不用 show()
  • 打开按钮优先用 command="show-modal",只有在需要携带参数时
  • 才写 JS
  • 关闭前的校验通过 cancel 事件拦截,不用别的方式
  • 弹窗必须有 aria-labelledby 指向标题
  • 关闭后必须调 focus() 把焦点还给触发元素
  • 不允许在 HTML 里写 open,避免闪屏

这几条定下来之后,弹窗相关的无障碍问题基本清零。以前每次审计都得改一遍的地方,现在从源头就没有了。

从成本上说,这套方案的手写代码量比引入一个弹窗库要小得多——无非就是模板里多写几个属性。但它换回来的东西是实打实的:焦点、遮罩、Esc、无障碍语义,全部由浏览器负责,不用担心实现漏了某个边界。

最后

回头看,<dialog> 这个元素被冷落了很久。它在 2013 年就有了,能真正用起来是最近几年的事——随着 closedby、command、@starting-style 这几个配套特性落地,它终于从”勉强能用”变成了”比手写更好用”。

如果项目里现在还在用 div 拼弹窗,建议挑最简单的那个先换成 <dialog> 试试。改完之后打开开发者工具的无障碍面板看一眼,就能感受到差别——那把原来要写一百多行 JS 才能填上的漏洞,一行 showModal() 就修补了。

HTML 原生对话框体系:dialog、closedby、command 与 inert 的完整实战
收藏 (0) 打赏

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

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

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

淘吗网 html HTML 原生对话框体系:dialog、closedby、command 与 inert 的完整实战 https://www.taomawang.com/web/html/2942.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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