做后台页面的人大概都经历过这个循环:写一个下拉菜单,先加一个 div,再监听 document 的点击来关闭,再处理 z-index 被父级 overflow 裁掉的问题,最后发现键盘 Tab 能跑到菜单外面去。下一期做一个确认弹窗,上面这一套再重来一遍。
用组件库当然能省事,但一个按钮的浮层要拖进来几十 KB 的运行时,总觉得不太划算。好在浏览器这几年把这块的短板补得差不多了:popover、dialog、inert 加上新的 command / commandfor 属性,基本能覆盖大部分浮层场景,代码量小得惊人。下面按「能直接抄去用」的标准,把这套东西过一遍。
先把这块拼图看清楚
这几个特性不是互相替代的关系,各自的分工很明确:
- popover:轻量浮层,比如下拉菜单、气泡提示、筛选面板。它不阻塞页面其余部分的交互,但支持点击外部关闭。
- dialog:真正的模态或非模态对话框,模态打开时会把页面其余部分锁住。
- inert:手动让某块区域「不可交互」,包括鼠标、键盘焦点和辅助技术。
- command / commandfor:把「点击按钮触发某个动作」这件事从 JS 里搬到 HTML 属性上。
弄清楚这四样各自的边界,后面写代码就很少纠结了。
popover:一个属性换来的浮层
最小可用版本
只要给元素加上 popover 属性,再用 popovertarget 把按钮和它关联起来,一个可以开关、可以按 Esc 关闭、可以点击外部关闭的浮层就成型了:
<button popovertarget="tip">查看说明</button>
<div id="tip" popover>
<p>这段内容会被渲染到浏览器的顶级层(top layer)上。</p>
</div>
这里一行 JS 都没写。按钮的 popovertarget 默认行为是「切换」,也就是打开状态再点一次会关闭。
三个取值,先记住 auto 就够了
popover 的值有三种:
<!-- 等价于 popover="",带点击外部关闭、Esc 关闭 -->
<div id="a" popover="auto">…</div>
<!-- 只能通过代码或按钮关闭,点外面不管用 -->
<div id="b" popover="manual">…</div>
<!-- 面向 tooltip 一类辅助提示的轻量模式 -->
<div id="c" popover="hint">…</div>
不确定选哪个的时候,先写 auto。真正需要「点别处不能关」的场景,比如一个正在拖拽中的面板,再换成 manual。至于 hint,它主要是给 tooltip 这种一次性提示准备的,自动关闭的规则比 auto 更宽松,普通菜单用不上。
顶级层解决了 z-index 的老问题
这是我个人最喜欢的一点。popover 打开时会被放进浏览器的顶级层,也就是和 document.body 平级的一个独立渲染层。这意味着两件事:
- 父元素的
overflow: hidden裁不到它; - 你不用再和
z-index: 9999较劲了。
顺带说一句,浏览器默认样式表给 popover 准备了基础的定位和外观:position: fixed、inset: 0、margin: auto。所以不写任何 CSS 它也能正常显示——只不过位置是屏幕正中央,而不是贴着触发按钮。
想让它贴着按钮,用锚点定位
要在外部样式表里把浮层对齐到按钮上,可以借助 CSS 锚点定位(以下 CSS 请放在外部样式表或你自己的样式块里):
#menu-btn {
anchor-name: --menu-btn;
}
#menu {
position-anchor: --menu-btn;
top: anchor(bottom);
left: anchor(left);
margin: 6px 0 0;
}
需要 JS 介入时
有些场景确实得用代码控制,比如根据接口结果决定是否弹出:
<button id="open-btn">打开面板</button>
<div id="panel" popover="manual">……</div>
const panel = document.getElementById('panel');
document.getElementById('open-btn').addEventListener('click', () => {
panel.togglePopover();
});
panel.addEventListener('toggle', (event) => {
console.log(event.oldState, '→', event.newState); // closed → open
});
可选方法有三个:showPopover()、hidePopover()、togglePopover()。事件则是 beforetoggle(可取消)和 toggle(不可取消)。如果你要在浮层展开前阻止它,用 beforetoggle;只是想知道它开没开,用 toggle 就够了。
dialog:真正的模态,交给浏览器管
popover 不阻塞页面其他区域,而确认框这类场景需要「不处理完就别想干别的」。这就是 <dialog> 的活儿。
showModal 与 show 的区别
<dialog id="confirm">
<form method="dialog">
<p>删除后无法恢复,确认继续吗?</p>
<button value="cancel">取消</button>
<button value="confirm">删除</button>
</form>
</dialog>
const dlg = document.getElementById('confirm');
dlg.showModal(); // 模态:进顶级层、加 ::backdrop、页面其余部分自动 inert
dlg.addEventListener('close', () => {
if (dlg.returnValue === 'confirm') {
// 执行删除
}
});
showModal() 和 show() 差在两点:前者会把元素提升到顶级层,并且让页面其余部分自动进入不可交互状态;后者只是把它当作普通块级元素显示出来,用户依然能点页面别的地方。
form method=”dialog” 是个被低估的写法
注意上面那两行按钮:没有 onclick,没有事件监听,按钮的 value 会被写进 dialog.returnValue,同时对话框自动关闭。判断用户点了哪个按钮,只需要读一次 returnValue。
更妙的是,这种表单依然会走原生的表单校验。如果对话框里有个必填输入框:
<dialog id="rename">
<form method="dialog">
<label>新名称
<input name="title" required>
</label>
<button value="cancel">取消</button>
<button value="save">保存</button>
</form>
</dialog>
输入为空时点「保存」,浏览器会弹出校验提示,对话框保持打开,完全不用自己写逻辑。
dialog 还是 popover?
判断标准其实很朴素:用户能不能在浮层打开的时候继续操作页面?能,就是 popover;不能,就是 dialog。下拉菜单、筛选器是前者,删除确认、表单填写是后者。
commandfor:把事件绑定从 JS 里搬走
前面的 popovertarget 只解决 popover 的开关。如果按钮要触发的是对话框,就得靠新的 command 和 commandfor 属性组合(早期版本里叫 invoketarget 和 invokeaction,后来改了名)。
<button commandfor="confirm" command="show-modal">删除</button>
<button commandfor="confirm" command="request-close">取消</button>
内置的命令值有这么几个:
show-modal:对 dialog 调用showModal()close:直接关闭对话框request-close:请求关闭,会先触发cancel事件,可以被拦截show-popover/hide-popover/toggle-popover:控制 popover
另外,任何以 -- 开头的自定义值都会被当作自定义命令派发出去,可以用 command 事件接住,适合做「点击按钮触发某个业务动作」的声明式写法。
需要注意的是,command / commandfor 目前主要由 Chromium 系浏览器实现,Safari 和 Firefox 还在跟进。如果项目要兼容这两家,popover 场景继续用 popovertarget(它的支持面已经比较广),dialog 场景则保留一小段 JS 作为兜底。
inert:手动锁住一块区域
模态 dialog 会自动让页面其余部分 inert,你不需要操心。但有些情况浏览器管不到,比如一个用 popover="manual" 实现的侧边抽屉——它不阻塞页面,背后的内容依然可以点。
这时候手动加 inert 就行:
<main id="content">……页面主体……</main>
<div id="drawer" popover="manual">……抽屉……</div>
const drawer = document.getElementById('drawer');
const content = document.getElementById('content');
drawer.addEventListener('toggle', (event) => {
content.inert = event.newState === 'open';
});
inert 和 aria-hidden="true" 的区别值得说一句:aria-hidden 只是把内容从辅助技术里藏起来,键盘还是能 Tab 进去;inert 是真正的一刀切,鼠标、键盘、屏幕阅读器全都够不着。做浮层遮罩的时候,inert 才是对的那个工具。
完整案例:订单卡片的操作菜单
把上面这些串起来,做一个后台里很常见的场景——订单卡片右上角一个「更多操作」,点开后是重命名和删除,两个动作各自弹对话框。
HTML 部分
<article class="order-card">
<h2>订单 #20250417-0031</h2>
<p>状态:待审核 | 金额:¥1,280.00</p>
<button popovertarget="order-actions">更多操作</button>
<div id="order-actions" popover="auto">
<ul>
<li><button commandfor="dlg-rename" command="show-modal">重命名</button></li>
<li><button commandfor="dlg-delete" command="show-modal">删除订单</button></li>
<li><button popovertarget="order-actions" popovertargetaction="hide">关闭</button></li>
</ul>
</div>
</article>
<dialog id="dlg-rename">
<form method="dialog">
<p>重命名订单</p>
<label>新名称
<input name="title" value="订单 #20250417-0031" required>
</label>
<button value="cancel">取消</button>
<button value="save">保存</button>
</form>
</dialog>
<dialog id="dlg-delete">
<form method="dialog">
<p>删除后无法恢复,确认继续吗?</p>
<button value="cancel">取消</button>
<button value="delete">确认删除</button>
</form>
</dialog>
整个交互里,菜单的开关、对话框的弹出、对话框的关闭,全部由属性驱动,一行绑定代码都没有。
JS 部分只管业务
剩下要写的就是「用户到底点了什么」:
const menu = document.getElementById('order-actions');
const renameDialog = document.getElementById('dlg-rename');
const deleteDialog = document.getElementById('dlg-delete');
// 打开对话框前先把菜单收起来,避免浮层叠浮层
document.querySelectorAll('#order-actions button').forEach((btn) => {
btn.addEventListener('click', () => menu.hidePopover());
});
renameDialog.addEventListener('close', () => {
if (renameDialog.returnValue !== 'save') return;
const nextTitle = renameDialog.querySelector('input[name="title"]').value;
document.querySelector('.order-card h2').textContent = nextTitle;
});
deleteDialog.addEventListener('close', () => {
if (deleteDialog.returnValue !== 'delete') return;
document.querySelector('.order-card').remove();
});
读一下 returnValue,该干嘛干嘛,不用再关心焦点管理、Esc 键、背景遮罩这些东西——浏览器已经替你做完了。
几个真会踩到的坑
一、popover 的默认位置在屏幕正中央。很多人第一次写完会发现浮层飞到屏幕中间去了,这不是 bug。浏览器默认样式给了它 inset: 0; margin: auto;。要贴着按钮,老老实实用锚点定位,或者用 JS 读取按钮的 getBoundingClientRect() 手动设位置。
二、两个 auto 类型的 popover 会互相关闭。这是设计如此——打开一个新的,之前那个就自动关掉,正好符合「一次只开一个菜单」的预期。但如果你希望两个浮层能同时存在,得至少把其中一个改成 manual 或者 hint。
三、对话框关闭后焦点会回到触发元素上。浏览器默认会这么做,但有个前提:那个触发元素还在 DOM 里。上面的删除案例中,卡片被移除后触发按钮跟着消失了,焦点就会掉到 body 上,键盘用户会瞬间迷失位置。这种场景下需要在 close 事件里手动 focus() 到列表的下一项或者搜索框。
四、commandfor 的支持面还不宽。如果你的用户里有相当比例的 Safari 或 Firefox,把它当作渐进增强来用,保留一段 JS 兜底:
if (!('commandForElement' in HTMLButtonElement.prototype)) {
document.querySelectorAll('[commandfor][command="show-modal"]').forEach((btn) => {
btn.addEventListener('click', () => {
document.getElementById(btn.getAttribute('commandfor')).showModal();
});
});
}
什么时候还是该上组件库
原生这套东西覆盖不了所有需求。如果你需要的是:浮层自动避让视口边缘、复杂的嵌套子菜单、拖拽排序、虚拟滚动,或者设计稿要求的动画时序非常精细——那还是老老实实引入成熟方案更省心。
但对于后台系统里最常见的那批交互:下拉菜单、确认框、筛选抽屉、气泡提示,原生的 popover 和 dialog 已经完全够了,而且它们天生带键盘支持和无障碍语义,这一点是自己手搓 div 很难补上的。
下次再写这类组件之前,不妨先问一句:这东西浏览器是不是已经会了?

