HTML Invoker Commands API 实测
先看一段你肯定写过的代码。控制一个弹窗的开关,负责任的写法大概是这样:
js
const dialog = document.getElementById('set-dialog');
const openBtn = document.getElementById('set-open');
const closeBtn = document.getElementById('set-close');
let lastFocused = null;
// 打开:记住从哪来,关闭后好还回去
openBtn.addEventListener('click', () => {
lastFocused = document.activeElement;
dialog.showModal();
});
// 关闭按钮
closeBtn.addEventListener('click', () => dialog.close());
// 点遮罩关闭------手写版最常忘的一步
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close();
});
// 焦点归还------第二常忘的一步,屏幕阅读器用户全靠它
dialog.addEventListener('close', () => {
lastFocused?.focus();
});
// ESC 关闭由 showModal 自带,但要做状态同步
dialog.addEventListener('cancel', () => {
console.log('用户按了 ESC,这里同步状态');
});
现在再看两行 HTML:
html
<button commandfor="set-dialog" command="show-modal">打开</button>
<dialog id="set-dialog">...</dialog>
打开弹窗:第一行。ESC 关闭:浏览器自带。遮罩点击、焦点管理:浏览器自带。JS:零行。
这不是什么新框架的语法糖,是 HTML 自己长出来的能力------Invoker Commands API 。说实话,我第一次看到 commandfor 这个属性时是不信的,直到我把本文所有代码实际跑了一遍。
一、两个属性
1.1 commandfor + command
就两个属性,语义直白:
commandfor:这个按钮控制谁(目标元素的 id)command:控制它干什么(一个命令字符串)
html
<button commandfor="my-dialog" command="show-modal">打开模态框</button>
<button commandfor="my-dialog" command="close">关闭模态框</button>
<dialog id="my-dialog">
<p>我是 dialog,一个监听器都没写</p>
<button commandfor="my-dialog" command="close">好,关闭</button>
</dialog>
控制逻辑写在按钮上,不是写在 dialog 上。谁是发起方,属性就写在谁身上。
1.2 两种内置弹层都能管
<dialog> 和 popover 都吃这套命令:
html
<button commandfor="my-pop" command="toggle-popover">切换气泡</button>
<div id="my-pop" popover>我是 popover,点外面自动关(light dismiss)</div>
popover 属性自带 light dismiss(点击弹层外部自动关闭),配上 toggle-popover 命令,一个点击出现、点空白消失的气泡交互就齐活了。
1.3 支持度:不用等了
今年 1 月,Invoker Commands 达成 Baseline(MDN 口径:2026 年 1 月全主流浏览器可用)。Can I use 上 commandfor 的全球支持率截至发稿是 82.91%。这不是尝鲜特性,是可以直接写进生产的。

