上周做一个小活动页面,需求很简单:点击一个按钮,出来一个小浮层,里面有几个快捷操作。搁在以前,我第一反应是写个div,绝对定位,加个class控制显示隐藏,再用JavaScript监听点击和点击外部关闭。做了这么多年前端,这活儿闭着眼也能干。
但是,今年在检查同事提交的代码时,发现他写了一个我平时不太常用的属性——popover。浏览器原生支持的弹层,不用自己处理z-index、focus、点击关闭、事件冒泡命中。
当时我第一反应是“这玩意儿靠谱吗?”于是去查了一下支持情况。好家伙,现代浏览器在2024年左右已经基本全绿了。我半信半疑在项目里试了一把,结果整个弹层的代码量减少了大约70%。还真不是夸张。
今天就把我折腾这个原生popover的过程写下来,给还在用老办法做弹层的同学一个参考。
先说popover是什么
HTML里给元素加一个popover属性,它就被浏览器当作一个弹出层元素来看待。默认情况下,这个元素是隐藏的。它可以和其他几个专属API配合使用,比如使用showPopover()方法,或者使用更简单的声明式调用方式——用popovertarget属性挂在按钮上。
最简单的用法是这样:
<button type="button" popovertarget="tip" popovertargetaction="toggle">显示快捷键</button>
<div id="tip" popover>
<p>按 Ctrl+K 打开命令面板</p>
</div>
注意button上的两个属性:popovertarget指向弹层id,popovertargetaction有三个可选值,show、hide、toggle。假如不写popovertargetaction,默认就是toggle。点一次显示,再点一次关闭。
浏览器对弹层的“轻触外部关闭”原则很严格。popover元素默认就是“轻量级弹层”,只要点击弹层以外的区域,它就会自动关闭。这就是很多UI组件库里“点击遮罩关闭”的功能,现在原生实现了。
这种声明式的写法,是真的不需要写一行JavaScript就能实现交互。而且对于多个按钮控制同一个弹层也没问题。
和传统的hidden或display切换有什么区别
以前我们用hidden或者display:none做隐藏元素,再用class控制出现,还需要管理aria-expanded属性。而原生popover在被隐藏时,浏览器自动把它从无障碍树里移除。不会像div那样,虽然看不见,但是读屏软件却可能读到里面的文字。
这一点在无障碍上带来的收益应该是意外的,但确实好用。当然如果你用的是对话框dialog元素,那是另外一套机制,这里说的是popover。
因为popover元素默认带有浏览器预设的样式效果,比如它会出现在最上层,背景会有半透明的遮罩?等一下,其实并没有强制遮罩。你可以通过CSS自定义背景、边框、位置。默认的样式为:position: fixed,元素宽度由内容决定,并且自带一个很淡的阴影。在不同浏览器里长得不太一样,但都不算丑。
弹层的最上层性质,不用再写一个很高的z-index。浏览器会自动把popover元素置于顶层,就好比播放视频全屏那样。
/* 在不懂样式时,popover看起来就像固定定位的卡片 */
配合CSS的::backdrop伪元素,还能做一层半透明遮罩。
#tip::backdrop {
background: rgba(0, 0, 0, 0.3);
}
不过这里的遮罩不会挡住外面的交互,它只是一个视觉装饰。想通过点遮罩关闭弹层,还是用“点击外部关闭”的原生行为,所以体验还好。
使用完,真觉得遇到问题还是要回退到JS
原生popover虽然很方便,但并不是万能的。有些小细节需要自己额外处理。我举几个例子。
期望的交互奇奇怪怪?自己监听toggle事件
popover元素有一个beforetoggle事件和一个toggle事件。当弹层显示/隐藏的时候触发。我们可以用这些事件来做一些额外操作。比如记录埋点,或者在打开弹层时把焦点设置到输入框。
const pop = document.getElementById('tip');
pop.addEventListener('toggle', (event) => {
// event.newState 是 'open' 或 'closed'
if (event.newState === 'open') {
console.log('弹层打开了');
}
});
浏览器对这个支持得很好。在刚开始实验的时候我忘了加事件监听,结果弹层出来以后焦点还在按钮上,键盘用户按下空格,又触发了开关。若想控制焦点,可以在toggle后手动调pop.querySelector('input').focus()。
不要让嵌套的popover把自己关了
默认的“点击外部关闭”机制,在弹层内部放置一个子级popover时,可能会发生奇怪的“连锁关闭”。因为内部弹层也属于外部?不,内部弹层应该包裹在同一个父popover元素里,或者用ancestor规则去避免。
popover有“祖先”的概念,具体来说,一个popover元素内包含另一个popover,点击内部弹层时外部弹层不会关闭。这比较好理解。但如果两个弹层是兄弟元素,点击一个弹层内的区域,却会触发另一个弹层的关闭。因为兄弟区域被当成“外部”。
所以遇到类似“点击一个按钮出现tooltip,点击tooltip里的另一个按钮再出现一个menu”这样的UI,需要小心使用。一种办法是嵌套,让内部弹层成为外部弹层的子孙节点,就像下拉菜单的二级菜单。
下面是简单的嵌套结构:
<div id="outerPop" popover>
<button popovertarget="innerPop" popovertargetaction="toggle">更多选项</button>
<div id="innerPop" popover>
<a href="#" rel="external nofollow" >编辑</a>
</div>
</div>
用这种结构,点击内部弹层的时候不会关掉外层弹层。
默认行为关闭不了,用CSS就能让它常显
popover元素可以被强制一直显示吗?可以使用JavaScript调用showPopover(),但如果用户点击外部还是会自动关闭。如果想要把它当成一个“模态框”(模态对话框)来一直锁住外部,不让点外部关闭,就需要配合一些逻辑,比如在按钮上click后,立刻给弹层加一个监听,或者用一个新的方法叫showModal()。不过后一个方法是dialog元素的。
现实中我做纯提示tooltip时,比较少出现需要强制关闭的场景。但如果真要做右键菜单那种,完全不带“轻击外部关闭”的模态,可以考虑:将弹层打开后,在文档根部监听pointerdown,如果target在弹层以外,阻止这个事件,然后去做别的。但这样就会跟原生popover的关闭混在一起。
所以我的取舍是:能符合默认光照就尽量用popover;非要复杂模态交互,还是老老实实使用dialog + showModal。
结合动态内容改造成提示气泡
有些同学喜欢用CSS伪元素给小气泡画一个三角箭头。popover元素完全支持。我们对一个popover加一个class就可以定义它的位置,比如把它定位在按钮下方。比较麻烦的是定位的基准坐标不是自动的。popover默认是相对视口居中?其实不是,它的默认position是fixed,初始位置可能是在视口中央,但因为没有left/top属性,具体位置依赖于元素的自然文档流?其实它离开了文档流,固定定位,没有设置left/top就会停留在原本它在DOM里的位置附近?也不太确定,不同浏览器表现略有不同。最常用的定位方法还是JS计算按钮坐标后赋left/top。
这算是一个痛点。比如希望点击一个按钮后,popover显示在按钮正下方。没有原生锚点定位,你得自己算。我的处理办法是给按钮绑定一个click,然后用getBoundingClientRect()获取按钮位置,再设置popover的left和top。千万不要忘了把popover的margin给重置一下。
btn.addEventListener('click', () => {
const rect = btn.getBoundingClientRect();
const popoverEl = document.getElementById('tip');
popoverEl.style.left = rect.left + 'px';
popoverEl.style.top = (rect.bottom + 8) + 'px';
if (!popoverEl.matches(':popover-open')) {
popoverEl.showPopover();
}
});
这里使用CSS伪类:popover-open可以判断弹层是否显示。按逻辑,先定位再打开,基本就稳了。
如果页面滚动,或者浏览器窗口尺寸改变,popover的位置不会跟着更新,你需要额外监听scroll和resize调整位置。这种只要popover已打开时的实时跟随定位,肯定还得写JS。原生没有弄这个,它主要解决的是显示隐藏和层叠管理。
在项目中批量替换旧的div弹层
我们项目里原先有几十个“点击出现气泡”的交互。老代码清一色的结构是:一个div absolute,一个按钮click切换class,再做了document中click事件监听来关闭。
重构前,需要看每一个弹层和按钮的具体逻辑。有的弹层内容里有动态数据,事件都是委托到父级。直接把那个div改成popover属性以后,原来绑定在div上的事件可能会受影响吗?放心,因为事件绑定在元素上,不涉及显示方式,不影响。
把原来控制显示隐藏的代码删掉,只需保留popovershow相关的showPopover调用就会自动出现。原来点击外部关闭的逻辑可以直接删除。因为原生popover已经做了这件事。另外,将原来在div上的某些aria属性也删掉,避免跟原生产生冲突。
有一个小坑:popover元素不能嵌套在另一个popover元素内,除非这两个popover之间是祖先关系。假如你有一个menu按钮,menu里面又有子菜单(用一个列表),那就需要上面的嵌套结构,仅支持嵌套popover,而不是把popover放在其他div里。
具体来说,如果列表中的每一项均包含一个popover,那这些popover不能互相包裹。如果它们不是父子关系,点击其中一个会导致另一个关闭。所以我们改造的时候,对于这种嵌套下拉,需要专门处理。
真的一个脚本都不用写?不全是
如果只是调用toggle属性就能弹出隐藏,那你需要HTML按钮上写popovertarget。比如一个按钮就让弹层显示,两个按钮分别控制显示和隐藏。这样不用JS,可以实现基本交互。
但是如果想要点击弹层内部的某个链接后自动关闭弹层,那原生不支持。比如用户点了气泡里的确认按钮,期望整个menu消失。虽然点击按钮时,鼠标落在弹层内部,不会触发外部关闭,所以还需要JS主动hidePopover。
confirmBtn.addEventListener('click', () => {
popoverEl.hidePopover();
});
这种场景躲不开JavaScript。所以很多时候还是在外面包了一层简单的监听器。
做一个通用的帮助气泡组件
我能想到最合适的应用是放在帮助提示上。在每个设置项旁边放一个问号图标,点击弹出一段说明文字。点击页面其他位置自动关闭,完全符合默认轻量popover行为。
我封装了一个很小的helper方法:
function attachHelp(buttonId, contentHtml) {
const pop = document.createElement('div');
pop.innerHTML = contentHtml;
pop.setAttribute('popover', '');
document.body.appendChild(pop);
const btn = document.getElementById(buttonId);
btn.setAttribute('popovertarget', pop.id);
btn.setAttribute('popovertargetaction', 'toggle');
}
pop.id需要唯一生成,临时写没问题。只要把popover挂到body下,它的层级天然高于页面其他元素,不会跟父级overflow冲突。这比使用tooltip的父级relative方案省心许多。
在微信小程序开发里虽然不能用这些API,但web端搞H5项目时用它真的很迅速。
浏览器兼容性,还是要啰嗦一句
如果是做内部管理系统,用户都在Chromium系浏览器上,放心用。如果是做电商大促,需要兼容旧版Safari,就得提前加个判断。因为Safari直到16.4才开始支持popover,更老的版本则完全失效。我给这个帮助按钮做了回退处理:如果!('showPopover' in HTMLElement.prototype),就退回原来的class切换方式。
这也就是渐进增强。现代浏览器体验原生效果,老浏览器照常用老办法,不会功能缺失。
if (typeof HTMLElement.prototype.showPopover !== 'function') {
// fallback: 加class控制显示隐藏
}
反正当前2025年,主流移动端safari和chrome都支持得很好。uni-app里的web-view组件也支持。
最后放一个完整例子:消息气泡
下面我在页面里写一个能直接跑的完整案例,没有多余样式干扰,你复制到空html文件里点开就行。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>popover教程实例</title>
</head>
<body>
<button id="notifBtn" popovertarget="notifPop" popovertargetaction="toggle">查看消息</button>
<div id="notifPop" popover>
<h3>新通知</h3>
<p>你的商品已通过审核。</p>
<button type="button" id="markRead">我知道了</button>
</div>
<script>
const popover = document.getElementById('notifPop');
document.getElementById('markRead').addEventListener('click', function() {
popover.hidePopover();
});
</script>
</body>
</html>
这里必须注意,按钮上面的popovertarget不一定非要用id引用,也可用class吗?不能,只能是目标元素的id。所以先给popdiv设一个id。
和之前的代码一样,如果点了按钮再点“我知道了”,会把弹层关掉。并且在弹层外点击,也会自动关掉。原生关闭动画比较平淡,过渡很平滑,不需要额外加transition。不过你可以在CSS里给popover加上动画效果,这里不扩展。
结语:理性看待新玩具
原生popover不能完全替代所有弹层库,但它确实把“轻量提示浮层”这个场景变得特别轻松。不用加载UI框架,也能编码出可用的交互,这种快乐很像原生CSS变量刚普及时期。
至少以后再有人问我怎么弹出一个气泡,我会先甩给他一个popover,再解释什么遮罩z-index。
既然是标准API,没有别人说得那么玄乎,还是自己动手去试最靠谱。希望这篇随笔能帮你少踩一点坑。

