Многим типам компонентов, таким как вкладки, меню, галереи изображений и другие, нужно какое-то содержимое для отображения.
Так же, как встроенный в браузер <select> ожидает получить контент пунктов <option>, компонент <custom-tabs> может ожидать, что будет передано фактическое содержимое вкладок, а <custom-menu> – пунктов меню.
Код, использующий меню <custom-menu>, может выглядеть так:
<custom-menu>
<title>Сладости</title>
<item>Леденцы</item>
<item>Фруктовые тосты</item>
<item>Кексы</item>
</custom-menu>
…Затем компонент должен правильно его отобразить – как обычное меню с заданным названием и пунктами, обрабатывать события меню и т.д.
Как это реализовать?
Можно попробовать проанализировать содержимое элемента и динамически скопировать и переставить DOM-узлы. Это возможно, но если мы будем перемещать элементы в теневой DOM, CSS-стили документа не будут применяться, и мы потеряем визуальное оформление. Кроме того, нужно будет писать дополнительный код.
К счастью, нам этого делать не нужно. Теневой DOM поддерживает элементы <slot>, которые автоматически наполняются контентом из обычного, «светлого» DOM-дерева.
Именованные слоты
Давайте рассмотрим работу слотов на простом примере.
Теневой DOM <user-card> имеет два слота, заполняемых из обычного DOM:
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<div>Имя:
<slot name="username"></slot>
</div>
<div>Дата рождения:
<slot name="birthday"></slot>
</div>
`;
}
});
</script>
<user-card>
<span slot="username">Иван Иванов</span>
<span slot="birthday">01.01.2001</span>
</user-card>
В теневом DOM <slot name="X"> определяет «точку вставки» – место, где отображаются элементы с slot="X".
Затем браузер выполняет «композицию»: берёт элементы из обычного DOM-дерева и отображает их в соответствующих слотах теневого DOM-дерева. В результате мы получаем именно то, что хотели – компонент, который можно наполнить данными.
После выполнения скрипта структура DOM выглядит следующим образом (без учёта композиции):
<user-card>
#shadow-root
<div>Имя:
<slot name="username"></slot>
</div>
<div>Дата рождения:
<slot name="birthday"></slot>
</div>
<span slot="username">Иван Иванов</span>
<span slot="birthday">01.01.2001</span>
</user-card>
Мы создали теневой DOM, он изображён под #shadow-root. Теперь у элемента есть два DOM-дерева: обычное («светлое») и теневое.
Чтобы отобразить содержимое, для каждого <slot name="..."> в теневом DOM браузер ищет slot="..." с таким же именем в обычном DOM. Эти элементы отображаются внутри слотов:
В результате выстраивается так называемое «развёрнутое» (flattened) DOM-дерево:
<user-card>
#shadow-root
<div>Имя:
<slot name="username">
<!-- элемент слота вставляется в слот -->
<span slot="username">Иван Иванов</span>
</slot>
</div>
<div>Дата рождения:
<slot name="birthday">
<span slot="birthday">01.01.2001</span>
</slot>
</div>
</user-card>
…Но развёрнутое DOM-дерево существует только для целей отображения и обработки событий. Это то, что мы видим на экране. Оно, в некотором плане, «виртуальное». Фактически в документе расположение узлов не меняется.
Это можно легко проверить, запустив querySelectorAll: все узлы находятся на своих местах.
// узлы светлого DOM находятся в том же месте, в `<user-card>`
alert( document.querySelectorAll('user-card span').length ); // 2
Так что развёрнутый DOM составляется из теневого вставкой в слоты. Браузер использует его для рендеринга и при всплытии событий (об этом позже). Но JavaScript видит документ «как есть» – до построения развёрнутого DOM-дерева.
Атрибут slot="..." работает только на непосредственных детях элемента-хозяина теневого дерева (в нашем примере это элемент <user-card>). Для вложенных элементов он игнорируется.
Например, здесь второй <span> игнорируется (так как он не является потомком верхнего уровня элемента <user-card>):
<user-card>
<span slot="username">Иван Иванов</span>
<div>
<!-- некорректный слот, должен быть на верхнем уровне user-card: -->
<span slot="birthday">01.01.2001</span>
</div>
</user-card>
Если в светлом DOM есть несколько элементов с одинаковым именем слота, они добавляются в слот один за другим.
Например, этот код:
<user-card>
<span slot="username">Иван</span>
<span slot="username">Иванов</span>
</user-card>
Даст такой развёрнутый DOM с двумя элементами в <slot name="username">:
<user-card>
#shadow-root
<div>Имя:
<slot name="username">
<span slot="username">Иван</span>
<span slot="username">Иванов</span>
</slot>
</div>
<div>Дата рождения:
<slot name="birthday"></slot>
</div>
</user-card>
Содержимое слота «по умолчанию»
Если мы добавляем данные в <slot>, это становится содержимым «по умолчанию». Браузер отображает его, если в светлом DOM-дереве отсутствуют данные для заполнения слота.
Например, в этой части теневого дерева текст Аноним отображается, если в светлом дереве нет значения slot="username".
<div>Имя:
<slot name="username">Аноним</slot>
</div>
Слот по умолчанию (первый без имени)
Первый <slot> в теневом дереве без атрибута name является слотом по умолчанию. Он будет отображать данные со всех узлов светлого дерева, не добавленные в другие слоты
Например, давайте добавим слот по умолчанию в наш элемент <user-card>; он будет собирать всю информацию о пользователе, не занесённую в другие слоты:
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<div>Имя:
<slot name="username"></slot>
</div>
<div>Дата рождения:
<slot name="birthday"></slot>
</div>
<fieldset>
<legend>Другая информация</legend>
<slot></slot>
</fieldset>
`;
}
});
</script>
<user-card>
<div>Я люблю плавать.</div>
<span slot="username">Иван Иванов</span>
<span slot="birthday">01.01.2001</span>
<div>...И играть в волейбол!</div>
</user-card>
Всё содержимое обычного дерева, не добавленное в слоты, попало в <fieldset> «Другая информация».
Элементы добавляются в слот по очереди, один за другим, поэтому оба элемента данных, которые не были добавлены в слоты, попадают в слот по умолчанию.
Развёрнутое DOM-дерево выглядит так:
<user-card>
#shadow-root
<div>Имя:
<slot name="username">
<span slot="username">Иван Иванов</span>
</slot>
</div>
<div>Дата рождения:
<slot name="birthday">
<span slot="birthday">01.01.2001</span>
</slot>
</div>
<fieldset>
<legend>Другая информация</legend>
<slot>
<div>Я люблю плавать.</div>
<div>...И играть в волейбол!</div>
</slot>
</fieldset>
</user-card>
Пример меню
Давайте вернёмся к меню <custom-menu>, упомянутому в начале главы.
Мы можем использовать слоты для распределения элементов.
Вот разметка для меню <custom-menu>:
<custom-menu>
<span slot="title">Сладости</span>
<li slot="item">Леденцы</li>
<li slot="item">Фруктовые тосты</li>
<li slot="item">Кексы</li>
</custom-menu>
Шаблон теневого DOM-дерева с правильными слотами:
<template id="tmpl">
<style> /* стили меню */ </style>
<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>
</template>
<span slot="title">попадает в<slot name="title">.- В шаблоне много элементов
<li slot="item">, но только один слот<slot name="item">. Поэтому все такие<li slot="item">добавляются в<slot name="item">один за другим, формируя список.
Развёрнутое DOM-дерево становится таким:
<custom-menu>
#shadow-root
<style> /* стили меню */ </style>
<div class="menu">
<slot name="title">
<span slot="title">Сладости</span>
</slot>
<ul>
<slot name="item">
<li slot="item">Леденцы</li>
<li slot="item">Фруктовые тосты</li>
<li slot="item">Кексы</li>
</slot>
</ul>
</div>
</custom-menu>
Можно заметить, что в валидном DOM-дереве тег <li> должен быть прямым потомком тега <ul>. Но это развёрнутый DOM, который описывает то, как компонент отображается, в нём такая ситуация нормальна.
Осталось только добавить обработчик click для открытия и закрытия списка, и меню <custom-menu> готово:
customElements.define('custom-menu', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
// tmpl -- шаблон для теневого DOM-дерева (выше)
this.shadowRoot.append( tmpl.content.cloneNode(true) );
// мы не можем выбирать узлы светлого DOM, поэтому обработаем клики на слоте
this.shadowRoot.querySelector('slot[name="title"]').onclick = () => {
// открыть/закрыть меню
this.shadowRoot.querySelector('.menu').classList.toggle('closed');
};
}
});
Вот полное демо:
Конечно, мы можем расширить функциональность меню, добавив события, методы и т.д.
Обновление слотов
Что если внешний код хочет динамически добавить или удалить пункты меню?
Браузер наблюдает за слотами и обновляет отображение при добавлении и удалении элементов в слотах.
Также, поскольку узлы светлого DOM-дерева не копируются, а только отображаются в слотах, изменения внутри них сразу же становятся видны.
Таким образом, нам ничего не нужно делать для обновления отображения. Но если код компонента хочет узнать об изменениях в слотах, можно использовать событие slotchange.
Например, здесь пункт меню вставляется динамически через 1 секунду, и заголовок меняется через 2 секунды:
<custom-menu id="menu">
<span slot="title">Сладости</span>
</custom-menu>
<script>
customElements.define('custom-menu', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>`;
// shadowRoot не может иметь обработчиков событий, поэтому используется первый потомок
this.shadowRoot.firstElementChild.addEventListener('slotchange',
e => alert("slotchange: " + e.target.name)
);
}
});
setTimeout(() => {
menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Леденцы</li>')
}, 1000);
setTimeout(() => {
menu.querySelector('[slot="title"]').innerHTML = "Новое меню";
}, 2000);
</script>
Отображение меню обновляется каждый раз без нашего вмешательства.
Здесь есть два события slotchange:
-
При инициализации:
slotchange: titleзапускается сразу же, как толькоslot="title"из обычного дерева попадает в соответствующий слот. -
Через 1 секунду:
slotchange: itemзапускается, когда добавляется новый элемент<li slot="item">.
Обратите внимание, что событие slotchange не запускается через 2 секунды, когда меняется контент slot="title". Это происходит потому, что сам слот не меняется. Мы изменяем содержимое элемента, который находится в слоте, а это совсем другое.
Если мы хотим отслеживать внутренние изменения обычного DOM-дерева из JavaScript, можно также использовать более обобщённый механизм: MutationObserver.
API слотов
И, наконец, давайте поговорим о методах JavaScript, связанных со слотами.
Как мы видели раньше, JavaScript смотрит на «реальный», а не на развёрнутый DOM. Но если у теневого дерева стоит {mode: 'open'}, то мы можем выяснить, какие элементы находятся в слоте, и, наоборот, определить слот по элементу, который в нём находится:
node.assignedSlot– возвращает элемент<slot>, в котором находитсяnode.slot.assignedNodes({flatten: true/false})– DOM-узлы, которые находятся в слоте. Опцияflattenимеет значение по умолчаниюfalse. Если явно изменить значение наtrue, она просматривает развёрнутый DOM глубже и возвращает вложенные слоты, если есть вложенные компоненты, и резервный контент, если в слоте нет узлов.slot.assignedElements({flatten: true/false})– DOM-элементы, которые находятся в слоте (то же самое, что выше, но только узлы-элементы).
Эти методы можно использовать не только для отображения содержимого, которое находится в слотах, но и для его отслеживания в JavaScript.
Например, если компонент <custom-menu> хочет знать, что он показывает, он может отследить событие slotchange и получить пункты меню из slot.assignedElements:
<custom-menu id="menu">
<span slot="title">Сладости</span>
<li slot="item">Леденцы</li>
<li slot="item">Фруктовые тосты</li>
</custom-menu>
<script>
customElements.define('custom-menu', class extends HTMLElement {
items = []
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>`;
// слотовый элемент добавляется/удаляется/заменяется
this.shadowRoot.firstElementChild.addEventListener('slotchange', e => {
let slot = e.target;
if (slot.name == 'item') {
this.items = slot.assignedElements().map(elem => elem.textContent);
alert("Items: " + this.items);
}
});
}
});
// пункты меню обновятся через 1 секунду
setTimeout(() => {
menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Кексы</li>')
}, 1000);
</script>
Итого
Обычно, если у элемента есть теневое дерево, то содержимое обычного, светлого DOM не показывается. Слоты позволяют показать элементы светлого DOM на заданных местах в теневом DOM.
Существует два вида слотов:
- Именованные слоты:
<slot name="X">...</slot>– получают элементы светлого DOM сslot="X". - Слот по умолчанию: первый
<slot>без имени (последующие неименованные слоты игнорируются) – показывает элементы элементов светлого дерева, которые не находятся в других слотах. - Если одному слоту назначено несколько элементов, они добавляются один за другим.
- Содержимое элемента
<slot>используется как резервное. Оно отображается, если в слоте нет элементов из светлого дерева.
Процесс отображения элементов внутри слота называется «композицией». В результате композиции строится «развёрнутый DOM».
При композиции не происходит перемещения узлов – с точки зрения JavaScript, DOM остаётся прежним.
JavaScript может получить доступ к слотам с помощью следующих методов:
slot.assignedNodes/Elements()– возвращает узлы/элементы, которые находятся внутриslot.node.assignedSlot– обратный метод, возвращает слот по узлу.
Если мы хотим знать, что показываем, мы можем отследить контент слота следующими способами:
- событие
slotchange– запускается, когда слот наполняется контентом в первый раз, и при каждой операции добавления/удаления/замещения элемента в слоте, за исключением его потомков. Сам слот будетevent.target. - MutationObserver для более глубокого просмотра содержимого элемента в слоте и отслеживания изменений в нём.
Теперь, когда мы научились показывать элементы светлого DOM в теневом DOM, давайте посмотрим, как их правильно стилизовать. Основное правило звучит так: теневые элементы стилизуются внутри, а обычные элементы – снаружи; однако есть заметные исключения.
Мы рассмотрим их подробно в следующей главе.
Комментарии
<code>, для нескольких строк кода — тег<pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)