上一篇说到了command,commandfor ,commandfor 管开关,现在说一下弹层本体
说 commandfor命令,这个命令只负责开和关,弹层本体还没着落。总不能用 <div> 手搓吧。
手搓过气泡组件的人都懂,那不是写一个 div 的事,主要有四个点:
1
2
3
4
5
6
overlay.style.zIndex = 99999; // 1:z-index 层级
document.addEventListener('click', // 2:点空白处关闭
(e) => { if (!wrap.contains(e.target)) close(); });
document.addEventListener('keydown', // 3:ESC 关闭
(e) => { if (e.key === 'Escape') close(); });
// 4:焦点管理和边界翻转
每一个点都被造了无数遍轮子。2023 年起,浏览器把它们全部收编进一个 HTML 属性:
1
2
<button popovertarget="tip">点我</button>
<div id="tip" popover>我是气泡</div>
一、一个属性就是一套系统
1.1 最小示例
popover 写在弹层本体上,popovertarget 写在触发按钮上指向它。上面两行就是全部——按钮点一下开,再点一下关。
1.2 位置呢?别急,锚点定位来补齐
popover 解决了“层级”和“开关”,但气泡默认出现在视口居中(或跟随 DOM 流),这在实际 UI 中基本不可用。想要弹窗紧贴着触发按钮,需要配合 Anchor Positioning(锚点定位) API。
最简用法是在弹窗元素上用 anchor 属性指向触发按钮的 id,或者通过 CSS 的 position-anchor 和 inset-area 控制方位。例如:
1
2
3
4
5
#tip {
position-anchor: --btn; /* 锚定到按钮 */
inset-area: block-start; /* 出现在按钮上方 */
}
#tip::backdrop { background: transparent; } /* 无遮罩 */
(注:锚点定位水也很深,这里只提核心思路,后续可单开一篇细说。)
1.3 默认是 auto
popover 实际上是 popover="auto" 的省写。默认值是auto
1.4 支持度:不用等了
Popover API 2024 年 4 月就进入了 Baseline(Chrome 官方博客口径),比 commandfor 早两年,支持率没有悬念。

二、配套四件套(新增焦点管理)
2.1 Top Layer:z-index 大战的终点是不在一个图层打
一个 z-index: 99999 的红色大块头压在页面上,popover 能不能盖过它?
测试页面布局:红色块头 position: fixed,z-index 拉满,和气泡故意重叠。用 elementFromPoint 看重叠区最顶层是谁:
1
T6c Top Layer: 打开前最顶层=monster, showPopover() 后=pz
打开前是 monster(红色块头),showPopover() 之后变成了 pz(气泡)。z-index: 99999 输了。
原因:显示中的 popover 渲染在 top layer——浏览器渲染栈里比所有普通元素高一层的位置,z-index 根本够不着它。这也是 <dialog> showModal 后压得住全页面的同一套机制。

