Многим типам компонентов, таким как вкладки, меню, галереи изображений и другие, нужно какое-то содержимое для отображения.
Так же, как встроенный в браузер <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, давайте посмотрим, как их правильно стилизовать. Основное правило звучит так: теневые элементы стилизуются внутри, а обычные элементы – снаружи; однако есть заметные исключения.
Мы рассмотрим их подробно в следующей главе.