二、浏览器为什么要管这事
2.1 多个库重复造
打开/关闭弹层,是 Web 上重复度最高的交互,没有之一。jQuery 时代抄一遍,Vue/React 时代每个组件库再抄一遍,抄出来的版本还各不一样------有的忘了焦点归还,有的忘了遮罩点击。所有人都做、所有人做的都一样的一件事,就该到平台层。CSS 动画干掉 JS 动画库是这个逻辑,commandfor 也是。
2.2 可访问性是白送的
上面那段二三十行的 JS 里,最容易被砍掉的就是焦点管理那几行------业务 deadline 一来,lastFocused?.focus() 是第一个牺牲品。而声明式版本里,焦点管理、键盘操作、按钮和弹层的从属关系,全部由浏览器实现,想忘都忘不掉。这是我认为比「少写代码」更重要的价值:它把下限从「看开发者良心」变成了「平台保证」。
2.3 趋势
回头看这两年的 HTML:<dialog> 给了原生模态框,popover 给了原生弹层,commandfor 把它们串起来------HTML 在从「静态文档标记」往「声明式交互协议」走。今天理解这条线,明天看懂 interesttarget(悬停意图触发,实验中)就不费劲。
三、实测
命令表和事件流,可以先猜一下输出,再往下看。
3.1 命令全表(实测版)
| 目标 | 命令 | 实测结果 |
|---|---|---|
| dialog | show-modal |
✅ 打开模态框 |
| dialog | close |
✅ 关闭 |
| dialog | request-close |
✅ 关闭, 规范里比 close 多一道可拦截环节 |
| dialog | show |
❌ 未生效 |
| popover | toggle-popover |
✅ 切换 |
| popover | show-popover / hide-popover |
✅ 显示 / 隐藏 |
request-close专为"关闭前拦截"设计,对应的 command 事件可以preventDefault()阻止关闭;而close命令的默认行为无法 被preventDefault()拦截。一个给业务留钩子,一个给 UI 保底。
show-modal 好好的,show 按理说就是它的非模态兄弟,但我在 Chromium 143 上实测:事件不派发,dialog 纹丝不动,静态页面和动态创建的 dialog 都一样。
这不是 浏览器没实现好,也不是 Bug,而是 W3C 规范压根就没给 <dialog> 定义 show 这个命令。
ini
静态 dialog + show 命令: open=False, 事件=[]
静态 dialog + show-modal: open=True, 事件=['show-modal']
所以非模态打开,目前老实用 JS 的 dialog.show()。
3.2 click 和 command 不是一回事
在按钮和目标元素上都挂好监听,点一下打开模态框按钮:
bash
log: [document·capture] 捕获收到: target=my-dialog, command=show-modal
log: [dialog] 🎯 command 在 dialog 上触发! command=show-modal, source=#open-btn, bubbles=false
- command 事件在 dialog 上触发,不在按钮上 。
click事件的 target 是按钮,command事件的 target 是被控元素。事件模型里这叫不对称,通俗一点来说:click 是「用户按了按钮」,command 是一个命令发给了目标------语义不同,落点就不同。 source指回发起的按钮(#open-btn),所以事件对象里能拿到完整上下文。bubbles=false,不冒泡。
3.3 不冒泡,全局接管就得用捕获
实测挂在 document 上的普通监听器(bubble 阶段)从头到尾一条都没收到,而加了第三个参数 true(capture 阶段)的监听器每条都收到了:
js
// 收不到:command 事件不冒泡
document.addEventListener('command', e => { ... });
// 能收到:捕获阶段经过 document
document.addEventListener('command', e => {
console.log('全局埋点:', e.command, '→', e.target.id);
}, true);
想做一个全局的弹窗埋点、或者集中处理所有自定义命令,监听器要挂在捕获阶段。这个细节文档里不好找,是测试脚本第一次跑崩了才发现的(顺手还发现另一个行为:模态框开着的时候,::backdrop 会拦截页面上所有点击,连测试脚本点 dialog 外面的按钮都点不动------模态的语义,实打实的)。
3.4 自定义命令:-- 前缀是硬性要求
内置命令不够用时,可以发明自己的命令,约定加 -- 前缀:
html
<button commandfor="todo-list" command="--add-item">添加</button>
js
document.getElementById('todo-list').addEventListener('command', (e) => {
if (e.command === '--add-item') {
// e.source 就是那个按钮,上下文齐全
addItem(e.source.dataset.value);
}
});
重点来了:-- 前缀不是代码风格建议,是能不能跑的分界线。我实测了一个不带前缀的自定义命令:
python
T7 自定义命令前缀规则: 无 -- 前缀事件派发=False(❌ 不派发), 有 -- 前缀派发=True(✅)→ -- 是硬性要求不是风格建议
没有 --,浏览器直接不派发事件------静默忽略,连报错都没有。写错了你不会得到任何提示,页面就是「没反应」。排查思路:先查前缀。
3.5 内置命令也能拦
内置命令的默认行为是可以取消的。
js
dialog.addEventListener('command', (e) => {
if (e.command === 'show-modal' && !userHasPermission) {
e.preventDefault(); // 内置的 show-modal 也不会执行了
}
});
结果:preventDefault() 调用后,弹窗没有打开。这意味着你可以统一在 command 事件里做权限校验、埋点、条件拦截,不用去动任何一行按钮代码。声明式交互没有失去控制权------命令的「默认行为」仍然是事件的默认行为。
3.6 边界行为
commandfor指向不存在的 id:无异常、无警告,静默忽略(排查靠眼睛)- 对已关闭的 dialog 发
close:控制台警告,不崩 - 模态框开着时,
::backdrop拦截外部一切点击(这本身就是模态的定义)
四、什么时候还得写 JS
虽说很厉害了,但是也并非完全替代js
- 多弹窗联动、复杂状态机------声明式命令管单点交互,管不了编排
- 要传复杂数据------命令通道传的是语义(干什么),不是 payload;数据还是走你的状态层
- 非模态打开 dialog ------
show命令实测没通,暂时 JS
静态的开关交给 HTML,动态的编排交给 JS。
面试题
Q1:commandfor 触发的 command 事件,在哪个元素上触发?和 click 事件有什么区别?
答:在被控元素(commandfor 指向的目标)上触发,不在按钮上。click 的 target 是按钮本身,语义是「用户手势」;command 的 target 是被控元素,语义是「命令到达」,通过 e.source 反查发起按钮。另外 command 事件实测不冒泡(Chromium 143,bubbles=false)。
Q2:自定义命令为什么必须用 -- 前缀?不写会怎样?
答:规范约定 -- 前缀标识自定义命令,与内置命令空间隔离。实测(Chromium 143)不带 -- 的未知命令根本不会派发事件------静默忽略、无报错,页面表现为「点了没反应」。所以它不是风格建议,是功能开关。
Q3:怎么在「打开模态框」这个动作上做统一的权限校验?
答:在目标 dialog 上监听 command 事件,命中 show-modal 且校验不通过时调用 e.preventDefault(),内置命令的默认行为即被取消,弹窗不会打开。埋点、审计同理------所有入口收敛到一个事件,不用改任何按钮。
总结
sql
Invoker Commands API(Baseline 2026.1)
│
├─ 两个属性
│ ├─ commandfor → 控制谁(目标 id)
│ └─ command → 干什么(命令字符串)
│
├─ 内置命令(Chromium 143 实测)
│ ├─ dialog: show-modal ✅ / close ✅ / request-close ✅ / show ❌
│ └─ popover: toggle-popover / show-popover / hide-popover ✅
│
├─ 事件流(核心)
│ ├─ command 事件 → 在【目标元素】上触发(click 在按钮上)
│ ├─ e.source 反查发起按钮,e.command 拿命令名
│ ├─ 不冒泡 → 全局接管用捕获阶段(addEventListener 第三个参数 true)
│ └─ preventDefault() 可拦截内置命令默认行为
│
└─ 自定义命令
├─ 必须用 -- 前缀(硬性要求,实测无前缀不派发)
└─ 目标上统一监听,e.source 携带上下文
下一篇可以说一下Popover API:弹层这件事,HTML 属性就够。commandfor 和popover 配合着用才是完全体。
gitCode仓库地址:gitcode.com/m0_47553675...