12k
All articles

Управление веб-компонентами без JavaScript

Используйте пользовательские команды invoker, чтобы управлять web components только через HTML. command и commandfor связывают кнопки с элементом без JavaScript.

OpenReplay Team
OpenReplay Team
Управление веб-компонентами без JavaScript

Пользовательские invoker-команды позволяют кастомному элементу декларативно предоставлять свои действия: автор компонента пишет один обработчик события command на JavaScript, а все, кто использует этот компонент, подключают к нему кнопки с помощью HTML-атрибутов command и commandfor — без единой строки скрипта на стороне потребителя.

Поставка кастомного элемента обычно тянет за собой раздел в README: тот самый, где объясняется, какие методы нужно вызывать или какой атрибут data-action навесить на кнопки. Компонент работает; проблема — в обвязке.

Эта статья начинается там, где заканчивается наше руководство по Invoker Commands API. В нём рассматриваются встроенные команды для диалогов и поповеров (show-modal, close, toggle-popover и им подобные) и основы CommandEvent; здесь всё это не повторяется. Invoker-команды имеют статус Baseline Newly available с 12 декабря 2025 года, поэтому раздела про полифилы тоже не будет. Вместо этого мы соберём один готовый к распространению элемент — <code-viewer>, публичным API которого является набор его команд.

Ключевые выводы

  • Имена пользовательских команд должны начинаться с двойного дефиса, например --expand; этот префикс — зарезервированное пространство имён, поэтому пользовательская команда никогда не сможет конфликтовать со встроенной, добавленной браузером позже.
  • Событие command срабатывает непосредственно на элементе, указанном в commandfor, не всплывает и не пересекает границы shadow DOM, поэтому обработчик должен находиться на самом компоненте.
  • Внутри обработчика event.command содержит имя команды, а event.source — кнопку, которая её инициировала; именно здесь место атрибутам aria-pressed и aria-expanded.
  • Компонент не может быть целью commandfor изнутри собственного shadow-дерева, поскольку у хоста там нет id; вместо id свойство commandForElement принимает прямую ссылку на элемент.
  • Спецификация не определяет никаких изменений состояния для пользовательских команд, поэтому обработчик должен сам поддерживать состояние ARIA.

Автор пишет JavaScript, потребитель пишет HTML

Такое разделение труда — вся суть пользовательских invoker-команд. Вы, автор компонента, пишете обработчик command один раз, внутри элемента. Каждый последующий потребитель управляет компонентом из разметки:

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

Никаких импортов, кроме самого компонента, никаких имён методов, которые нужно запоминать, никакой самописной делегации событий. Набор команд становится публичным интерфейсом элемента, документируемым так же, как документируется command="show-modal" для <dialog>.

Почему методы и атрибуты data-* недостаточно хороши?

Оба паттерна, которые сегодня используют авторы компонентов, перекладывают работу на потребителя. Императивный метод вынуждает каждого потребителя писать JavaScript:

document.querySelector('#snippet').expand();
// plus a click listener on every button that should call it

Самодельный атрибут data-action сохраняет декларативность разметки, но заставляет заново реализовывать диспетчеризацию: делегированный обработчик клика, соглашение о разборе атрибута и документацию для словаря, который понимает только ваш компонент. Ни один из подходов не даёт вам ничего от платформы. С command/commandfor вы наследуете семантику настоящей кнопки, включая активацию с клавиатуры, и соглашение об обвязке, общее со всеми остальными управляемыми командами элементами на странице.

Как определить пользовательскую invoker-команду?

Значения пользовательских команд должны начинаться с двойного дефиса, и этот префикс зарезервирован по определению. В состояниях атрибута 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 с явным перечислением случаев и без «всеядного» default. Любое значение с префиксом -- порождает событие независимо от того, обрабатываете вы его или нет, и ничего не выбрасывается, если не обрабатываете. Session replay декларативно связанных компонентов показывает этот сбой как «мёртвую» кнопку: клик проходит, на экране ничего не меняется, ошибок в консоли нет — именно так со стороны пользователя выглядит опечатка в значении команды или обработчик, повешенный не на тот узел.

Shadow DOM: обращение к хосту через commandForElement

Компонент не может быть целью commandfor изнутри собственного shadow-дерева. Атрибут commandfor разрешает только те id, которые находятся в том же дереве, что и кнопка, а у хоста, расположенного снаружи в light DOM, нет id внутри его же shadow root. Эту брешь закрывает свойство commandForElement: вместо id оно принимает прямую ссылку на элемент, в том числе через границы shadow root. Так внутренняя кнопка может использовать тот же обработчик команд:

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 DOM, и event.target всегда является элементом, который его получил; никакой акробатики с composedPath() не требуется. А event.source подвергается ретаргетингу относительно дерева обработчика: для внутренней кнопки обработчик на хосте увидит хост, а не кнопку, — поэтому храните прямые ссылки на внутренние кнопки, если вам нужно их обновлять.

Состояние и ARIA — ваша забота

Спецификация не определяет никаких изменений состояния для пользовательских значений команд; единственное поведение браузера — отправка события. Никто не выставит за вас aria-pressed или aria-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. Старая пара умеет только показывать, скрывать или переключать поповеры, тогда как command и commandfor также управляют диалогами с помощью значений вроде show-modal и close и отправляют пользовательские команды с двойным дефисом любому элементу. Новые атрибуты поддерживают всё, что умели старые, поэтому в новом коде следует предпочитать command и commandfor.

Работают ли command и commandfor на элементах, отличных от button?

Нет. Спецификация HTML определяет command и commandfor только для элемента button, а соответствующие IDL-свойства, command и commandForElement, находятся на HTMLButtonElement. Ссылки, поля ввода и другие элементы не могут выступать в роли invoker'ов. Кастомный элемент, оборачивающий нативную кнопку, тоже не получает поведение invoker'а автоматически: внутренняя нативная кнопка должна сама нести эти атрибуты.

Отправляет ли кнопка с command свою родительскую форму?

Нет, и это палка о двух концах. Кнопка с command или commandfor без явно указанного type не является кнопкой отправки, поэтому форму она не отправит. Но и команду она не выполнит: при наличии владельца-формы type находится в состоянии Auto, и активация завершается до срабатывания команды, так что кнопка не делает вообще ничего. Указывайте type='button' на invoker-кнопках внутри формы. Спецификация помечает это ограничение как меру совместимости, которую планируется снять.

Можно ли обрабатывать события command делегированным обработчиком на предке?

Только в фазе перехвата. Событие command не всплывает, поэтому обычный делегированный обработчик на предке никогда не сработает. Обработчики фазы перехвата выполняются по пути вниз к цели, поэтому addEventListener('command', handler, { capture: true }) на контейнере перехватит события command, отправленные его потомкам в том же дереве. Событие никогда не пересекает границы shadow DOM, поэтому делегирование останавливается на 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.