上个月改一个后台的“通知偏好”面板。原本那套是自己写的浮层:一个绝对定位的 div,外面套一层透明遮罩,配一百多行 JS 处理点击外部关闭、Esc 关闭、焦点跳转,还专门写了个工具函数来管 z-index 的分配。代码量不算大,但每次 UI 一动就要重新调一遍。后来换成 popover 属性,删到只剩几十行,那几个监听器全没了——浏览器自己就把活儿干了。
这篇不讲概念,直接按我当时重构的顺序写:先看 popover 的最基本用法,再讲它和 dialog 到底怎么选,最后把 command / commandfor 接上去,拼一个完全不用 JS 绑定事件的完整案例。
一、popover 的三种值,别只会用默认的
最省事的写法就是给元素加一个 popover 属性,再用 popovertarget 指向它:
<button popovertarget="notify-panel">通知偏好</button>
<div id="notify-panel" popover>
<p>这里是一些设置项</p>
</div>
浏览器会做这几件事:把 div 提到顶层(top layer,不受任何父元素 overflow 或 z-index 干扰)、加上一个隐式的 display: none、点击按钮时切换显示、点页面其他位置自动关闭、按 Esc 自动关闭。你没写一行 JS。
popover 属性有三个有效值,很多人只知道第一个:
popover="auto"或干脆写popover:默认行为。同一时间只能开一个 auto 类型的弹层,开新的会自动关掉旧的。popover="manual":不自动关。点外部、按 Esc 都没用,必须由代码或触发按钮主动关闭。做那种“必须点确定才能走”的提示就用这个。popover="hint":后加的值,专门给工具提示用。它不参与 auto 的互斥——也就是说,提示气泡弹出来时,不会把旁边的下拉菜单挤掉。以前 tooltip 和 menu 打架的问题,靠这个值就能解决。
说一个容易踩的地方:auto 的“互斥”是按打开的先后顺序算的,会形成层次关系。如果你在 popover A 里面又开了一个 popover B,那么关闭 A 的时候 B 也会被一起关掉。这大多数时候是想要的效果,但如果你想让 B 独立存在,就得把 B 改成 manual。
另外,popover 元素默认会出现在视口正中间,因为它被提到顶层之后相当于 position: fixed。这一点第一次用的人八成会愣一下——按钮在右上角,面板弹在屏幕中央。
二、dialog 和 popover,一句话区分
选哪个,就看一件事:要不要挡住后面的内容。
<dialog> 调 showModal() 之后是模态的。后面的页面点不动,焦点被锁在对话框内部,Tab 循环只在框里转,同时会渲染一层 ::backdrop 遮罩。按 Esc 会关闭,但关闭前会先派发一个 cancel 事件,你可以在这个事件里 preventDefault() 把它拦下来。
popover 是非模态的。后面的页面照常能点,没有遮罩,焦点也不会被困住。更适合放侧边筛选、设置面板、操作菜单这类“用户可以边看主内容边操作”的东西。
两者都在顶层,所以不会被祖先元素的 overflow: hidden 裁掉,也不会被 z-index 更高的元素盖住。这是它们相比自研浮层最大的优势。
有个坑得单独提一下:<dialog open> 不等于模态。直接写 open 属性只是让 dialog 显示出来,不锁焦点、不遮罩、和普通 div 没区别,而且这种状态下 Esc 也不会关它。要想清楚自己要的是哪种。
还有一个区别是有没有返回值。dialog 有 returnValue,配合 <form method="dialog"> 能直接用提交按钮的 value 当结果;popover 没有这套机制,结果要么读写表单元素,要么自己维护状态。
三、command 和 commandfor:把绑定这件事交给 HTML
早期控制 popover 用的是 popovertarget + popovertargetaction,只能写在 button 上,只能管 popover。后来这套能力被泛化了,变成了 command 和 commandfor 两个属性——能管 dialog,也能管 popover,而且绑定的行为不依赖 JS 的事件委托,组件被移动、重新挂载之后依然有效。
内置的命令值这些:
show-modal:对 dialog 调 showModal(),也就是用模态方式打开close:对 dialog 调 close(),直接关闭,没有拦截的机会request-close:对 dialog 调 requestClose(),走和按 Esc 一样的流程,会先派发 cancel,可以被拦show-popover/hide-popover/toggle-popover:控制 popover 的三种动作
写法长这样:
<button commandfor="advanced" command="show-modal">高级设置</button>
commandfor 里填目标元素的 id,command 填动作。一个元素可以被多个按钮用不同的命令指向,互不影响。
命令值还可以自定义,只要以 -- 开头:
<button commandfor="card" command="--rotate">旋转</button>
<script>
document.getElementById('card').addEventListener('command', (event) => {
if (event.command === '--rotate') {
// event.source 是发出命令的那个按钮
// event.target 是 commandfor 指向的元素,也就是 card
}
});
</script>
自定义命令走的还是同一个 command 事件,好处是写法统一——不管内置还是自定义,都是“按钮发出、目标接收”这一套。
四、完整案例:通知偏好面板
需求:主页面有个按钮打开一个非模态的设置面板,面板里再放一个按钮打开高级设置的模态框。全程不写事件绑定。
<button commandfor="prefs" command="toggle-popover">通知偏好</button>
<div id="prefs" popover>
<h2>通知方式</h2>
<label><input type="checkbox" checked> 站内信</label>
<label><input type="checkbox"> 邮件</label>
<label><input type="checkbox"> 短信</label>
<button commandfor="advanced" command="show-modal">高级设置</button>
<button commandfor="prefs" command="hide-popover">完成</button>
</div>
<dialog id="advanced" aria-labelledby="adv-title">
<form method="dialog">
<h2 id="adv-title">高级设置</h2>
<label>免打扰时段 <input type="time" value="22:00"></label>
<button value="cancel">取消</button>
<button value="save">保存</button>
</form>
</dialog>
里面有几个值得单独说的点。
form method=”dialog” 是 dialog 专属的
表单留空 method 默认是 get,会刷新页面。写成 dialog 之后,提交动作变成“关闭对话框”,并且把被你点击的那个提交按钮的 value 写进 dialog.returnValue。取消和保存共用同一个表单,不需要额外给按钮绑 click。
document.getElementById('advanced').addEventListener('close', (event) => {
if (event.target.returnValue === 'save') {
// 保存逻辑
}
});
注意监听的是 close 不是 submit。close 在对话框真正关闭之后触发,不管是点按钮关的、按 Esc 关的还是代码关的,都会走到这里,很适合做统一的收尾。
close 和 request-close 的区别
如果你在关闭之前需要确认一下(比如“有未保存的修改,确定关闭吗”),command 要用 request-close,它会先派发 cancel 事件:
const dlg = document.getElementById('advanced');
dlg.addEventListener('cancel', (event) => {
if (hasUnsavedChanges()) {
event.preventDefault();
// 这里可以再弹一个确认框,用户确认后再调 dlg.close()
}
});
如果用 command="close",直接关掉,上面的逻辑根本没有机会执行。这两个值的差别就是这么实际。
popover 里面套 dialog
上面这个结构里,prefs 是非模态的,advanced 是模态的。打开 advanced 的时候,prefs 还在后面保持着打开状态;关掉 advanced,焦点会回到“高级设置”那个按钮上。整个过程中你不需要手动管理任何一个元素的显示状态。
五、定位:让 popover 贴住触发按钮
前面说过 popover 默认在屏幕中间,这在大部分场景下都不能接受。解决办法是 CSS 锚点定位:
.trigger {
anchor-name: --trigger-btn;
}
[popover] {
position-anchor: --trigger-btn;
position-area: bottom span-right;
margin-top: 6px;
}
思路是给触发按钮起一个锚点名字,然后让 popover 以它为参照物定位。position-area 用的是九宫格思路,bottom span-right 意思是“放在锚点底部、并向右展开”。出现空间不够的时候,浏览器会自动翻转到上方,不需要自己写一堆边界判断。
有两个限制:一是锚点元素和 popover 要在同一棵文档树里;二是锚点元素必须是可见的,如果它被隐藏或被移出文档,popover 会退回默认位置,而不是跟着消失。
浏览器不支持锚点定位的话,整段 CSS 会被忽略,弹层就回到视口中间。可以说是一个可以接受的降级——功能还在,只是位置不完美。
六、兼容性和降级怎么写
dialog 的底子比较厚,主流浏览器早些年就都支持了,基本可以直接上。popover 和 command / commandfor 要新一些,得看具体要兼容到什么版本。
降级思路不用太复杂,因为 popover 元素在不支持它的浏览器里就是一个普通 div——如果不做任何处理,它会一直显示在页面上,很碍眼。所以关键动作是把“默认隐藏”这件事补回来:
if (!('popover' in HTMLElement.prototype)) {
document.querySelectorAll('[popover]').forEach((el) => {
el.hidden = true;
});
document.addEventListener('click', (event) => {
const trigger = event.target.closest('[commandfor]');
if (!trigger) return;
const target = document.getElementById(trigger.getAttribute('commandfor'));
if (target) target.hidden = !target.hidden;
});
}
这段只是把显示切换补上,点击外部关闭、焦点管理这些都得自己来,但至少页面不会破。真要做完整降级,建议还是把 popover 当成增强项,核心内容始终有一条不依赖弹层的路径。
七、可访问性上几个具体的坑
- 触发按钮上的 aria-expanded、aria-haspopup 不需要手写,浏览器会跟着弹层的开合自动同步。但弹层内部的内容不会被自动朗读,如果它是承载表单的面板,记得加上
role="dialog"和一个可访问的名字。 - dialog 一定要有名字。
aria-labelledby指向标题是最稳的做法,实在没有标题就用aria-label。没有名字的模态框在屏幕阅读器里就是一句“对话框”,用户完全不知道这是干什么的。 - 关闭时焦点会自动还给打开它的那个元素。但如果那个元素在关闭过程中被重新渲染掉了(React 里很常见),焦点会掉到 body 上,键盘用户就找不到自己刚才在哪了。这种情况需要自己在 close 事件里手动 focus 到一个合理的落点。
- 不要把 popover 和
role="tooltip"混着用。tooltip 只需要 aria-describedby 就够了,不需要一个有身份的弹层。 - Esc 关闭对 auto popover 是默认行为,但 manual popover 不会响应 Esc。如果是长驻面板,得自己挂一个 keydown 处理,否则键盘用户就被困在里面了。
八、和框架配合时要注意什么
这些属性都是普通 HTML 属性,写在 JSX、Vue 模板或者 Svelte 里都行,不需要 ref,也不需要 useEffect 去绑事件。这是它比第三方弹层库省事的地方。
唯一要留心的是 commandfor 和 popovertarget 指向的是 id。在列表渲染里,id 必须是稳定且唯一的。如果只是用数组下标拼 id,一旦列表顺序变化,命令就会指到错误的元素上——这个 bug 很隐蔽,表现是“点 A 弹出了 B 的面板”。用业务主键做 id 后缀更稳。
收尾
原生弹层真正省力的地方,不是少写了几行 HTML,而是把一堆容易出错的边缘逻辑交给了浏览器:点击外部关闭、Esc 关闭、焦点陷阱、焦点归还、层级顺序、滚动穿透。这些东西自己实现的时候都是 bug 的高发区,换成原生之后一整个消失。
落地的时候可以先从最不影响主流程的地方下手,比如给一个图标按钮加个工具提示,用 popover="hint" 试一下手感。跑通之后再往模态上换。整个过程的可逆性很好——出问题把属性删掉就回去了,不会留下一堆需要清理的脚手架。

