Смысл создания теневого DOM-дерева – это инкапсуляция внутренних деталей компонента.
Допустим, клик произошёл внутри теневого DOM на компоненте <user-card>. Но скрипты основного документа ничего не знают о внутреннем устройстве теневой DOM-структуры, в особенности, если компонент создан сторонней библиотекой.
Поэтому, чтобы не нарушать инкапсуляцию, браузер меняет у этого события целевой элемент.
События, которые произошли в теневом DOM, но пойманы снаружи этого DOM, имеют элемент-хозяин в качестве целевого элемента event.target.
Рассмотрим простой пример:
<user-card></user-card>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<p>
<button>Нажми меня</button>
</p>`;
this.shadowRoot.firstElementChild.onclick =
e => alert("Внутренний целевой элемент: " + e.target.tagName);
}
});
document.onclick =
e => alert("Внешний целевой элемент: " + e.target.tagName);
</script>
Если нажать на кнопку, то выведется следующее:
- Внутренний целевой элемент:
BUTTON– внутренний обработчик событий получает правильный целевой элемент – элемент, находящийся внутри теневого DOM. - Внешний целевой элемент:
USER-CARD– обработчик событий на уровне документа получает элемент-хозяин в качестве целевого.
Хорошо, что браузер подменяет целевые элементы событий. Потому что внешний документ ничего не знает о внутреннем устройстве компонента. С его (внешнего документа) точки зрения, событие происходит на <user-card>.
Подмена целевого элемента не происходит, если событие берёт начало на элементе из слота, который фактически находится в обычном, светлом DOM.
Например, если пользователь кликнет на <span slot="username"> в примере ниже – целевой элемент события будет именно этот span для обоих обработчиков – теневого и обычного (светлого):
<user-card id="userCard">
<span slot="username">John Smith</span>
</user-card>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div>
<b>Имя:</b> <slot name="username"></slot>
</div>`;
this.shadowRoot.firstElementChild.onclick =
e => alert("Внутренний целевой элемент: " + e.target.tagName);
}
});
userCard.onclick = e => alert(`Внешний целевой элемент: ${e.target.tagName}`);
</script>
Если клик произойдёт на "John Smith", то для обоих обработчиков – внутреннего и внешнего – целевым элементом будет <span slot="username">. Это элемент обычного (светлого) DOM, так что подмены не происходит.
С другой стороны, если клик произойдёт на элементе, который находится в теневом DOM, например, на <b>Имя</b>, то как только всплытие выйдет за пределы теневой DOM-структуры, его event.target станет <user-card>.
Всплытие и метод event.composedPath()
Для обеспечения всплытия событий используется развёрнутый DOM.
Таким образом, если у нас есть элемент в слоте, и событие происходит где-то внутри него, то оно всплывает до <slot> и выше.
Полный путь к изначальному целевому элементу, со всеми теневыми элементами, можно получить, воспользовавшись методом event.composedPath(). Как видно из названия, этот метод возвращает путь после композиции.
В примере выше развёрнутое DOM-дерево будет таким:
<user-card id="userCard">
#shadow-root
<div>
<b>Имя:</b>
<slot name="username">
<span slot="username">John Smith</span>
</slot>
</div>
</user-card>
Так что, при клике по <span slot="username"> вызов метода event.composedPath() вернёт массив: [span, slot, div, shadow-root, user-card, body, html, document, window]. Что в точности отражает цепочку родителей от целевого элемента в развёрнутой DOM-структуре после композиции.
{mode:'open'}Если теневое DOM-дерево было создано с {mode: 'closed'}, то после композиции путь будет начинаться с элемента-хозяина: user-card и дальше вверх по дереву.
Этот метод следует тем же принципам, что и остальные. Внутреннее устройство закрытых DOM-деревьев совершенно скрыто.
Свойство: event.composed
Большинство событий успешно всплывают сквозь границу теневого DOM. Но не все.
Это поведение регулируется с помощью свойства composed объекта события. Если оно true, то событие пересекает границу. Иначе, оно может быть поймано лишь внутри теневого DOM.
Если посмотреть в спецификацию UI Events, то большинство событий имеют composed: true:
blur,focus,focusin,focusout,click,dblclick,mousedown,mouseupmousemove,mouseout,mouseover,wheel,beforeinput,input,keydown,keyup.
Все события курсора и сенсорные события также имеют composed: true.
Хотя есть и события, имеющие composed: false:
mouseenter,mouseleave(они вообще не всплывают),load,unload,abort,error,select,slotchange.
Эти события могут быть пойманы только на элементах того же DOM, в котором находится целевой элемент события.
Генерация событий
Когда мы генерируем своё событие, то, чтобы оно всплывало за пределы компонента, нужно установить оба свойства: bubbles и composed – в значение true.
Например, здесь мы создаём элемент div#inner в теневом DOM-дереве элемента div#outer и генерируем на нём два события. Только одно с флагом composed: true выйдет наружу, в документ:
<div id="outer"></div>
<script>
outer.attachShadow({mode: 'open'});
let inner = document.createElement('div');
outer.shadowRoot.append(inner);
/*
div(id=outer)
#shadow-dom
div(id=inner)
*/
document.addEventListener('test', event => alert(event.detail));
inner.dispatchEvent(new CustomEvent('test', {
bubbles: true,
composed: true,
detail: "composed"
}));
inner.dispatchEvent(new CustomEvent('test', {
bubbles: true,
composed: false,
detail: "not composed"
}));
</script>
Итого
Только те события пересекают границы теневого DOM, у которых флаг composed установлен в значение true.
У большинства встроенных событий стоит composed: true, это описано в соответствующих спецификациях:
- UI Events https://www.w3.org/TR/uievents.
- Touch Events https://w3c.github.io/touch-events.
- Pointer Events https://www.w3.org/TR/pointerevents.
- …И так далее.
У некоторых встроенных событий всё же стоит composed: false:
mouseenter,mouseleave(вообще не всплывают),load,unload,abort,error,select,slotchange.
Эти события могут быть пойманы только на элементах, принадлежащих тому же DOM-дереву.
Если мы генерируем своё событие CustomEvent, то должны явно поставить флаг composed: true.
Обратите внимание, что в случае вложенных компонентов теневые DOM могут быть вложены друг в друга. События с флагом composed всплывают через границы всех теневых DOM. Поэтому, если событие предназначено только для ближайшего внешнего компонента-родителя, мы можем инициировать его на элементе-хозяине и установить флаг composed: false. Тогда оно будет уже вне теневого DOM компонента, но не выплывает наружу в «ещё более внешний» DOM.
Комментарии
<code>, для нескольких строк кода — тег<pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)