Управление веб-компонентами без JavaScript
Используйте пользовательские команды invoker, чтобы управлять web components только через HTML. command и commandfor связывают кнопки с элементом без 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.