借助 Invoker Commands API,按钮可以使用 HTML 属性以声明方式触发目标元素上的操作。
这种方法减少了对手动事件监听器的需求,并将界面与实现细节分离开来。
对于应用专属的自定义操作,您可以自行定义命令名称。自定义命令必须以双短划线 (--) 为前缀,以免与未来的内置浏览器命令发生冲突。
实现步骤
- 定义目标元素:确定将响应操作的元素。如果该商品没有唯一的
id,请添加一个。 - 配置调用方按钮:使用
commandfor属性指向目标的id,并使用command属性指定自定义命令名称(以--为前缀)。 - 处理命令事件:直接在目标元素上附加
command事件监听器。事件对象包含command属性和target属性(引用由commandfor标识的元素)。 - 处理 aria 状态:自定义命令没有固有语义,您必须处理
aria-pressed或aria-expanded等状态。
示例:自定义动画控件
<!-- The target element that will respond to custom commands -->
<div id="action-target" class="target">
Action Target
</div>
<!-- Buttons declaratively linked to the target element -->
<!-- Each button sends a unique custom command starting with '--' -->
<button commandfor="action-target" command="--spin">
Spin
</button>
<button commandfor="action-target" command="--grow">
Grow
</button>
<button commandfor="action-target" command="--reset">
Reset All
</button>
<script>
// 1. **Optional:** Define a registry of requested actions for cleaner logic
const commandRegistry = {
'--spin': (target, source) => {
const isSpun = target.classList.toggle('is-spun');
// Set ARIA states, as custom commands have no inherent semantics.
source?.setAttribute('aria-pressed', isSpun);
},
'--grow': (target, source) => {
const isGrown = target.classList.toggle('is-grown');
source?.setAttribute('aria-pressed', isGrown);
},
'--reset': (target) => {
target.classList.remove('is-spun', 'is-grown');
// Reset all associated buttons' ARIA states
document.querySelectorAll(`button[commandfor="${target.id}"]`).forEach(btn => {
btn.setAttribute('aria-pressed', 'false');
});
},
};
// 2. **Mandatory:** Listen for the 'command' event directly on the target element
// (This is necessary because the native 'command' event does not bubble)
document.getElementById('action-target').addEventListener('command', (event) => {
const command = event.command;
const target = event.target;
const source = event.source; // event.source refers to the triggering button
const action = commandRegistry[command];
if (action) {
action(target, source);
}
});
</script>
主要限制
- 为自定义命令添加前缀:强制性要求:所有自定义命令名称都必须以
--开头(例如command="--my-action")。 - 定位:
commandfor属性必须与同一文档树中元素的id匹配。 - 无冒泡:
command事件不会冒泡。如果可能有多个目标,请向事件处理脚本添加{ capture: true },并监听祖先。 - 影子根:如果目标可能位于影子根中,请使用
event.composedPath()[0],而不是event.target。 - 无障碍功能:自定义命令没有固有语义,您必须明确应用任何状态。
无障碍注意事项
- 如果按钮显示额外内容,请使用
aria-expanded属性,默认设置为aria-expanded="false",在显示随播内容时设置为aria-expanded="true" - 如果按钮本身可促成互动(例如,可播放和暂停的切换按钮,可静音和取消静音的音量按钮),请使用
aria-pressed属性,在处于非活动状态时设置为aria-pressed="false",在处于活动状态时设置为aria-pressed="true" - 如果按钮打开的元素具有
menu、listbox、tree、grid或dialog角色,则该控件必须具有aria-haspopup属性,且其值设置为所打开元素的角色 - 如果按钮在激活时被停用,请使用
aria-disabled="true"而不是disabled,这样就不会失去焦点
后备策略
如果不支持 Invoker Commands API,则不会触发 command 事件。为了在所有现代浏览器中获得全面支持,建议通过 npm install 或 CDN 使用 https://github.com/keithamus/invokers-polyfill 中的 invokers-polyfill。
此 polyfill 完全支持自定义操作(以 -- 开头),并会像原生 API 一样分派 command 事件。
动态导入(性能优化)
为获得最佳性能,您应仅在浏览器本身不支持该 API 时加载 polyfill。这样可以节省带宽,并缩短现代浏览器用户的脚本执行时间。
注意:此 polyfill 不会管理自定义命令的 ARIA 状态(例如 aria-pressed 或 aria-expanded)。您必须在事件监听器中手动同步这些状态,以确保您的网站可供访问。
// 1. Conditionally load the polyfill
const hasNativeSupport = 'commandForElement' in HTMLButtonElement.prototype;
if (!hasNativeSupport) {
// Wrap in an async IIFE to avoid top-level await issues in older browsers
(async () => {
try {
await import('https://esm.run/invokers-polyfill');
} catch (err) {
console.error('Error loading fallback:', err);
}
})();
}
// 2. Manually manage ARIA states in your listener
document.getElementById('action-target').addEventListener('command', (event) => {
const command = event.command;
const target = event.target;
const source = event.source; // The button that triggered the command
if (command === '--spin') {
const isSpun = target.classList.toggle('is-spun');
// Polyfill tip: Manually update ARIA to match the new state
source?.setAttribute('aria-pressed', isSpun);
}
});
手动回退(传统模式)
如果您不想使用 polyfill,可以结合使用事件委托来分派事件,并使用命令注册表来处理操作。这是传统 JavaScript 开发中的一种常见架构模式,至今仍具有很高的效率和可扩展性。
// 1. **Optional:** Define a registry of requested actions for cleaner logic
const commandRegistry = {
'--spin': (target) => target.classList.toggle('is-spun'),
'--grow': (target) => target.classList.toggle('is-grown'),
'--reset': (target) => target.classList.remove('is-spun', 'is-grown'),
};
// 2. If CommandEvent doesn't exist, we assume no native support and provide the fallback
if (!globalThis.CommandEvent) {
globalThis.CommandEvent = class CommandEvent extends Event {
constructor(type, { source, command, ...options } = {}) {
super(type, options);
this.source = source;
this.command = command;
}
}
}
// 3. The fallback: Dispatch events manually if native support is missing
document.addEventListener('click', (event) => {
const button = event.composedPath().find((el) => el.matches?.("button[commandfor]"));
if (!button) return;
const target = document.getElementById(button.getAttribute('commandfor'));
const command = button.getAttribute('command');
if (target && command) {
target.dispatchEvent(new CommandEvent('command', {
command,
source: button,
}));
}
});
// 4. **Mandatory:** Register the unified listener directly on the target element
document.getElementById('action-target').addEventListener('command', (event) => {
const command = event.command;
const target = event.target;
const action = commandRegistry[command];
if (action) {
action(target);
}
});