后台项目里挂着一个挺有名的组件库,但真正在用的只有三个东西:Modal、Tooltip、Dropdown。打包分析跑出来,这三个组件加上它们拖进来的定位引擎,一共占了 38KB(min 之后 gzip)。
更烦的是每次升级这个库都要重新验一遍弹层逻辑。它的 Modal 在弹窗里再开弹窗时会算错 z-index,Tooltip 在表格里滚动时偶尔漂到屏幕外面去。改又不好改,因为都是压缩过的产物。
所以我把它们全部换成了浏览器自带的方案。整个过程踩了不少坑,这篇写下来。
先别急着动手:popover 和 dialog 不是二选一
很多人第一次接触 Popover API,会以为它是用来替代所有弹层的。不是。这两个东西解决的是两类不同的问题,搞混了会写出很难维护的代码。
判断标准只有一条:这个浮层出现的时候,用户还能不能操作页面上的其他东西。
不能操作,就是模态。用 <dialog>,调 showModal() 打开。浏览器会自动把页面其余部分设置为 inert,Tab 键不会跑到背景里去,屏幕阅读器也读不到。
能操作,就是非模态。用 popover 属性。用户点开下拉菜单的同时,理论上还可以点页面上的链接。
我们的项目里,确认删除框属于前者,导航下拉和提示气泡属于后者。分完之后思路就清楚了。
popover 有三种模式,默认那个不一定是你想要的
popover 属性可以写三个值,行为差别不小:
<div popover>...</div> <!-- 等价于 popover="auto" -->
<div popover="manual">...</div>
<div popover="hint">...</div>
auto 是默认值。它的特点:点击弹层外面的任何地方会关闭,按 Esc 会关闭,而且同一时间只会有一个 auto 弹层处于打开状态——打开第二个会自动把第一个关掉。菜单、下拉框基本都是这个。
manual 关掉了上面所有自动行为。不会点外面关闭,不会按 Esc 关闭,也不会互相顶掉。适合那种需要一直挂着的浮层,比如全局的操作引导,或者正在拖拽中的面板。用这个模式意味着关闭逻辑必须自己写。
hint 是专门为 tooltip 这类提示设计的。它的特殊之处在于:它不会关闭已经打开的 auto 弹层,而且如果同一时间打开多个 hint,其他的 hint 会自动关掉。这个语义正好对应「鼠标划过看提示」的场景。
我们有个搜索框,聚焦的时候会弹出一个历史记录面板。一开始我给它写了 hint,结果发现点页面别处它不关。因为 hint 依然有轻触关闭行为,但和 auto 的处理不同,实际的体验差异让人挺意外。后来改回了 auto。
实战一:确认删除框改成 dialog + commandfor
原来的写法是调用组件库的 Modal.confirm(),返回一个 Promise。改完之后是这样的:
<dialog id="confirm-delete" closedby="any" aria-labelledby="cd-title">
<h2 id="cd-title">确认删除该项目?</h2>
<p>项目下的 12 个环境变量和 3 条部署记录会一并清空,且无法恢复。</p>
<form method="dialog">
<button value="cancel">取消</button>
<button value="confirm">删除</button>
</form>
</dialog>
<button commandfor="confirm-delete" command="show-modal">删除项目</button>
注意 commandfor 和 command 这两个属性。commandfor 指向要操作的元素的 id,command="show-modal" 表示以模态方式打开这个 dialog。整段代码没有一行 JavaScript。
配套的 JS 只需要监听关闭事件:
const dlg = document.getElementById('confirm-delete');
dlg.addEventListener('close', () => {
if (dlg.returnValue === 'confirm') {
deleteProject(currentId);
}
});
为什么 returnValue 能拿到 confirm?因为 <form method="dialog"> 里的按钮被点击时,会把按钮的 value 写进 dialog 的 returnValue,然后自动关闭。这是 dialog 元素从设计之初就带着的能力,很多人不知道。
closedby="any" 是后来加上的。它的作用是允许点击 dialog 外部区域关闭。默认值是 closerequest,也就是只能通过 Esc 或者代码关闭。还有 closedby="none",连 Esc 都不管用,用在那种必须做出选择的场景。
顺带一提,command 除了 show-modal,还支持 close、request-close、show-popover、hide-popover、toggle-popover。后面三个是给 popover 用的。
实战二:导航下拉用 popover 配锚点定位
HTML 部分非常短:
<button id="nav-trigger" popovertarget="nav-menu">更多</button>
<div id="nav-menu" popover>
<a href="/settings" rel="external nofollow" >项目设置</a>
<a href="/members" rel="external nofollow" >成员管理</a>
<a href="/audit" rel="external nofollow" >操作日志</a>
</div>
popovertarget 会自动处理点击切换,也会自动维护按钮上的 aria-expanded。
麻烦在定位。popover 默认会被放在视口正中央——因为它在 UA 样式表里带的是 position: fixed 加上自动外边距居中。第一次看到菜单从屏幕中间冒出来,我还以为写错了什么。
解决办法是 CSS 锚点定位:
#nav-trigger {
anchor-name: --nav-trigger;
}
#nav-menu {
position-anchor: --nav-trigger;
position-area: bottom left;
margin-top: 6px;
}
anchor-name 给触发按钮起一个锚点名字,position-anchor 让 popover 认这个锚点,position-area 把它放到锚点的正下方靠左。浏览器会自己处理翻转——如果下方空间不够,自动挪到上方;如果右边超出视口,自动往左收。
这比之前用定位引擎算坐标可靠多了。定位引擎需要监听 resize 和 scroll,还得处理容器裁剪,Popover 的定位是浏览器合成层里做的,滚动时不抖。
实战三:hint 模式做悬浮提示
<button popovertarget="tip-retention" class="tip-trigger">数据留存</button>
<div id="tip-retention" popover="hint">
免费版保留 7 天,付费版 90 天,企业版可配置。
</div>
这里用 hint 而不是 auto,原因是页面上可能存在已打开的下拉菜单。如果用 auto,用户鼠标划过「数据留存」这四个字的时候,下拉菜单会被直接顶掉,体验很割裂。
还有一点值得说:hint 模式下的 popover 会带一个隐式的 role="tooltip"。如果里面只有纯文本,这个角色是对的。但如果提示内容里有可点击的链接,就不该用 hint,得改用 auto 并手动指定 role。
焦点、滚动穿透和 inert
这是替换过程中花时间最多的部分。
<dialog> 用 showModal() 打开时,浏览器会自动做三件事:页面其余内容 inert、焦点移入 dialog、Tab 循环限制在 dialog 内部。这三条都是免费的,不用写一行代码。
但 popover 不做这些。popover 打开后,背景内容依然可以点击。如果下拉菜单里有一个输入框,用户 Tab 出去之后焦点会跑到背景页面的链接上,看起来像 bug。
我们的处理方式是:下拉菜单本身不需要模态行为,保持现状。但那个搜索历史面板需要,所以改成用 <dialog> 加 show()(非模态),然后手动在面板外面加一个遮罩。看起来绕,但这是当下的正确做法。
另外提一下滚动条。<dialog> 模态打开时,背景页面的滚动会被浏览器自动锁住,不需要再手动往 body 上打 overflow: hidden。这一条在移动端上尤其省事,之前用组件库的时候,iOS 上总会出现背景跟着弹窗一起滚动的问题。
动画里的那个坑
给 popover 加淡入淡出动画,第一版写完发现只有淡入有,淡出是瞬间消失。原因是 popover 关闭时会被设成 display: none,而 display 是不可动画的属性,过渡会直接被跳过。
需要显式允许离散属性过渡,并配合 @starting-style 处理入场:
#nav-menu {
opacity: 0;
transform: translateY(-4px);
transition: opacity 160ms ease, transform 160ms ease,
display 160ms allow-discrete, overlay 160ms allow-discrete;
}
#nav-menu:popover-open {
opacity: 1;
transform: translateY(0);
}
@starting-style {
#nav-menu:popover-open {
opacity: 0;
transform: translateY(-4px);
}
}
:popover-open 是 popover 打开时的伪类。加上 allow-discrete 之后,display 的切换才会被纳入过渡流程,退场动画才能跑完再隐藏。
兼容性与降级
截止目前,popover 和 <dialog> 在主流浏览器上都没问题,包括移动端 Safari。真正需要留意的只有两处:
commandfor 和 command 这对属性目前只在 Chromium 内核上有,Firefox 和 Safari 还认不出来。所以不能只靠它,得加一段兜底:
if (!('commandForElement' in HTMLButtonElement.prototype)) {
document.querySelectorAll('[commandfor]').forEach(btn => {
const target = document.getElementById(btn.getAttribute('commandfor'));
const cmd = btn.getAttribute('command');
btn.addEventListener('click', () => {
if (cmd === 'show-modal') target.showModal();
else if (cmd === 'close') target.close();
else if (cmd === 'show-popover') target.showPopover();
else if (cmd === 'hide-popover') target.hidePopover();
else if (cmd === 'toggle-popover') target.togglePopover();
});
});
}
这段完全不影响支持的浏览器,因为在支持的浏览器里它压根不执行,属性会自己处理。
另一处是 CSS 锚点定位。Safari 已经支持了基础部分,但 position-area 的某些取值组合还没跟上。我们的做法是给 popover 加一个基础定位:
#nav-menu {
inset: auto;
top: calc(anchor(bottom) + 6px);
left: anchor(left);
}
如果锚点定位整体不可用,popover 会退回默认的居中显示——不好看,但功能完整,不会出现「菜单跑到屏幕外面」这种坏掉的情况。
那些真正花时间的坑
顶层渲染不受父元素裁剪。之前有人用 overflow: hidden 的容器包住表格,想让弹层被裁掉作为「意外保护」。换成 popover 之后,它被提升到 top layer,完全不受影响。这是好事——说明以前组件库那些裁剪问题不会再出现了。
dialog 里的表单提交。如果 dialog 内部有一个 method="post" 的表单,按回车会正常提交并跳转页面,dialog 不会自动关闭。要么用 method="dialog",要么在 submit 事件里手动 preventDefault。
嵌套 popover。在 auto popover 里再开一个 auto popover,外层会被自动关掉。如果确实需要嵌套(比如下拉菜单里再展开二级),内层要用 manual。
回车键的默认行为。页面上如果只有一个按钮,在表单里按回车会触发它。我们那个「删除项目」按钮一度变成了默认提交按钮,用户填完搜索框按回车就弹出了删除确认。后来把按钮挪出了表单才解决。
::backdrop 的样式。dialog 的背景遮罩是 ::backdrop,但给 popover 加是没用的,因为 popover 不在顶层模态栈里。
最后的结果
38KB 的依赖被彻底移掉了。取而代之的是几十行 HTML、大约 60 行 CSS,加上一小段只在旧浏览器上跑的降级代码。
更重要的是那些以前需要跟组件库较劲的地方——z-index 层级、定位漂移、滚动锁定——现在都由浏览器负责了。这些逻辑写在渲染引擎里,比写在业务仓库里靠谱得多。
如果你手上也有类似的项目,我的建议是先数一下到底用了那个库的多少个组件。如果不超过五个,值得花两天试试这条路。超过二十个,还是留着吧,改造的收益跟成本不成正比。