而且跟z-index不一样的是,在浏览器的f12工具栏里面Layers标签里面,top-layer并没有显示在这里
这是因为top-layer是一个抽象的堆叠层,由浏览器自动管理、动态变化的抽象概念;DevTools里面的Layers面板显示的是具体的合成层,是一个具体的、可视化的图层,是两个不同的概念。
z-index 大战的终点,是根本不在一个图层打。
2.2 light dismiss 和 ESC 自动支持
点弹层外部关闭、按 ESC 关闭——这两个交互在 UI 库里叫「点击外部关闭」,是要专门写代码、写测试、修 bug 的功能。auto popover 里它们是默认行为,实测:
1
T2 auto light dismiss: 点外部关闭=True, ESC 关闭=True ✅
2.3 焦点自动回退:A11y 的白送福利(新增)
除了显性的层级和关闭,浏览器还默默帮你管理了焦点。当 auto 弹窗打开时,焦点会自动移动到弹窗内部(或首个可聚焦元素);关闭时,焦点会自动返回到触发按钮。
这个“焦点回退”机制在手写组件时极难完美实现(要记录触发源、处理焦点丢失),而 Popover API 直接内置了它,这也是它碾压第三方 UI 库的隐性杀手锏。
三、边界测试
3.1 auto vs manual
| 行为 | auto | manual |
|---|---|---|
| 点外部关闭 | ✅ | ❌ 实测不关 |
| ESC 关闭 | ✅ | ❌ 实测不关 |
| 开另一个 auto 气泡时 | 自己被强制关掉(互斥) | 不受影响 |
| 嵌套场景(气泡里套气泡) | 祖先豁免互斥,父子可以同时开 | — |
1
2
3
T3 manual: 点外部仍开=True, ESC 后仍开=True ✅
T4 auto 互斥: 开 Y 后 X 关闭=True, Y 开着=True ✅
T10 嵌套: 外层=True, 内层=True ✅ 都开着(祖先豁免互斥)
互斥规则有一句值得背下来:开新的 auto 气泡会强制关闭其他 auto 气泡,祖先除外(这里的“祖先”特指 DOM 结构上的嵌套父级,例如外层 <div popover> 包裹内层 <div popover> 时,开内层不会关外层)。这保证页面上不会同时飘着一堆互不相干的气泡——这正是 tooltip 类交互的核心体验,规范直接替你定了。
3.2 打开一个 popover 的四种方式
这是本篇第二大的坑。四种方式实测:
方式一:popovertarget 声明式触发(上面已演示)——推荐,还能配 popovertargetaction="show" / "hide" 做分立按钮,实测通过。
方式二:commandfor + command="toggle-popover"——上一篇的主角也能管 popover,内置命令三个:toggle-popover / show-popover / hide-popover,实测通过。
方式三:JS 调 showPopover() / hidePopover() / togglePopover()——需要在业务逻辑里联动时用。
方式四:open 属性——❌ 别碰。
虽然 <dialog> 和 <details> 用 open 驱动状态,但 popover 完全不认它。实测表明:<div popover open> 页面加载并不会开;即便 showPopover() 成功后,DOM 上也不会出现 open 属性。更关键的是,div 上根本没有 el.open 这个 IDL 属性。
铁律:open 属性既不驱动状态,也不反映状态。判断是否打开,唯一靠谱的姿势是:
1
el.matches(':popover-open') // ✅ 唯一靠谱的状态查询
想让某个气泡默认开着?页面加载后调一次 showPopover(),老实但有效。
3.3 popovertarget 和 commandfor 撞车,听谁的
2023 年规范先给了 popovertarget,2025 年又给了更通用的 commandfor。同一个按钮,两套属性都写,点一下会发生什么?
按钮同时挂 popovertarget="pop-w" 和 commandfor="pop-w" command="toggle-popover":
1
2
3
4
T7 同一按钮两套 API: 单次点击后 pop-w 开启=True ✅ 正常开一次
console:
warning: popovertarget is ignored on elements with commandfor.
答案是开一次,popovertarget 被忽略,console 给一条警告。浏览器用这条警告给出了官方态度——commandfor 赢了。
新项目可以直接使用 commandfor(一套 API 管 dialog + popover,还能自定义命令);存量代码里的 popovertarget 不用急着改,它没被废弃,照样工作。
MDN 的口径也是这个意思:两者能力非常相似,commandfor 更通用。
3.4 toggle 事件
监听开关状态,规范给了两个事件:beforetoggle(变之前)和 toggle(变之后),事件对象带 oldState / newState。正常情况:
1
2
T8c 对照组(只开不关):
toggle: closed -> open
特别留意 beforetoggle 的可取消性:在 beforetoggle 中调用 e.preventDefault() 可以阻止弹窗打开或关闭,且不会触发后续的 toggle 事件。这在表单未保存时拦截关闭的场景中非常实用。
但把「开」和「关」放进同一个任务里(showPopover() 紧跟 hidePopover()),事件序列变成这样:
1
2
3
4
T8b 同一任务内 showPopover()+hidePopover() 的事件序列:
beforetoggle: closed -> open
beforetoggle: open -> closed
toggle: closed -> closed
最后一行toggle 事件 fire 了,但 oldState 和 newState 都是 closed。规范把同一轮里发生的多次状态变化合并成一次通知,最后状态没变,就报一个空转。
埋点踩这个坑的姿势:在 toggle 里统计弹层打开次数——你会把空转也记进去。判断逻辑写 if (e.newState === 'open') 才是对的,别假设每次 toggle 都是真实开关。
3.5 hint 一句话带过(下篇预告)
popover="hint" 在 Chromium 143 已实现,定位是「悬停意图」场景(配 interest invokers 使用),水比较深,本篇暂不展开,留作下篇预告。
四、popover 还是 dialog:一张决策表
两者都上 top layer,都能白送一堆东西,什么时候用哪个:
| 维度 | popover | dialog(showModal) |
|---|---|---|
| 模态性 | 非模态,页面还能点 | 模态,backdrop 拦截一切点击 |
| 点外部 | auto 默认关闭 | 默认不关,要自己写 |
| 典型场景 | tooltip、菜单、toast、非阻塞通知 | 确认框、表单、必须处理完才能走的流程 |
| 轻量程度 | 任意元素 + 一个属性 | 专用标签 + 语义更重 |
打断用户用 dialog,不打断用 popover。
面试题
Q1:popover 和 dialog 都能弹层,怎么选?
答:看模态性。dialog.showModal() 是模态的——backdrop 拦截页面所有点击(Vol.01 实测过外部按钮点不动),且语义上是「流程阻塞」。popover 是非模态的,页面交互照常。确认框、强制流程用 dialog;tooltip、菜单、通知用 popover。
Q2:怎么判断一个 popover 当前是否打开?
答:标准姿势是 el.matches(':popover-open') 或监听 toggle 事件读 e.newState。注意两个坑:div 上没有 el.open IDL 属性(那是 dialog/details 的);open 属性在 Chromium 143 实测既不驱动也不反映 popover 状态。
Q3:popovertarget 和 commandfor 同时写在一个按钮上,点一下会怎样?
答:只触发一次,popovertarget 被忽略,console 输出警告「popovertarget is ignored on elements with commandfor.」。commandfor 是更晚更通用的机制,冲突时胜出。
总结
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Popover API(Baseline 2024.4)
│
├─ 一个属性
│ └─ popover="auto"(默认)/ "manual" / "hint"
│
├─ 白送四件套(实测)
│ ├─ Top Layer:z-index 够不着(T6c: 99999 输给气泡)
│ ├─ light dismiss:点外部关闭(仅 auto)
│ ├─ ESC 关闭(仅 auto)
│ └─ 焦点自动回退:打开/关闭自动管理焦点(A11y 福利)
│
├─ auto 的隐藏规则
│ ├─ 互斥:开新关旧,DOM 嵌套父级豁免
│ └─ manual:全部不自动关,自己管自己
│
├─ 四种打开方式
│ ├─ popovertarget ✅(存量友好)
│ ├─ commandfor ✅(2026 首选,冲突时胜出)
│ ├─ showPopover() 等 JS ✅(逻辑联动用)
│ └─ open 属性 ❌(不驱动不反映,状态查询用 :popover-open)
│
└─ 事件
├─ beforetoggle → toggle(带 oldState/newState)
├─ ⚠️ beforetoggle 可执行 e.preventDefault() 拦截开关
└─ ⚠️ 同任务开+关 = 空转 toggle(closed->closed),埋点按 newState 判断
下一篇可以讲一下<dialog> 与 Top Layer——为什么 z-index: 99999 打不过一个 dialog。本篇只是开头,下篇top layer、::backdrop 和层叠上下文都可以说一下。

验证 Demo 与实测脚本已打包,gzh程序员蜡笔熊回复「弹层」
感谢大家观看,可以评论和提提意见。