14 декабря 2021 г.

Слоты теневого DOM, композиция

Многим типам компонентов, таким как вкладки, меню, галереи изображений и другие, нужно какое-то содержимое для отображения.

Так же, как встроенный в браузер <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=«…» могут иметь только дети первого уровня

Атрибут 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>
  1. <span slot="title"> попадает в <slot name="title">.
  2. В шаблоне много элементов <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:

  1. При инициализации:

    slotchange: title запускается сразу же, как только slot="title" из обычного дерева попадает в соответствующий слот.

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

Мы рассмотрим их подробно в следующей главе.

Карта учебника