Shadow DOM и события
Как события ведут себя в Shadow DOM: всплытие через границу тени, ретаргетинг, event.composedPath(), event.composed и пользовательские события.
Веб-компонент, построенный на Shadow DOM, скрывает свою внутреннюю структуру за теневой границей. Эта инкапсуляция меняет порядок распространения событий: одни события пересекают границу, другие нет, а те, что пересекают, ретаргетируются — внешний мир никогда не увидит ваши приватные узлы. В этой главе объясняются эти правила, чтобы ваши компоненты генерировали события, которые страница-хост действительно может использовать.
Вы рассмотрите четыре темы: как события всплывают через теневую границу, ретаргетинг событий, event.composedPath(), флаг event.composed и отправку пользовательских событий, которые выходят за пределы теневого дерева.
В этой главе предполагается, что вы уже знаете основы Shadow DOM и общие принципы всплытия и захвата событий. Если пользовательские события для вас в новинку, сначала прочитайте раздел Отправка пользовательских событий.
Всплытие событий в Shadow DOM
Всплытие событий описывает, как событие распространяется вверх по дереву DOM: оно срабатывает на целевом элементе, затем на каждом предке по очереди, пока не достигнет document. (Полное описание см. в Всплытие и захват.)
Применительно к Shadow DOM возникает вопрос: продолжает ли событие всплывать, когда достигает теневого корня, и выходит ли оно в светлый DOM хоста? Это зависит от того, является ли событие составным (composed):
- Составные события пересекают теневую границу и продолжают всплывать в светлый DOM. Большинство пользовательских нативных событий являются составными:
click,mousedown,keydown,input,pointermoveи другие. - Несоставные события останавливаются на теневом корне и никогда не достигают хоста. Примеры:
focus(используйтеfocusin/focusout, если нужны составные события фокуса),scroll,mouseenterиload.
Чтобы остановить распространение события в любой точке — независимо от того, является ли оно составным — вызовите event.stopPropagation().
Ретаргетинг событий
Этот аспект часто удивляет разработчиков. Когда составное событие пересекает границу, браузер ретаргетирует его: для слушателей в светлом DOM event.target указывает на элемент-хост, а не на внутренний элемент, на который вы на самом деле кликнули.
Это сделано намеренно. Инкапсуляция потеряла бы смысл, если бы внешний код мог читать приватные узлы компонента из event.target. Поэтому страница-хост видит «что-то внутри <my-widget> было нажато», а не «была нажата третья <button> в теневом дереве». Внутри самого теневого дерева event.target по-прежнему указывает на реальный элемент.
Если вам нужен настоящий путь через теневое дерево, используйте event.composedPath() — об этом рассказано ниже.
Использование event.composedPath()
Поскольку ретаргетинг скрывает внутренний элемент от event.target, нужен другой способ проверить реальный путь распространения. event.composedPath() возвращает array узлов, через которые прошло событие, включая узлы внутри любых теневых деревьев, которые оно пересекло, в порядке от самого внутреннего целевого элемента к window.
Это надёжный способ ответить на вопрос «на какой внутренний элемент на самом деле кликнули?» из слушателя в светлом DOM — но только для компонентов, у которых теневой корень имеет режим mode: 'open'. Для корня с mode: 'closed' метод composedPath() останавливается на хосте, а внутренние узлы опускаются, сохраняя конфиденциальность закрытого компонента.
Рассмотрим, как event.composedPath() можно использовать для отслеживания распространения события в Shadow DOM:
<div id="outer"></div>
<script>
const outer = document.getElementById('outer');
const shadow = outer.attachShadow({ mode: 'open' });
const inner = document.createElement('div');
inner.textContent = 'Click me';
inner.addEventListener('click', event => {
const composedInfo = document.createElement('p');
composedInfo.textContent = 'The event composedPath contains the following elements:';
shadow.appendChild(composedInfo);
const path = event.composedPath();
path.forEach((e) => {
const pathItem = document.createElement('p');
pathItem.textContent = e.tagName;
shadow.appendChild(pathItem);
});
});
shadow.appendChild(inner);
</script>При клике на внутренний <div> выводится список всех узлов в составном пути: он начинается с DIV, на который кликнули, затем идёт DIV (хост #outer), затем BODY, HTML и, наконец, записи для document и window (которые выводятся как undefined, поскольку у них нет tagName). Первые несколько записей — это именно то, что event.target скрывает от слушателей в светлом DOM.
Понимание event.composed
Свойство event.composed, доступное только для чтения, является boolean-значением: true, если событие может пересекать теневые границы, false, если оно ограничено своим теневым деревом. Изменить его после создания события нельзя — для нативных событий это значение зафиксировано спецификацией, а для пользовательских событий вы задаёте его при создании через опцию composed.
Этот флаг особенно важен при создании компонента: нужно решить, должны ли ваши пользовательские события выходить наружу. Нативные события взаимодействия, такие как click, по умолчанию являются составными; ваши собственные CustomEvent-ы не являются составными, если вы явно не укажете это.
Рассмотрим, как event.composed может применяться на практике:
<div id="outer"></div>
<script>
const outer = document.getElementById('outer');
const shadow = outer.attachShadow({ mode: 'open' });
const button = document.createElement('button');
button.textContent = 'Click me';
button.addEventListener('click', event => {
const composedInfo = document.createElement('p');
composedInfo.textContent = `Composed: ${event.composed}`;
shadow.appendChild(composedInfo);
});
shadow.appendChild(button);
</script>В этом примере клик по кнопке внутри Shadow DOM вызывает событие click. Мы динамически создаём элемент <p>, чтобы отобразить свойство event.composed внутри Shadow DOM.
Пользовательские события в Shadow DOM
Пользовательские события позволяют компоненту сообщать что-то внешнему миру — «значение изменилось», «элемент выбран», «диалог закрыт» — не раскрывая своих внутренних деталей. Это стандартный способ, с помощью которого веб-компонент взаимодействует со страницей, которая его использует. (Подробнее об API см. в разделе Отправка пользовательских событий.)
Чтобы пользовательское событие достигло слушателя на элементе-хосте в светлом DOM, необходимо установить две опции:
composed: true— позволяет событию пересечь теневую границу.bubbles: true— позволяет ему всплывать вверх по дереву, достигая слушателей у предков.
Если установить только bubbles, событие будет всплывать внутри теневого дерева, но остановится на теневом корне. Если установить только composed, оно пересечёт границу, но не будет подниматься к предкам. Как правило, нужны обе опции.
Создадим и отправим пользовательское событие внутри Shadow DOM:
<div id="container"></div>
<script>
const container = document.getElementById('container');
const shadow = container.attachShadow({ mode: 'open' });
const button = document.createElement('button');
button.textContent = 'Click me';
button.addEventListener('click', () => {
const event = new CustomEvent('customEvent', { bubbles: true, composed: true });
button.dispatchEvent(event);
});
shadow.appendChild(button);
container.addEventListener('customEvent', () => {
const composedInfo = document.createElement('p');
composedInfo.textContent = `Custom Event Triggered!`;
container.appendChild(composedInfo);
});
</script>Клик по кнопке отправляет customEvent с bubbles: true и composed: true, поэтому событие пересекает теневую границу и всплывает к слушателю на хосте (container) в светлом DOM. Чтобы передать данные вместе с событием, используйте свойство detail:
button.dispatchEvent(new CustomEvent('customEvent', {
bubbles: true,
composed: true,
detail: { value: 42 }
}));
container.addEventListener('customEvent', (event) => {
console.log(event.detail.value); // 42
});Обратите внимание: даже если событие достигает хоста, ретаргетинг всё равно применяется: в слушателе container свойство event.target указывает на элемент-хост, а не на внутреннюю button. Используйте event.composedPath()[0], если вам нужен исходный целевой элемент.
Краткий справочник
| Свойство / метод | Что сообщает |
|---|---|
event.composed | true, если событие может пересекать теневые границы (только для чтения). |
event.composedPath() | Array узлов, через которые проходит событие, включая открытые теневые деревья, начиная с самого внутреннего. |
event.target (из светлого DOM) | Элемент-хост из-за ретаргетинга — никогда не приватный внутренний узел. |
Опция bubbles | Позволяет пользовательскому событию всплывать вверх по дереву. |
Опция composed | Позволяет пользовательскому событию покидать теневое дерево. |
Распространённые ошибки
- Забыть
composed: trueв пользовательских событиях. Пользовательское событие только сbubblesтихо останавливается на теневом корне и никогда не достигает страницы-хоста — частая причина ошибки «мой слушатель не срабатывает». - Чтение
event.targetизвне. Оно ретаргетируется на хост. Используйтеevent.composedPath(), когда вам нужен реальный внутренний целевой элемент. focusне является составным. Используйтеfocusin/focusout, если нужно, чтобы изменения фокуса достигали хоста.- Закрытые теневые корни.
composedPath()не раскрывает узлы внутри корня сmode: 'closed', поэтому не полагайтесь на него для проверки закрытых компонентов.
Связанные главы
- JavaScript Shadow DOM — что такое теневое дерево и как его подключить.
- Слоты и композиция Shadow DOM — проецирование светлого DOM в теневое дерево.
- Стилизация Shadow DOM — изолированные стили внутри компонента.
- Пользовательские элементы — определение собственных HTML-элементов.
Заключение
События в Shadow DOM подчиняются нескольким чётким правилам: составные события пересекают границу, несоставные — нет, а составные события ретаргетируются на хост, чтобы внутренние детали оставались скрытыми. Используйте event.composed, чтобы проверить возможность пересечения границы, event.composedPath() — чтобы получить реальный путь, и CustomEvent с bubbles: true и composed: true, чтобы компоненты могли общаться со страницей, которая их использует.