12k
All articles

无需 JavaScript 即可控制 Web 组件

使用自定义 invoker commands 仅靠 HTML 控制 Web 组件。了解 command 和 commandfor 如何在无需 JavaScript 的情况下连接按钮与自定义元素。

OpenReplay Team
OpenReplay Team
无需 JavaScript 即可控制 Web 组件

自定义 invoker command(调用器命令)让自定义元素能够以声明式的方式暴露自身的行为:组件作者只需在 JavaScript 中编写一个 command 事件监听器,之后所有使用该组件的人都可以通过 commandcommandfor 这两个 HTML 属性把按钮接入其中,使用方完全不需要写任何脚本。

发布一个自定义元素时,往往还得附带一段 README:说明该调用哪些方法,或者该在按钮上撒上哪些 data-action 属性。组件本身能用;真正的摩擦在于接线。

本文从我们的 Invoker Commands API 指南 结束的地方开始。那篇文章介绍了用于 dialog 和 popover 的内置命令(show-modalclosetoggle-popover 等)以及 CommandEvent 的基础知识;这些内容本文不再重复。Invoker command 自 2025 年 12 月 12 日起已进入 Baseline Newly available,因此也不需要 polyfill 章节。取而代之的是,我们将构建一个可分发的元素 <code-viewer>,它的公共 API 就是它的命令集。

关键要点

  • 自定义命令名必须以双短横线开头,例如 --expand;这个前缀属于保留命名空间,因此自定义命令永远不会与浏览器日后新增的内置命令冲突。
  • command 事件直接在 commandfor 所指向的元素上触发,不会冒泡,也不会跨越 shadow 边界,因此监听器应当挂在组件自身上。
  • 在处理函数内部,event.command 携带命令名,event.source 是触发该命令的按钮,aria-pressedaria-expanded 也应该设置在它上面。
  • 组件无法从自己的 shadow tree 内部通过 commandfor 被定位到,因为宿主元素在那里没有 id;此时应改用 commandForElement 属性,它接受直接的元素引用。
  • 规范没有为自定义命令定义任何状态变更,因此处理函数必须自行维护 ARIA 状态。

作者写 JavaScript,使用方写 HTML

这种分工正是自定义 invoker command 的核心思想。你作为组件作者,只需在元素内部编写一次 command 监听器。之后每一位使用方都可以通过标记来驱动组件:

<button command="--expand" commandfor="snippet">Expand</button>

除了组件本身之外不需要任何 import,不需要记住方法名,也不需要手写事件委托。命令集成为该元素的公共接口,其文档形式与 <dialog>command="show-modal" 完全一致。

为什么方法和 data-* 属性不够用?

如今组件作者常用的两种模式都把工作推给了使用方。命令式方法会强迫每一位使用方去写 JavaScript:

document.querySelector('#snippet').expand();
// 还要为每个需要调用它的按钮加上 click 监听器

自定义的 data-action 属性虽然让标记保持声明式,但你得自己重新实现分发机制:一个委托的 click 监听器、一套属性解析约定,以及一份只有你的组件才懂的词汇表文档。这两种做法都无法从平台本身获得任何助力。而使用 command/commandfor,你可以直接继承真正的 button 语义(包括键盘激活),并且与页面上所有其他命令驱动的元素共享同一套接线约定。

如何定义自定义 invoker command?

自定义命令值必须以双短横线开头,而这个前缀在定义上就是保留的。在 command 属性的状态定义 中,任何以 -- 开头的值都被归类为自定义关键字,这意味着内置命令永远不会采用这种形式,因此你的命令不会与浏览器日后新增的任何命令冲突。既不是内置关键字、也没有 -- 前缀的值是无效的,不会派发任何事件。

我们的 <code-viewer> 暴露三个命令:--expand--toggle-wrap--copy。它的文档宣传的正是这份清单,而不是一组方法。

命令作用需要在按钮上更新的 ARIA 状态
--expand切换元素上的 expanded 属性aria-expanded
--toggle-wrap切换元素上的 wrap 属性aria-pressed
--copy将元素的文本内容写入剪贴板

command 事件在哪里触发?

command 事件在 commandfor 所指向的目标元素上触发,而不是在按钮上,并且它不会冒泡,因此挂在祖先元素上的冒泡阶段委托监听器捕捉不到它。请把监听器挂到接收命令的那个元素上;CommandEvent 接口 在基础事件之外为处理函数提供了两个属性:event.command 保存命令名,event.source 指回发起调用的按钮。

