上个月重构一个后台工具条,需求说起来很朴素:一个「更多操作」按钮,点开是一列菜单项,点外面自动收起,按 ESC 也能收,菜单里再点东西不关。
按老习惯,这种活要么上某个 UI 库的 Dropdown 组件,要么自己写一套 —— 监听 document 的 click、判断 e.target 是不是在容器里、维护一个 isOpen 状态、处理 z-index、处理焦点、处理滚动穿透。代码不多,但脏,而且每个项目都要重写一遍,细节还总漏。
这次我直接用了 HTML 原生的 Popover。写完之后,整个下拉菜单的 JS 是零行。不是「用了几行」,是真的零行。这篇文章就把这个重构过程完整写一遍,包括过程中撞到的那些坑。
先说清楚 popover 是什么
Popover API 的核心就两件事:一个全局属性 popover,一个全局方法 showPopover() / hidePopover(),再加一个 togglePopover()。
一个带有 popover 属性的元素,默认是不渲染的。它不是 display: none 意义上的不渲染 —— 它压根就不在顶层渲染树里,直到你显式把它「弹出来」。
弹出来之后,它会自动进入浏览器的顶层(top layer)。这个「顶层」跟 z-index: 9999 完全是两回事:它是独立于文档流之外的渲染层,任何元素的 z-index 都盖不住它,也不受父元素 overflow: hidden 影响。长年被定位上下文坑过的人看到这里应该会笑出来。
最妙的一点是,它自带三大行为:点击外部关闭、按 ESC 关闭、打开时自动管理和焦点相关的交互。这些行为过去都要自己写,现在浏览器给了。
最小的能跑的例子
先看一眼最朴素的形态,不用任何 JS:
<button popovertarget="demo-tip">看提示</button>
<div id="demo-tip" popover>
这是一段提示信息。
</div>
就这么点东西。popovertarget 的值指向目标元素的 id,按钮就变成了开关;再点一次,关闭。点页面其他任何地方,也关闭。ESC,还是关闭。
浏览器原生支持从 2024 年开始就比较完整了 —— Chrome 114+、Safari 17+、Firefox 125+ 都已经覆盖。写这篇文章的时候,2025 年的移动端基本不用考虑兼容问题了。后面我会讲低版本浏览器的处理方式。
popover 属性的三种取值
很多人第一次用会以为 popover 是个布尔属性,写了就行。其实它是有取值的,取了空值就等价于 popover="auto"。
| 取值 | 含义 | 典型场景 |
|---|---|---|
auto(默认) |
打开时会自动关闭其他同类型的 popover;点击外部、按 ESC 都会关闭 | 下拉菜单、气泡提示、工具栏弹出面板 |
manual |
不自动关闭任何 popover;点击外部和 ESC 也不关闭,只能通过调用方法或 popovertarget 关闭 |
需要保持打开的侧边浮层、Toast、引导步骤 |
hint |
比较新的值,和其他 auto 不互斥,用作辅助提示 | 悬停提示、短时提示气泡 |
我的实践经验:95% 的场景用 auto。唯一需要 manual 的地方,是你要在弹层里再叠一层弹层,而内层关闭时不想把外层也带走 —— 但即便这个场景,通常也有别的方式解决,下面会讲。
hint 这个值比较新,支持面窄一些,做工具提示可以考虑,但别当主力。
真实案例:工具栏的下拉菜单
回到我那个重构。目标是一个类似富文本编辑器工具栏的结构,点击「更多」按钮弹出菜单,菜单项点了就执行动作并关闭。
结构大致长这样:
<div class="toolbar">
<button data-action="bold">加粗</button>
<button data-action="italic">斜体</button>
<button popovertarget="more-menu">更多</button>
<div id="more-menu" popover>
<button data-action="underline">下划线</button>
<button data-action="strike">删除线</button>
<button data-action="code">行内代码</button>
</div>
</div>
菜单能弹了,能关了。但有个问题:点击菜单项之后,菜单不会自动关。因为 popover 的「自动关闭」只认点击外部和 ESC,点它自己内部的东西不会触发关闭。
所以这里需要一丁点 JS:
const menu = document.getElementById('more-menu')
menu.addEventListener('click', (event) => {
const target = event.target.closest('button[data-action]')
if (!target) return
executeAction(target.dataset.action)
menu.hidePopover()
})
function executeAction(action) {
// 具体的业务动作
console.log('执行:', action)
}
六行。这已经是整个下拉菜单的全部 JS 了。对比一下过去要写的那些 document 级监听、e.target 判断、定时器防风、焦点恢复 —— 是数量级的差别。
退场动画怎么做
这是几乎所有第一次用 popover 的人都会卡住的地方。你想给弹层加个淡出动画,写好了 keyframes,发现出场有效,退场一点动画都看不到,元素「啪」地就没了。
原因在于,popover 关闭时,元素会立刻从顶层移除,display: none 生效,动画没有机会播放。
解决方法有三种,从简单到复杂排列。
方法一:用 @starting-style + transition。这是目前最干净的方案,专门为这种「元素从无到有」的场景设计的。
#more-menu {
opacity: 0;
transform: translateY(-6px);
transition: opacity 200ms ease, transform 200ms ease,
display 200ms allow-discrete,
overlay 200ms allow-discrete;
}
#more-menu:popover-open {
opacity: 1;
transform: translateY(0);
}
@starting-style {
#more-menu:popover-open {
opacity: 0;
transform: translateY(-6px);
}
}
关键在 display 200ms allow-discrete 和 overlay 200ms allow-discrete。allow-discrete 这个关键字的作用是,让 display 和 overlay 这两个本身离散的属性也能参与过渡。没有它,动画出不来。
方法二:手动延迟隐藏。如果你不想用新特性,可以在 JS 里先加一个 class,等过渡结束再调 hidePopover()。缺点是要监听 transitionend,而且和原生关闭路径(比如点击外部)不好统一。
方法三:用 CSS 的 ::backdrop。这个仅对 dialog 有效,popover 的 ::backdrop 行为不太一致,不建议走这条路。
我最终选了方法一。@starting-style 目前在 Chrome 117+、Safari 17.5+、Firefox 129+ 都已经支持,2025 年写这个东西不算激进。
popover 和 dialog 到底怎么选
这两个东西经常被放在一起讲,但它们在语义和用途上差别很大,用错了会很难受。
popover 是无模态的。它没有焦点陷阱,不锁背景滚动,用户能一边看着弹层一边操作后面的内容。适合菜单、提示、快捷面板。
dialog 有两种形态。调 show() 时是无模态的,调 showModal() 时是模态的 —— 焦点被锁在对话框里,背景变暗,ESC 也会关闭。适合表单、确认框、需要用户专注处理的场景。
还有一个很实用的区别:dialog 支持 form method="dialog",表单提交时对话框自动关闭,并且可以把提交按钮的 value 作为返回值。这在做「确认 / 取消」这种两选一的对话框时非常顺手。
<dialog id="confirm-box">
<form method="dialog">
<p>确定要删除这条记录吗?</p>
<button value="cancel">取消</button>
<button value="ok">确定</button>
</form>
</dialog>
JS 里监听 close 事件,读一下 dialog.returnValue 就能知道用户点了哪个按钮。
const box = document.getElementById('confirm-box')
box.addEventListener('close', () => {
if (box.returnValue === 'ok') {
deleteRecord()
}
})
// 打开
box.showModal()
这套组合拳,比我用过的任何一个 UI 库的确认框都干净。而且没有任何 DOM 拼装,全在 HTML 里。
嵌套弹层怎么办
我遇到过一个场景:下拉菜单里有一项叫「设置样式」,点了之后要再弹出一个面板选颜色。菜单本身是个 popover,颜色面板也是。默认情况下,内层弹出时,外层的 auto popover 会被自动关掉,这是浏览器的设计 —— 同一时刻只允许一个 auto popover 打开。
有几种处理方式。
方案一:让内层用 popover="manual"。这样它不会触发外层关闭。代价是内层也不再自动关闭,点击外部和 ESC 都不会收起,需要自己监听。
方案二:把颜色选择直接嵌在外层菜单里。这种最常见。菜单项展开成子列表,不用另开一个 popover,问题自然消失。
方案三:外层也用 manual,全局用 JS 统一管理。当你有三级以上弹层需求时,基本就得走这条路了。但说实话,三级以上的弹层交互本身就不该做,用户体验一团糟。
我用的是方案二。菜单项点开就地在下方展开,一个 popover 搞定。
造型和定位
popover 弹出时,浏览器默认把它放在触发元素的中间位置,并且水平垂直居中。这显然不是我们想要的,需要自己调。
传统做法是用 CSS Anchor Positioning,但这个特性目前跨浏览器支持还不太行。我用的是另一个更稳的做法 —— 用 position: fixed 加定位属性,配合 popovertarget 触发源的相对位置在 JS 里算一下。或者干脆手工写一个绝对定位。
#more-menu {
position: fixed;
inset: auto;
margin: 0;
/* 由 JS 设置 top / left 或者用 anchor 定位 */
}
#more-menu:popover-open {
display: block;
}
更省事的方式是利用 popover 默认就有的样式重置。现代浏览器给 popover 的默认样式里有 position: fixed、inset: 0、margin: auto、width: fit-content、height: fit-content,外加一个白底加黑边的框。你不喜欢默认的样子就用 CSS 覆盖,但至少位置是可控的。
如果实在不想手算位置,还有个土办法:用 popovertarget 的按钮作为参照,在 toggle 事件里用 getBoundingClientRect() 算一下位置,然后给弹层设置 top 和 left。
const menu = document.getElementById('more-menu')
const trigger = document.querySelector('[popovertarget="more-menu"]')
menu.addEventListener('toggle', (event) => {
if (event.newState !== 'open') return
const rect = trigger.getBoundingClientRect()
menu.style.top = (rect.bottom + 6) + 'px'
menu.style.left = rect.left + 'px'
})
这个方案在滚动页面时可能有偏移,配合 position: fixed 使用的话问题不大。真要精细,还是等 Anchor Positioning 全面铺开吧。
低版本浏览器的降级方案
如果项目要兼容 Chrome 100 以下那种环境,需要做检测和降级。我的做法是:
const supportsPopover = typeof HTMLElement !== 'undefined'
&& 'popover' in HTMLElement.prototype
if (!supportsPopover) {
// 加载一个极简的 polyfill
// 或者给 [popover] 元素加一个降级样式类,手动处理开关
}
说实话,如果只是偶尔需要几处弹层,我倾向于不 polyfill,而是直接写一个几十行的迷你实现兜底。真正需要 polyfill 的场景是:项目的设计系统严重依赖 popover 语义,多个组件共用一个机制。
社区里有现成的 @oddbird/popover-polyfill,配合 babel 或者其他构建工具能做到按需加载,体积不到 3KB。但如果可用浏览器份额到了 99% 以上,加这个依赖就有点没必要了。
踩坑清单
把这次重构中撞到的问题列一下,都是实打实踩出来的。
坑一:popovertarget 按钮的默认行为是 toggle,不是 open。很多人以为按钮只会打开弹层,实际上再点一次是关闭。如果你希望按钮只负责打开,关闭交给别处,需要自己在 toggle 事件里判断并拦截。
坑二::popover-open 伪类只在打开时匹配。你不能用它来判断「这个元素是不是 popover」。判断类型要用 element.popover 属性或者 matches(':popover-open')。
坑三:toggle 事件里的 newState 值。它是字符串 'open' 或 'closed',不是布尔值。我第一次写 if (event.newState) 结果两个分支都进了,因为 'closed' 也是 truthy。
坑四:popover 里的表单默认不会提交。这个坑特别深。如果你在 popover 里放一个 <form>,即便里面有 submit 按钮,浏览器也不会提交它 —— 因为 popover 的定义里没有隐含的表单关联。想让表单工作,需要显式设置 form 属性指向一个外层表单,或者用 JS 手动处理。
坑五:CSS 的层叠顺序。popover 在顶层,意味着任何伪元素、任何 ::before、任何装饰层如果不在同一个 popover 里,都会被它盖住。你在弹层里加一个小三角,用 ::after 来做的话没问题;但如果你想在弹层外面盖一层遮罩,就得换个思路 —— 遮罩得是独立元素,而且不能用普通 z-index 去压 popover。
坑六:iOS Safari 上的滚动穿透。这个在 17.4 之后修了,但 17.0 附近有几个版本还是会出现背景跟着弹层滚动的情况。如果目标用户里有停留在旧版本的人,需要在 toggle 时给 body 加 overflow: hidden。
坑七:键盘导航。popover 打开后,Tab 键会按正常文档顺序走,不会把焦点限制在弹层内。想要箭头键上下切换菜单项,需要自己写键盘处理。这不是 popover 的缺陷 —— 它本来就是无模态的,但写菜单的时候经常会忘记这一点。
最后
重构完成后,我统计了一下代码:下拉菜单从原来的 180 行 JS + CSS 变成了 12 行结构 + 40 行样式 + 6 行 JS。更重要的是,行为不再需要自己维护了 —— 点击外部关闭、ESC 关闭、焦点管理、顶层渲染,这些过去要靠库或者手写逻辑保底的事情,现在浏览器兜底了。
当然,原生方案还没到完美的程度。定位和嵌套依然是需要权衡的地方,@starting-style 这类新特性也在逐步铺开。但对于绝大多数日常弹层需求,Popover API 已经足够好用,值得从今天开始,在新项目里先试试它,再决定要不要请 UI 库进场。
工具本来就是这样的:能用原生的地方,就别再叠一层。

