HTML 给 button 悄悄加了一对属性 command / commandfor——写弹窗那三行 getElementById + addEventListener 现在可以删了

做移动端页面的人都有个肌肉记忆:只要页面上有弹窗、气泡菜单、确认框,就得先写三行胶水代码——document.getElementById 拿到按钮、再拿到 dialog、最后 addEventListener('click', ...) 调 showModal()。一个页面塞五六个弹层,这三行就复制五六遍,还各自带一点忘写焦点归还、忘关遮罩点击的小毛病。

现在这套胶水可以从 HTML 里删掉了。Invoker Commands API 给 <button> 加了一对属性:command 声明”干什么”,commandfor 声明”对谁干”。浏览器直接把按钮和它要控制的元素连起来,不需要任何事件监听器。这个特性已经进了 Baseline——Chrome 135、Firefox 144、Safari 26.2 之后全部支持,Can I Use 上的全球覆盖率到 Chrome 152 版本已经是 84.72%,Safari 26.2 补上之后就是”全绿”,不是尝鲜功能。

旧写法 vs 新写法

以前打开一个模态框,至少是这样:

const openBtn = document.getElementById('open');
const dlg = document.getElementById('my-dialog');
openBtn.addEventListener('click', () => dlg.showModal());

const closeBtn = dialog.querySelector('.close');
closeBtn.addEventListener('click', () => dlg.close());

换成 Invoker Commands 之后,同一个交互变成一个监听器都不用写:

<button commandfor="my-dialog" command="show-modal">打开</button>

<dialog id="my-dialog">
  <p>删除这条记录?</p>
  <button commandfor="my-dialog" command="close">取消</button>
</dialog>

两个属性,零 JS。内置命令覆盖了最常被手写的那几类:dialog 侧有 show-modal、close、request-close;popover 侧有 toggle-popover、show-popover、hide-popover。名字就是把 JS 方法名转成 kebab-case,showModal() 对应 show-modal,requestClose() 对应 request-close,迁移时基本靠直觉就能对上。

真正省下的不是代码,是焦点和键盘那部分

少写三行只是表面收益。因为交互归浏览器管,你顺带拿到的是:

  • 焦点管理:模态框打开后焦点自动进入,关闭后自动回到触发按钮——以前这段是业务 deadline 一来第一个被砍的
  • Escape 键关闭:dialog 和 popover 的 Esc 行为由平台保证,不用你自己监听 keydown
  • 无障碍关系:触发按钮和目标元素之间的从属关系浏览器自己维护,读屏软件能正确念出来

说白了,它把你代码的下限从”看开发者良心”抬到了”平台保证”。遮罩点不关、焦点不归还这类 bug,是直接不再发生,而不是靠你记得写。

还有一个容易被忽略的约束:只有 <button> 能当 invoker,链接和输入框不行。因为 command 是”动作”,而 button 才是语义和键盘行为都对的元素。这也顺手治了一批”用 div 假装按钮”的老毛病。

自定义命令和那个不冒泡的坑

内置命令只管 dialog 和 popover。想用自己的动作,得用双横线前缀——跟 CSS 自定义属性一个命名约定,浏览器保证永远不会发一个以 -- 开头的内置命令,所以你的名字不会撞车:

<button commandfor="photo" command="--rotate-left">左转</button>
<img id="photo" src="cat.jpg" alt="一只正立的猫" />
const photo = document.getElementById('photo');
photo.addEventListener('command', (event) => {
  if (event.command === '--rotate-left') { /* 旋转并更新 alt */ }
  // event.source 是触发这条命令的那个按钮
});

这里有个必须记住的坑:command 事件不会冒泡。你必须监听在目标元素本身上,不能像普通事件那样在共同祖先上做事件委托,document.addEventListener('command', ...) 是收不到的。很多人第一次用自定义命令就是踩在这。

另外提醒一句,自定义命令是绑在元素的事件属性上的,CSS-Tricks 也点过这个——它和”用 HTML 属性写事件处理器算不算坏习惯”的老争论沾边。内置命令(dialog/popover 那几个)不受影响,只有你自己定义 -- 命令时才需要权衡。

现在就能落地

Step 1:先做特性检测,确认降级路径。老浏览器里带 command/commandfor 的按钮会退化成”点了没反应的普通按钮”,所以留一个回退:

if (!('command' in HTMLButtonElement.prototype)) {
  // 挂一个 click 监听器做兜底
}

Step 2:把项目里 dialog、popover 的 addEventListener + showModal()/close() 那一整块删掉,换成 command/commandfor 两个属性。渐进迁移也行——旧的 popovertarget/popovertargetaction 属性和新的 command/commandfor 可以同时存在,先改一半不用担心。

Step 3:跑一遍键盘测试(Tab 进得去、Esc 关得掉、关闭后焦点回到触发按钮),确认平台给的焦点行为符合你的页面预期,再删掉自己那套手写焦点管理。

from 三行 JS 变零行,真正的收益是弹层的可访问性从”事后补”变成”默认就有”。

评论区

0 条评论

登录后可评论。

阿跨·跨端开发 14 阅读