class CodeViewer extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<pre><slot></slot></pre>`;
    this.addEventListener('command', this.#onCommand);
  }

  #onCommand = (event) => {
    switch (event.command) {
      case '--expand':
        // state change goes here
        break;
      case '--toggle-wrap':
        break;
      case '--copy':
        navigator.clipboard.writeText(this.textContent);
        break;
    }
  };
}

基于 event.command 进行分发,使用明确列出的 case,且不要写宽松的 default。任何带 -- 前缀的值都会派发事件,无论你是否处理它;而当你不处理时,也不会抛出任何错误。对声明式接线组件的会话回放显示,这种失效模式表现为一个「死按钮」:点击生效了,屏幕上却毫无变化,控制台也没有报错——从用户角度看,命令值拼写错误或监听器挂在了错误的节点上,正是这个样子。

Shadow DOM:用 commandForElement 定位宿主元素

组件无法从自己的 shadow tree 内部通过 commandfor 被定位到。commandfor 属性 只能解析位于按钮自身所在树中的 id,而宿主元素身处 light DOM,在它自己的 shadow root 内部并没有 id。commandForElement 属性 填补了这一空缺:它接受直接的元素引用(可跨 shadow root),而不是 id。因此内部按钮可以走同一个命令处理函数:

connectedCallback() {
  const copyBtn = document.createElement('button');
  copyBtn.textContent = 'Copy';
  copyBtn.setAttribute('command', '--copy');
  copyBtn.commandForElement = this; // no id needed
  this.shadowRoot.append(copyBtn);
}

这里有两个关于事件传播的事实很重要。该事件直接在目标上触发,既未设置 bubbles 也未设置 composed,因此它永远不会跨越 shadow 边界,而 event.target 始终是接收它的那个元素;无需在 composedPath() 上做任何腾挪。此外,event.source 会相对于监听器所在的树进行重定向(retarget):对于那个内部按钮,挂在宿主上的监听器看到的是宿主本身,而不是按钮,所以如果你需要更新内部按钮,请保留对它们的直接引用。

状态与 ARIA 由你负责

规范没有为自定义命令值定义任何状态变更;浏览器唯一的行为就是派发事件。没有任何机制会替你设置 aria-pressedaria-expanded,因此请在改变状态的同一个分支里,在 event.source 上更新它们:

case '--expand': {
  const expanded = this.toggleAttribute('expanded');
  event.source.setAttribute('aria-expanded', expanded);
  break;
}
case '--toggle-wrap': {
  const wrapped = this.toggleAttribute('wrap');
  event.source.setAttribute('aria-pressed', wrapped);
  break;
}

对于使用方位于 light DOM 中的按钮,这样做是安全的:按钮与监听器共享同一棵树,因此 event.source 就是那个真实的按钮。

成品元素以及驱动它的 HTML

组装完成后,该组件就是一个类加一个监听器:

class CodeViewer extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<pre><slot></slot></pre>`;
    this.addEventListener('command', this.#onCommand);
  }

  #onCommand = (event) => {
    switch (event.command) {
      case '--expand': {
        const expanded = this.toggleAttribute('expanded');
        event.source.setAttribute('aria-expanded', expanded);
        break;
      }
      case '--toggle-wrap': {
        const wrapped = this.toggleAttribute('wrap');
        event.source.setAttribute('aria-pressed', wrapped);
        break;
      }
      case '--copy':
        navigator.clipboard.writeText(this.textContent);
        break;
    }
  };
}
customElements.define('code-viewer', CodeViewer);

而这就是使用方需要写的全部内容:

<code-viewer id="snippet">const answer = 42;</code-viewer>

<button command="--expand" commandfor="snippet" aria-expanded="false">Expand</button>
<button command="--toggle-wrap" commandfor="snippet" aria-pressed="false">Wrap</button>
<button command="--copy" commandfor="snippet">Copy</button>

使用方零 JavaScript。这些按钮可以位于文档中的任何位置、任意顺序,并且可以随意添加或移除。

结论

相比方法接口,命令集是一种更小、更耐久的公共 API:它可以在标记中被直接查看,默认支持键盘访问,并且带有命名空间,平台永远不会将其破坏。挑一个你目前通过方法或 data-* 约定驱动的组件,把它的行为迁移到以 -- 为前缀的命令背后,让下一位使用方无需打开 script 标签就能把它接好。

常见问题

commandfor 和 popovertarget 有什么区别?

command 和 commandfor 属性取代并泛化了 popovertarget 与 popovertargetaction。旧的这一对属性只能显示、隐藏或切换 popover,而 command 和 commandfor 还能通过 show-modal、close 等值驱动 dialog,并向任意元素派发以双短横线开头的自定义命令。新属性支持旧属性的全部能力,因此新代码应优先使用 command 和 commandfor。

command 和 commandfor 能用在 button 之外的元素上吗?

不能。HTML 规范只在 button 元素上定义了 command 和 commandfor,对应的 IDL 属性 command 与 commandForElement 也位于 HTMLButtonElement 上。链接、input 及其他元素无法充当调用器。包装原生 button 的自定义元素同样不会自动获得调用器行为:内部的原生 button 必须自己携带这些属性。

带 command 的按钮会提交它的父表单吗?

不会,但这把双刃剑两面都要注意。带有 command 或 commandfor 且未显式设置 type 的按钮不是提交按钮,因此它不会提交表单。但它也不会执行命令:当存在 form owner 时,type 处于 Auto 状态,激活流程会在命令触发之前返回,于是这个按钮什么都不做。请为表单内的调用器按钮设置 type='button'。规范将这一限制标注为兼容性措施,并计划在未来取消。

我能用挂在祖先元素上的委托监听器处理 command 事件吗?

只能在捕获阶段。command 事件不会冒泡,因此挂在祖先上的普通委托监听器永远不会触发。捕获阶段的监听器会在事件下行到目标的过程中执行,所以在容器上调用 addEventListener('command', handler, { capture: true }) 可以捕获派发给同一棵树中其后代元素的 command 事件。该事件永远不会跨越 shadow 边界,因此委托到 shadow root 处即终止。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.