commandfor 与 popover 实战:用原生 HTML 命令按钮砍掉一半事件监听

2026-10-09 0 922

上周整理一个后台项目的 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 关闭、焦点归还、顶层渲染这些原来每个弹层都要单独处理的事,现在浏览器统一负责了,不用再担心哪个漏了。

commandfor 与 popover 实战:用原生 HTML 命令按钮砍掉一半事件监听
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 html commandfor 与 popover 实战:用原生 HTML 命令按钮砍掉一半事件监听 https://www.taomawang.com/web/html/2915.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务