上周整理一个后台项目的 JS 文件,发现光是「点按钮打开某个弹层」这类代码就写了四十多处,全是同一个套路:querySelector 找到按钮、找到目标元素、绑 click、toggle 一个 class、再处理点击外部关闭。每一处都差不多,每一处又都有点小差异,有的忘了处理 Esc,有的忘了在关闭时把焦点还回去。
这些东西本来是浏览器该干的活。这些年规范也确实是往这个方向走的,只是很多人还没切过来。这次就借一个真实的后台列表页,把 commandfor + popover 这套组合完整走一遍,顺带把动画、事件委托、降级这些边角料也处理掉。
一个弹层的”标准写法”有多啰嗦
先看看我们原来怎么写的。一个筛选面板,点按钮弹出来:
<button id="filter-btn">筛选</button>
<div id="filter-panel" hidden>...</div>
<script>
const btn = document.getElementById('filter-btn')
const panel = document.getElementById('filter-panel')
btn.addEventListener('click', function (e) {
e.stopPropagation()
panel.hidden = !panel.hidden
})
document.addEventListener('click', function (e) {
if (!panel.hidden && !panel.contains(e.target)) {
panel.hidden = true
}
})
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !panel.hidden) {
panel.hidden = true
btn.focus()
}
})
</script>
二十行代码,换来一个「勉强能用」的弹层。说勉强是因为:面板不会自动跑到页面最上层,父级只要有 overflow: hidden 或者 transform,就会被裁掉;焦点不会困在面板里,Tab 键能直接跑到后面的内容上;而且这个 document 上的监听,一旦页面里有十个弹层,就是十份互相干扰的判断逻辑。
原生这套方案把这二十行压缩成两个属性。
popover:浏览器帮你管的浮层
给元素加上 popover 属性,它就自动获得了几件事:渲染在顶层,不受祖先元素的裁剪和层级影响;点击外部自动关闭;按 Esc 自动关闭并归还焦点;同一时刻只会显示一个 auto 类型的 popover。
<button commandfor="filter-panel" command="--toggle-popover">筛选</button>
<div id="filter-panel" popover>
<label><input type="checkbox"> 只看未读</label>
<label><input type="checkbox"> 只看我负责的</label>
</div>
就这么多。没有 script,没有 id 查找,没有事件监听。
popover 有三个可选值,别用错:
popover="" 或者 popover="auto" 是默认值,上面说的自动关闭、互斥显示都归它管。popover="manual" 关掉了所有自动行为,点击外部和 Esc 都不好使,得自己写代码关,适合做那种必须用户明确处理的浮层。popover="hint" 是给提示气泡用的,打开它不会关掉已经打开的 auto popover,不过这个值的浏览器支持还不完整,正式项目里先别依赖。
还有一个容易踩的点:auto popover 默认会被浏览器定位到视口正中间——UA 样式里给了 position: fixed; inset: 0; margin: auto。所以第一次写完你会发现面板跑到了屏幕中央,而不是按钮旁边。position: absolute 加手动 top/left 是传统解法,现在还能用 CSS 锚点定位把 popover 绑到触发按钮上:
#filter-btn {
anchor-name: --filter-trigger;
}
#filter-panel {
position-anchor: --filter-trigger;
top: anchor(bottom);
left: anchor(left);
margin: 6px 0 0;
position-try-fallbacks: flip-block;
}
anchor-name 给按钮起个名字,position-anchor 让面板认这个锚点,然后用 anchor(bottom) 这类函数取按钮的边。position-try-fallbacks: flip-block 的意思是:如果下面放不开,自动翻到上面去。这套东西在移动端和侧边栏场景里很省事,但兼容性比 popover 本身差一截,不支持的时候面板会回到默认的居中位置,功能不受影响,只是难看点。要不要用,看你项目的浏览器要求。
command 和 commandfor:让按钮直接指挥另一个元素
上面那个 --toggle-popover 就是内置命令之一。commandfor 指定「指挥谁」,command 指定「做什么」。目前已经被浏览器认领的内置命令大致分三组:
跟 popover 有关的:--toggle-popover、--show-popover、--hide-popover。
跟 <dialog> 有关的:--show-modal 相当于 showModal(),--close 相当于 close(),--request-close 相当于 requestClose()——最后这个的区别是它会先派发 cancel 事件,你可以在事件里拦一道做二次确认。
跟选择器有关的:--show-picker、--hide-picker、--toggle-picker,针对 <select> 和带 picker 的输入框,还在逐步铺开。
用 dialog 做一个删除确认,完整写法是这样:
<button commandfor="del-dialog" command="--show-modal">删除</button>
<dialog id="del-dialog" closedby="any">
<h2>确认删除</h2>
<p>删除后无法恢复,确定继续吗?</p>
<form method="dialog">
<button value="cancel">取消</button>
<button value="confirm">删除</button>
</form>
</dialog>
这里有个我之前一直写错的地方:<form method="dialog"> 里的按钮,点击后表单会以按钮的 value 作为返回值关闭对话框,同时触发 dialog 的 close 事件,dialog.returnValue 就是那个值。所以判断用户点的是取消还是确定,只要看 returnValue,不需要额外绑两个 click。
const dialog = document.getElementById('del-dialog')
dialog.addEventListener('close', () => {
if (dialog.returnValue !== 'confirm') return
deleteItem()
dialog.returnValue = ''
})
closedby="any" 是后加进来的属性,让对话框也支持点背景关闭,取值还有 closerequest(只有 Esc,默认)和 none(只能通过代码或表单关)。用的时候顺手查一下支持情况,不支持也不报错,只是退回默认行为。
完整案例:一个可复用的列表操作栏
把上面的东西攒起来,做一个后台工单列表页。需求是:顶部一个筛选 popover,每行有「指派」「归档」两个操作,批量选中后底部浮出一个操作条,删除时弹确认框。
<div class="toolbar">
<button commandfor="filter-panel" command="--toggle-popover">筛选</button>
<button commandfor="batch-bar" command="--show-popover">批量操作</button>
</div>
<div id="filter-panel" popover>
<label><input type="checkbox" data-filter="unread"> 只看未读</label>
<label><input type="checkbox" data-filter="mine"> 只看我负责的</label>
</div>
<ul id="ticket-list">
<li id="ticket-1001">
<input type="checkbox" class="row-check" value="1001">
<span>登录接口偶发 502</span>
<button commandfor="ticket-1001" command="assign">指派</button>
<button commandfor="ticket-1001" command="archive">归档</button>
</li>
<li id="ticket-1002">
<input type="checkbox" class="row-check" value="1002">
<span>导出按钮点击无响应</span>
<button commandfor="ticket-1002" command="assign">指派</button>
<button commandfor="ticket-1002" command="archive">归档</button>
</li>
</ul>
<div id="batch-bar" popover="manual">
<span id="batch-count">已选 0 项</span>
<button commandfor="batch-bar" command="--hide-popover">收起</button>
<button commandfor="batch-dialog" command="--show-modal">批量删除</button>
</div>
<dialog id="batch-dialog">
<p>确认删除选中的工单?</p>
<form method="dialog">
<button value="cancel">取消</button>
<button value="confirm">删除</button>
</form>
</dialog>
注意列表里那些按钮:command="assign" 和 command="archive" 都不是内置命令,浏览器不会做任何默认动作,但会在按钮被点击时派发一个 command 事件,冒泡到 document。这就是我们要的钩子。
整个页面的 JS 就剩下这么点:
const list = document.getElementById('ticket-list')
const batchBar = document.getElementById('batch-bar')
const batchCount = document.getElementById('batch-count')
const batchDialog = document.getElementById('batch-dialog')
// 所有行内操作和批量删除,都从这一处进去
document.addEventListener('command', (e) => {
const { command, source } = e
// 内置命令也走这里,用前缀区分开自己的自定义命令
if (command.startsWith('--')) return
// source 是真正被点的那个按钮,比 e.target 可靠
const row = source.closest('li')
const ticketId = row ? row.id.replace('ticket-', '') : ''
if (command === 'assign') {
openAssignPicker(ticketId)
} else if (command === 'archive') {
archiveTicket(ticketId)
}
})
// 选中数量变化时,控制批量条显隐
list.addEventListener('change', () => {
const checked = list.querySelectorAll('.row-check:checked').length
batchCount.textContent = '已选 ' + checked + ' 项'
if (checked > 0) {
batchBar.showPopover()
} else {
batchBar.hidePopover()
}
})
batchDialog.addEventListener('close', () => {
if (batchDialog.returnValue !== 'confirm') return
batchDialog.returnValue = ''
removeSelected()
})
两个细节值得说一下。
一是 e.source。CommandEvent 上带了这个属性,指向触发命令的那个按钮。虽然大部分时候 e.target 就是它,但如果按钮里包了 <span> 或图标,e.target 会变成内部元素,closest 得多绕一步。source 从一开始就是那个按钮,省事。
二是前缀判断。内置命令的取值全都以 -- 开头,所以自定义命令的时候千万别用这个前缀,一来规范把这个命名空间留给浏览器了,二来你在 handler 里做分流也方便。直接返回,让浏览器自己去处理内置命令。
给 popover 加入场动画
加上 popover 之后直接写 transition 是不生效的,因为元素在打开和关闭时 display 在 none 和 block 之间跳变,而 display 默认不参与过渡。得显式告诉浏览器「这个属性可以离散过渡」。
[popover] {
opacity: 0;
transform: translateY(-8px);
transition:
opacity 180ms ease,
transform 180ms ease,
display 180ms allow-discrete,
overlay 180ms allow-discrete;
}
[popover]:popover-open {
opacity: 1;
transform: translateY(0);
}
@starting-style {
[popover]:popover-open {
opacity: 0;
transform: translateY(-8px);
}
}
三块拼起来才完整。allow-discrete 让 display 和 overlay 能延迟切换——没有 overlay 这一条,元素在关闭动画播完之前就会被踢出顶层,动画是看不见的。@starting-style 定义「刚插入渲染树时的初始值」,没有它,元素一出现就已经是 opacity: 1,入场动画根本没起点。
dialog 的 ::backdrop 同理:
dialog::backdrop {
opacity: 0;
transition: opacity 180ms ease, display 180ms allow-discrete;
}
dialog[open]::backdrop {
opacity: 1;
}
@starting-style {
dialog[open]::backdrop {
opacity: 1;
}
}
这里写反了——@starting-style 里应该是 opacity: 0,让遮罩从透明渐入。写代码的时候容易与 popover 那段搞混,一个是元素本身从 opacity: 0 到 1,一个是遮罩从 0 到 1。
焦点和键盘,别以为浏览器全包了
popover 和 showModal 帮你处理了「打开时自动把焦点移进去」和「关闭时归还焦点」,但中间那段它管不了。
showModal() 打开的 dialog 有内置的焦点陷阱,Tab 键会在对话框内部循环,不会跑到背景页面上,这点比手写的好太多。popover 没有这层保护,Tab 还是能跑到后面去。如果你的 popover 里全是 checkbox 这类不需要连续输入的内容,一般没事;要是里面有输入框和一连串按钮,得自己加焦点循环,或者干脆改用 dialog。
另外一个常见问题是:popover 关闭后浏览器把焦点还给了触发按钮,但你的关闭逻辑如果是代码里调 hidePopover() 触发的,焦点不会自动还。需要手动补一句:
function closeFilter() {
const panel = document.getElementById('filter-panel')
panel.hidePopover()
document.querySelector('[commandfor="filter-panel"]')?.focus()
}
这个细节在触摸屏上看不出来,但用键盘操作的用户会立刻迷失位置。
兼容性检测与降级
这套特性在 Chromium 系上已经稳定,Safari 和 Firefox 在陆续跟进,但你的用户里总有落后一两个大版本的。上线前做个能力检测,不支持的时候补一段脚本兜底就行。
const probe = document.createElement('button')
const supportsCommand = 'command' in probe
if (!supportsCommand) {
// 把 commandfor / command 翻译成普通的点击绑定
document.querySelectorAll('[commandfor]').forEach((btn) => {
const targetId = btn.getAttribute('commandfor')
const cmd = btn.getAttribute('command')
btn.addEventListener('click', () => {
const target = document.getElementById(targetId)
if (!target) return
if (cmd === '--toggle-popover') {
target.togglePopover()
} else 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 {
// 自定义命令,补一个 CommandEvent 保持上层逻辑不变
document.dispatchEvent(new CustomEvent('command', {
detail: { command: cmd, source: btn }
}))
}
})
})
}
注意最后那个分支。降级代码里派发的是普通 CustomEvent,而原生的是 CommandEvent,两者结构不一样:原生的 command 和 source 直接挂在事件对象上,不是放在 detail 里。如果两套都要支持,业务代码里得兼容读取,或者在这里统一成一种形式。我自己的做法是在降级分支里包装一个假的 CommandEvent 结构:
const evt = new Event('command', { bubbles: true })
evt.command = cmd
evt.source = btn
document.dispatchEvent(evt)
用普通 Event 然后往上挂属性,虽然不太讲道理,但能让业务层完全无感,省掉一堆 if。生产代码里这么干是可以的,只要在注释里写清楚为什么。
另外 popover 本身也要检测,因为它比 command 出现得早,理论上存在「支持 popover 但不支持 command」的组合。检测方式是 'popover' in HTMLElement.prototype。
几个我踩过的坑
popover 元素不能是 position: relative 的祖先的后代吗? 不是。真正的问题出在 transform 和 filter 上——给祖先加了 transform,会让 position: fixed 的后代以那个祖先为包含块,popover 就飞到奇怪的地方去了。这不是这套 API 的锅,是 CSS 的老规矩,但配合锚点定位的时候特别容易撞上。
在 popover 里点按钮,面板会先关掉。 因为默认的 auto popover 遇到底部任何点击都会关。如果你的 popover 里有个「应用筛选」按钮,用户点完面板就没了。两个办法:把 popover 改成 manual,自己写关闭逻辑;或者在按钮的 click 里调一次 event.preventDefault()。我一般选前者,因为筛选面板正常就该关掉,只是要保证数据先收集完。
同一个 commandfor 值只能指向一个元素吗? 是的,commandfor 是 id 引用,id 在页面里必须唯一。如果列表里每一行都要有独立的 popover,就得给每行生成唯一 id,像上面案例里的 ticket-1001 那样。用框架的话直接绑行数据的 id 就行,纯手写的话记得别复制粘贴时忘了改。
自定义命令的命名。 除了别用 -- 开头,也别用空字符串和带空格的值。规范没明说不能用,但各个浏览器的解析实现未必一致,冒这个险不值得。短横线连缀的英文小写就行,assign、archive、mark-read,一目了然。
CommandEvent 上还有一个 source,但它可能是 null。 如果事件不是由用户点击触发的(比如你在脚本里 dispatchEvent),source 就是空。业务代码里记得判一下,别直接 source.closest()。
什么时候别用这套
不是所有点击交互都适合换成命令按钮。需要复杂参数、需要异步校验、需要根据状态走不同分支的操作,老老实实写事件监听更清楚。命令按钮最舒服的场景是「点一下就切换某个元素的可见状态」这类纯声明式的动作,尤其是弹层开关、对话框控制这种浏览器已经实现好的行为。
我自己的判断标准很简单:如果这个按钮的 onClick 里只有一行「打开/关闭某元素」,那就换成 commandfor;只要多出一行别的逻辑,就留着写 JS。混着用完全没问题,两者互不冲突。
改造完这个列表页之后,整个文件的 JS 从两百多行掉到七十行,省下来的那部分全是之前在各个弹层之间来回搬运的样板代码。更重要的是,Esc 关闭、焦点归还、顶层渲染这些原来每个弹层都要单独处理的事,现在浏览器统一负责了,不用再担心哪个漏了。

