前阵子帮一个团队看他们的组件库,光一个下拉菜单组件就写了三百多行。里面包括点击外部关闭、ESC 关闭、多实例互斥、焦点管理、滚动时重定位、iOS 上点击穿透的补丁,还有一堆 setTimeout 和 requestAnimationFrame。
问他们为什么不换,回答是「一直这么写的,能跑就行」。
问题是这些逻辑浏览器早就自己做了。Popover API 把这套东西从 JS 搬到了 HTML 属性上,一行 popover 就能拿到 light dismiss、ESC 关闭、顶层渲染、焦点管理。这篇文章就拿那个三百行的组件开刀,看看重写之后还剩多少。
先看现在的基础写法
Popover 的核心只有两个属性。给一个元素加上 popover,它就默认隐藏了;再用 popovertarget 指向它的 id,按钮就能把它打开。
<button popovertarget="user-menu">账号</button>
<div id="user-menu" popover>
<a href="/profile" rel="external nofollow" >个人资料</a>
<a href="/settings" rel="external nofollow" >设置</a>
<button popovertarget="user-menu" popovertargetaction="hide">关闭</button>
</div>
就这样。没有 addEventListener,没有 document.addEventListener('click', ...),没有 aria-expanded 的手动切换,也没有 role="menu" 需要自己维护。点按钮打开、点外面关闭、按 ESC 关闭,浏览器全包了。
那个团队三百行的组件,功能部分基本就是上面这九行。
popover 的三个取值,别搞混
直接写 popover 等价于 popover="auto"。auto 的含义有两层:
一层是轻关闭(light dismiss)。用户点到弹层外面任何一个地方,弹层就关了。这个行为以前得靠监听 document 的 click 事件,然后判断点击目标是否在弹层内部——就是那些写了两百多行还没写对的部分。
另一层是自动互斥。auto 类型的弹层,打开新的会自动关掉旧的。这个特性配合它就非常舒服:页面右上角有通知和头像两个面板,用户点开通知再点头像,通知自己就关了,你一行代码都不用写。
然后是 manual。这个值把上面两个行为都关掉——不响应 ESC,不响应外部点击,也不会自动关别人。什么时候用?自定义的确认对话框、拖拽面板、需要用户明确操作的浮层。反正只要你想完全自己控制什么时候关,就用 manual。
最后一个 hint。它是最近才被浏览器实现的,行为介于两者之间:响应 ESC 和外部点击(会关),但不会自动关掉别的弹层。设计意图是给那种「用来辅助提示的浮层」用的,比如工具提示(tooltip)、悬停预览。因为它关掉别人的话会很烦——用户只是想把鼠标移到提示上看一眼,结果主弹层没了。
三个值用一句话记住:auto 关自己也关别人,manual 谁都不关,hint 只关自己。
dialog 还是 popover,选哪个
两个都能做弹层,容易混。给一个简单的判断方法:
用 <dialog>,当这个弹层需要用户做决定才能继续。比如「确定要删除这条数据吗」,用户必须回一个「是」或者「否」。dialog 通过 showModal() 打开时是模态的,页面其他部分会被屏蔽,焦点会被困在里面,tab 键不会跑到背景去。这是给「必须处理」的场景准备的。
用 popover,当这个弹层是辅助性的。用户想看一眼就看,不想看直接点外面关掉,不影响他继续做别的事。下拉菜单、通知面板、筛选器、日期选择器,都属于这一类。
实际用的时候两者会混搭。比如一个筛选面板本身是 popover,点里面的「重置」时弹出一个确认 dialog。这是合理的层级关系。
还有一点要注意,<dialog> 也能加 popover 属性,但那时候它就不再是模态的了,行为和普通 popover 一样。这种写法一般出现在「先用对话框样式,后来想降级为非模态」的场景,不算常见,知道有这回事就行。
把它接到真实场景上
来拼一个完整的通知面板。需求是这样:右上角一个铃铛,有未读时显示小红点。点开显示通知列表,每条可以标记已读。面板打开时焦点应该落在里面比较合理的位置。
<div class="bell-wrap">
<button
type="button"
popovertarget="notify-panel"
aria-label="通知"
>
<svg aria-hidden="true"><!-- 铃铛图标 --></svg>
<span class="dot"></span>
</button>
<div id="notify-panel" popover>
<header>
<h2>通知</h2>
<button type="button" popovertarget="notify-panel" popovertargetaction="hide">
关闭
</button>
</header>
<ul>
<li>
<button type="button" data-id="1001">
你有一条新的审批请求
</button>
</li>
<li>
<button type="button" data-id="1002">
昨天的报表已经生成
</button>
</li>
</ul>
</div>
</div>
HTML 部分到这里就结束了。需要补的 JS 只有「标记已读要发请求」这一段业务逻辑,跟弹层的开关完全无关。
不过这里有个细节要处理一下。面板打开时,浏览器的默认行为是把焦点放到第一个可聚焦的子元素上,也就是那个「关闭」按钮。这不太合理,用户刚打开面板就把焦点放在关闭上,等下想往回 tab 还得绕一圈。可以用 autofocus 属性指定焦点位置:
<ul autofocus tabindex="-1">
...
</ul>
把 tabindex="-1" 加在列表上,它就变成了「可编程聚焦」的目标,但不会进入 tab 序列。打开面板时焦点落在列表容器上,接着按 tab 就自然走到第一条通知。这个体验比默认行为好得多。
关于样式的一点说明
popover 元素的默认样式是 display: none,浏览器会在打开时把它切成 block 并且提升到顶层(top layer)。顶层意味着它会渲染在页面所有 z-index 之上,包括那些写了 z-index: 99999 的元素。
想调整位置的话,用 ::backdrop 控制遮罩,用普通的 CSS 选择器给弹层本身定位。有一个选择器值得单独提一下:
[popover]:popover-open {
/* 只在打开时生效 */
}
以前要判断打开状态,得在 JS 里切换 class。现在这个伪类直接暴露了状态,做打开动画就顺手了。比如从上方滑入:
@starting-style {
[popover]:popover-open {
translate: 0 -8px;
opacity: 0;
}
}
@starting-style 是配合用的,它定义了「元素第一次进入渲染时的初始状态」,浏览器据此计算过渡的起点。这个组合是最近两年才稳下来的,之前做 popover 动画得靠 requestAnimationFrame 手动加 class 再触发 transition,麻烦得多。
定位:CSS 锚点定位能救一半问题
弹层最烦的事是「跟着触发按钮走」。以前这个活要么靠 JS 算 getBoundingClientRect,要么靠 Floating UI 这种库。
CSS 锚点定位(CSS Anchor Positioning)出来之后,很多场景可以纯 CSS 解决了。思路是先声明谁是谁的锚点,然后让弹层按锚点的位置摆放:
.bell-wrap button {
anchor-name: --bell;
}
#notify-panel {
position-anchor: --bell;
position: absolute;
top: anchor(bottom);
right: anchor(right);
margin-block-start: 8px;
}
anchor-name 给按钮起个名字,position-anchor 声明弹层以它为准。anchor(bottom) 拿到锚点元素底边的位置,弹层就从那里往下开始排。margin-block-start: 8px 留点间距,这样不会跟按钮贴脸。
更省心的是 position-try。当弹层靠近视口边缘、可能会溢出的时候,浏览器会自动换一个方向摆。比如上方放不下就放下方,右边放不下就放左边。这个逻辑以前在 JS 里写起来非常啰嗦,各种边界情况都要考虑,现在一句就行:
#notify-panel {
position-try-fallbacks: flip-block, flip-inline;
}
不过得说实话,锚点定位的浏览器支持还不如 popover 本身。写的时候得留一条降级路径,后面会讲。
嵌套 popover:这是个好用的能力
下拉菜单里再开子菜单,是过去写起来最头疼的场景之一。要处理 hover 延时、子菜单定位、鼠标从父移向子的间隙不能关、子菜单打开时父菜单不能消失……
Popover 支持嵌套,而且行为是设计好的:外壳 popover 打开时会「记住」里面还开着谁,子 popover 关掉不会连带关掉父的。这和前面的互斥规则正好配合。
<div id="main-menu" popover>
<button popovertarget="sub-menu">更多操作</button>
<div id="sub-menu" popover>
<button>导出</button>
<button>删除</button>
</div>
</div>
点「更多操作」会打开子菜单。这时点外面,两个一起关。如果只点子菜单里的按钮,子菜单关掉但主菜单还在。这套逻辑完全由浏览器处理,没有一行 JS。
唯一要注意的是层级。子 popover 在 DOM 顺序上如果写在父 popover 内部,它会跟随父元素的生命周期。如果写在外面,就要用 position-anchor 明确指定定位关系,否则它不知道该往哪放。
无障碍:原生带来的东西比想象的多
自己写下拉菜单最容易被漏掉的就是无障碍。几个常见错误:
没标 aria-expanded,屏幕阅读器不知道按钮是展开还是收起;没处理 aria-controls,用户不知道该操作影响哪个区域;焦点管理混乱,打开弹层后按 tab 焦点跑到背景去了。
用 popover 之后,展开状态由浏览器维护,按钮的语义会自动带上这一层。焦点在打开时会进入弹层,关闭时会回到触发按钮——这个「归还焦点」的行为是自动的,很多手写实现都做不到。
不过还有两件事得自己做。第一是给弹层加合适的语义,比如通知列表用 <ul> 而不是一堆 <div>,让屏幕阅读器能播报「列表,两项」。第二是给纯图标按钮加 aria-label,上面那个铃铛按钮就加了。
有一个坑要提醒:不要给 popover 元素加 role="dialog"。它不是对话框,加上去反而会误导屏幕阅读器。想要对话框语义,就用 <dialog>。原生标签的语义一个都别乱加。
踩过的坑
表单里的按钮要写 type。 <button> 默认是 type="submit"。如果这个按钮在 <form> 里面,点它会直接提交表单,然后页面刷新,你会看到弹层「闪一下」就没了。这个坑我踩过不止一次。
popover 在 dialog 内部关不掉 dialog。 如果 popover 写在 <dialog> 元素里面,点它外面的区域,关闭的是 dialog 还是 popover?答案是先关 popover,再点一次才关 dialog。这个层级是浏览器设计好的。如果不符合预期,要把 popover 移出 dialog 的 DOM,用锚点定位去对齐。
手动调 API 时要区分 showPopover 和 showModal。 有些场景需要 JS 控制,这时候别用错。popover.showPopover() 打开的是 popover,dialog.showModal() 打开的是模态对话框。名字很像,行为完全不同。想判断当前状态,用 el.matches(':popover-open')。
CSS 动画在早期实现里有闪烁。 如果发现弹层打开前有一瞬间会显示在页面左下角,通常是因为元素还没有被提升到顶层,但样式已经开始应用了。检查一下有没有给 popover 设置 position: absolute 之类会导致提前渲染的规则,把它放回 [popover]:popover-open 里面。
Safari 上 hover 触发的方式不一样。 如果做的是悬停预览类的 popover,不要太依赖 :hover 直接调用 API。不同浏览器对「鼠标从一个元素移到它的 popover 子元素」这个过程的判断有差异,容易在中间那一瞬间就把弹层关掉。用 hint 类型或者手动加一点延时更稳。
SSR 环境下的 hydration 会造成短暂的状态不同步。 服务端渲染出来的 HTML 里,popover 元素是 display: none,客户端接管之后如果 JS 立刻调 showPopover(),可能会和 hydration 抢时序,出现打开又立刻关掉的情况。稳妥做法是在 hydration 完成后(比如 useEffect 里)再去操作,或者干脆用属性驱动,让浏览器自己处理。
降级方案怎么写
Popover API 的浏览器覆盖已经相当好了,但如果你需要支持的老设备比较多,还是得留一手。思路是特性检测加最小降级:
if (!HTMLElement.prototype.hasOwnProperty('popover')) {
// 老浏览器:给按钮绑 click,切换 hidden 属性
document.querySelectorAll('[popovertarget]').forEach((btn) => {
const target = document.getElementById(btn.getAttribute('popovertarget'));
if (!target) return;
target.hidden = true;
btn.addEventListener('click', () => {
target.hidden = !target.hidden;
});
});
}
这段降级只覆盖最基本的开关,没有 light dismiss,没有焦点管理。但它是可以用一段小脚本维护的,比在业务代码里到处写 if-else 好得多。
CSS 那边同理,可以用 @supports (position-anchor: --x) 包住锚点定位的规则。不支持的时候,弹层就按默认方式渲染在文档流里(浏览器会自动给它 position: fixed 和一个居中位置),能用,只是不好看。这是可接受的降级,不需要为了它单独写一整套绝对定位逻辑。
回头看看那三百行
把文章里的东西合起来,通知面板这个例子最终大概是:HTML 三十行左右,CSS 几十行,JS 只剩「点击通知项发请求标记已读」这一段。那个三百行的组件,功能部分基本被浏览器接管了。
我不是说所有弹层都应该用 popover。复杂交互比如拖拽排序、多步表单、需要自定义键盘导航的菜单,仍然需要 JS 参与。但那些「只是想让一个东西弹出来」的场景,用原生属性真的更省事,也更容易做对。
如果你手上正好有个写了很久的下拉菜单组件,不妨拿一个页面先试点。改起来不难,删掉那些 setTimeout 和 requestAnimationFrame 的时候,会有点爽。

