Привет, Хабр!

Shadow DOM – это нечто вроде дополнения к обычному DOM, позволяющее инкапсулировать стили и разметку компонентов, скрывая их от основного дерева документа. Это механизм, который позволяет упаковать HTML-структуру, стили и скрипты в изолированные компоненты.

Shadow DOM обеспечивает инкапсуляцию — значит, CSS и JavaScript могут работать в рамках конкретного компонента, не влияя на остальную часть страницы. Это решает проблему "глобальной области видимости", с которой сталкиваются многие разработчики, и позволяет создавать более предсказуемые и устойчивые веб-приложения.

Shadow DOM

Shadow Host – это обычный DOM-узел в основном дереве документа, который служит контейнером для внедрения shadow DOM. В простых словах, это элемент, в котором "живет" Shadow DOM.

Можно превратить любой элемент в Shadow Host, применив к нему метод attachShadow. После этого, в этом элементе создается shadow root, который является корнем shadow tree.

<div id="shadow-host"></div>
<script>
  const shadowHost = document.getElementById('shadow-host');
  const shadowRoot = shadowHost.attachShadow({mode: 'open'});
</script>

Здесь div с ID shadow-host становится Shadow Host.

Shadow Tree – это коллекция элементов, которая реализуется внутри Shadow Host и инкапсулирована от основного DOM. Это дерево включает в себя элементы и стили, которые не влияют и не видны вне Shadow Host.

Элементы внутри Shadow Tree не могут быть напрямую выбраны с помощью селекторов из основного DOM. Это обеспечивает изоляцию стилей и скриптов, предотвращая непредвиденные взаимодействия.

<script>
  shadowRoot.innerHTML = '<p>Этот текст в Shadow DOM</p>';
</script>

Параграф <p> является частью Shadow Tree внутри Shadow Host.

Shadow Boundary – это граница, разделяющая Shadow DOM от обычного, Light DOM. Это граница инкапсуляции, за которой заканчивается влияние Shadow DOM и начинается обычный DOM.

Shadow Boundary гарантирует, что стили и скрипты внутри Shadow DOM не влияют на основной документ и наоборот. Хотя Shadow DOM инкапсулирован, события, сгенерированные внутри Shadow DOM, могут пересекать границы и всплывать в основной DOM.

Light DOM – это обычный DOM, который мы используем каждый день. Это структура HTML, которую мы пишем и с которой взаимодействуем большую часть времени при разработке веб-страниц.

В отличие от Shadow DOM, Light DOM полностью открыт и доступен для взаимодействия с остальной частью страницы. Это стандартный DOM, не затронутый инкапсуляцией Shadow DOM.

Light DOM часто используется для определения содержимого, которое будет проецироваться в Shadow DOM через слоты.

Использование метода attachShadow

Метод attachShadow — это метод, который вызывается на DOM элементе (называемом shadow host), для добавления к нему shadow DOM. Этот метод создает и возвращает ссылку на shadow root, который служит корнем для вашего shadow DOM.

Чтобы использовать attachShadow, нужно выбрать элемент, который нужно использовать в качестве shadow host, и вызовите на нем этот метод:

<div id="my-shadow-host"></div>

<script>
  const shadowHost = document.getElementById('my-shadow-host');
  const shadowRoot = shadowHost.attachShadow({ mode: 'open' });
</script>

В этом примере div с идентификатором my-shadow-host становится shadow host. После вызова attachShadow, к этому элементу присоединяется shadow root.

Метод attachShadow принимает один аргумент - объект конфигурации, который содержит свойство mode. Это свойство может иметь два значения: open или closed.

  • open: Если mode установлен в open, вы можете получить доступ к shadow root с помощью свойства shadowRoot на shadow host. Это обеспечивает большую гибкость, но также снижает уровень инкапсуляции.

    const shadowRoot = shadowHost.shadowRoot;
  • closed: Если mode установлен в closed, доступ к shadow root вне самого shadow DOM невозможен. Это обеспечивает более строгую инкапсуляцию, но уменьшает гибкость работы с компонентом.

После создания shadow root, вы можете добавить в него содержимое точно так же, как и в обычный DOM:

shadowRoot.innerHTML = `<style> p { color: red; } </style><p>Привет, Shadow DOM!</p>`;

Здесь в shadow DOM добавляется стилизованный параграф. Эти стили не будут влиять на остальную часть документа, поскольку они инкапсулированы в shadow DOM.

Не все элементы могут быть shadow hosts. Например, некоторые встроенные элементы, такие как <img>, не могут содержать shadow DOM.

Каждый shadow host может иметь только один shadow root. Повторный вызов attachShadow на том же элементе приведет к ошибке.

Стили, определенные в shadow DOM, ограничены его границами. Они не влияют на основной документ и не подвержены влиянию стилей основного документа, за исключением CSS Custom Properties (кастомные CSS переменные).

Для вставки содержимого из Light DOM в Shadow DOM используются слоты (<slot>). Это позволяет создавать более гибкие и мощные компоненты.

Взаимодействие Shadow DOM с основным DOM-деревом

Когда вы создаете Shadow DOM для элемента, вы создаете изолированное поддерево, которое "скрыто" от основного документа. Это означает, что cтили, определенные внутри Shadow DOM, не влияют на основной DOM и наоборот. Это предотвращает непреднамеренные конфликты стилей и делает компоненты более надежными.

Скрипты и селекторы, запущенные внутри Shadow DOM, не могут "выходить" за его пределы. Это означает, что селекторы типа document.querySelector или document.querySelectorAll не будут возвращать элементы из Shadow DOM.

Shadow DOM позволяет "проектировать" содержимое из основного DOM в Shadow DOM с использованием <slot> элементов. Эта функция позволяет определять места в шаблоне Shadow DOM, куда будет вставлено содержимое из Light DOM. Слоты могут быть как именованными, так и безымянными, что позволяет контролировать, как именно и где контент будет отображаться в компоненте.

События, сгенерированные внутри Shadow DOM, могут "всплывать" и быть обработаны в основном DOM. Однако события могут всплывать из Shadow DOM в основной DOM, они делают это таким образом, что их источник (истинный целевой элемент) остается скрытым.

В некоторых случаях, события из Shadow DOM могут быть переопределены или компонованы для создания более комплексных взаимодействий.

Хотя стили в Shadow DOM изолированы, существуют способы влияния на них из основного DOM:

  • CSS custom properties: кастомные CSS-свойства (также известные как CSS переменные) могут проникать через границы Shadow DOM, позволяя определенный уровень стилизации из внешнего контекста.

  • :host и :host-context псевдоклассы: эти псевдоклассы позволяют стилизовать shadow host исходя из его контекста восновном DOM.

Стилизация и инкапсуляция в Shadow DOM

Изоляция стилей

Стили, определенные внутри Shadow DOM, не распространяются на внешний DOM и наоборот. Это значит, что если вы определяете CSS правила внутри Shadow DOM, они не будут влиять на элементы, находящиеся за пределами этого Shadow DOM.

Shadow DOM поддерживает локальный контекст для стилей. Это означает, что селекторы, используемые внутри Shadow DOM, применяются только к элементам внутри этого конкретного Shadow DOM.

В обычном DOM, стили каскадируются - то есть они применяются ко всем подходящим элементам внутри документа. В Shadow DOM, стили ограничиваются только теми элементами, которые находятся внутри того же Shadow DOM.

Селекторы типа классов, идентификаторов и тегов, определенных внутри Shadow DOM, не влияют на элементы вне этого поддерева. Например, если вы определите стиль для элемента <p> в Shadow DOM, он не будет применяться к абзацам за пределами Shadow DOM.

Элементы в Shadow DOM скрыты от внешнего мира. Это означает, что даже если внешний стиль пытается целенаправлнно изменить элементы в Shadow DOM (например, через универсальные селекторы), это не будет иметь эффекта.

<style>
  p { color: blue; }
</style>
<p>Этот текст будет синим только внутри Shadow DOM.</p>

CSS в Shadow DOM

В отличие от других CSS правил, CSS переменные могут быть заданы в глобальном стиле и применяться внутри Shadow DOM. Это обеспечивает гибкость и позволяет внешнему контексту влиять на стилизацию компонентов в Shadow DOM.

:root {
  --main-color: blue;
}

Переменная --main-color может быть использована внутри Shadow DOM.

CSS переменные, определенные в Shadow DOM, имеют локальный контекст и не влияют на внешний DOM, что позволяет удерживать инкапсуляцию стилей.

Слоты - это элементы в Shadow DOM, которые предоставляют точки вставки для контента из внешнего DOM.

Можно стилизовать слоты как обычные элементы, используя CSS-селекторы. Это позволяет контролировать внешний вид проектируемого контента.

::slotted(p) {
  color: var(--main-color);
}

В коде выше все параграфы, проецируемые в слоты, будут окрашены в цвет, определенный переменной --main-color.

Селектор ::slotted может применяться только к непосредственным детям слота. Комбинаторы и псевдо-элементы в контексте ::slotted имеют ограничения.

Селекторы внутри Shadow DOM применяются только к элементам в этом Shadow DOM. Это означает, что даже если селектор общий (например, тег или класс), он не будет влиять на элементы вне Shadow DOM.

Примеры

Базовая стилизация

Создадим простой пользовательский элемент с Shadow DOM, в котором будет текстовый элемент, стилизованный изнутри Shadow DOM.

HTML

<my-custom-element></my-custom-element>

JavaScript

class MyCustomElement extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    const wrapper = document.createElement('div');
    wrapper.setAttribute('class', 'wrapper');

    const style = document.createElement('style');
    style.textContent = `
      .wrapper {
        border: 1px solid #333;
        padding: 10px;
        background-color: #f0f0f0;
      }
    `;

    shadow.appendChild(style);
    shadow.appendChild(wrapper);
  }
}

customElements.define('my-custom-element', MyCustomElement);

В этом примере создается пользовательский элемент my-custom-element. В конструкторе мы прикрепляем Shadow DOM (attachShadow) и добавляем в него div элемент с классом wrapper. Стили внутри Shadow DOM обеспечивают, что div будет иметь специфические рамку, отступ и фон, не влияя на остальную часть документа.

Использование слотов для стилизации

Рассмотрим использование слотов для стилизации контента, передаваемого из Light DOM.

HTML

<my-slotted-element>
  <p>OTUS</p>
</my-slotted-element>

JavaScript

class MySlottedElement extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    const slot = document.createElement('slot');
    slot.name = 'content';

    const style = document.createElement('style');
    style.textContent = `
      ::slotted(p) {
        color: green;
        font-weight: bold;
      }
    `;

    shadow.appendChild(style);
    shadow.appendChild(slot);
  }
}

customElements.define('my-slotted-element', MySlottedElement);

Здесь мы определяем пользовательский элемент my-slotted-element, который содержит слот. Стили внутри Shadow DOM применяются к элементам <p>, передаваемым в слот, делая их зелеными и жирными.

Использование CSS переменных

CSS переменные могут быть использованы для кастомизации стилей компонентов в Shadow DOM из внешнего контекста.

HTML

<style>
  my-variable-element {
    --main-color: red;
  }
</style>

<my-variable-element></my-variable-element>

JavaScript

class MyVariableElement extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    const wrapper = document.createElement('div');
    wrapper.setAttribute('class', 'wrapper');

    const style = document.createElement('style');
    style.textContent = `
      .wrapper {
        color: var(--main-color, blue);
      }
    `;

    shadow.appendChild(style);
    shadow.appendChild(wrapper);
  }
}

customElements.define('my-variable-element', MyVariableElement);

В этом примере определяется пользовательский элемент my-variable-element. Используя CSS переменную --main-color, мы позволяем внешнему стилю определять цвет текста внутри элемента. Если переменная не установлена, используется цвет по умолчанию (blue).

События и взаимодействие с Shadow DOM

Всплытие событий в Shadow DOM и их обработка

Особенностью Shadow DOM является то, что, хотя события могут всплывать из Shadow DOM в основной DOM, они делают это с сохранением инкапсуляции. Это означает, что, в то время как событие может быть "уловлено" в основном DOM, источник события (целевой элемент) остается в контексте Shadow DOM.

Когда событие выходит из Shadow DOM, целевой элемент (event.target) переопределяется на ближайший к корню Shadow DOM элемент в основном DOM (т.е., на shadow host).

События в Shadow DOM ведут себя так же, как и в обычном DOM. Они всплывают от исходного элемента до корня Shadow DOM и, если не остановлены, продолжают всплывать уже в основном DOM.

К примеру если у вас есть кнопка внутри Shadow DOM, и на нее нажимают, событие клика сначала достигнет корня Shadow DOM, а затем продолжит всплывать до элемента в основном DOM, в котором находится shadow host.

События, сгенерированные внутри Shadow DOM, могут быть обработаны напрямую внутри этого же контекста. Обработчики, добавленные в элементы Shadow DOM, будут реагировать только на события внутри этого Shadow DOM.

shadowRoot.querySelector('button').addEventListener('click', event => {
  console.log('Клик внутри Shadow DOM');
});

Для обработки событий из Shadow DOM в основном DOM, вы добавляете обработчик к shadow host или другим элементам выше по дереву.event.target будет ссылаться на shadow host, а не на исходный элемент внутри Shadow DOM.

document.querySelector('my-custom-element').addEventListener('click', event => {
  console.log('Событие клика перехвачено в основном DOM');
});

event.composedPath(): Этот метод возвращает массив элементов, через которые событие прошло. Он может быть использован для определения фактического пути всплытия события, включая элементы внутри Shadow DOM.

Вы можете остановить всплытие события, используя event.stopPropagation(). Однако, это остановит всплытие события на всех уровнях, включая Shadow DOM и основной DOM.

Делегирование событий раотает в основном DOM, но внутри Shadow DOM оно требует отдельного управления, поскольку элементы Shadow DOM изолированы от остальной части страницы.

Техники делегирования событий и связывание с внешним DOM

Вместо того чтобы назначать обработчик каждому элементу, обработчик устанавливается на более высоком уровне (например, на уровне родительского элемента). Затем, используя информацию о событии, определяется, какой именно дочерний элемент его сгенерировал.

document.getElementById('parent').addEventListener('click', (event) => {
  if (event.target.matches('button')) {
    console.log('Кнопка нажата');
  }
});

Все клики внутри элемента с ID parent проверяются на то, была ли нажата кнопка.

Поскольку Shadow DOM инкапсулирует своё содержимое, делегирование событий внутри Shadow DOM ограничивается его границами. Это означает, что обработчик, установленный во внешнем DOM, не сможет "уловить" события из Shadow DOM напрямую.

shadowRoot.addEventListener('click', (event) => {
  if (event.target.matches('button')) {
    console.log('Кнопка в Shadow DOM нажата');
  }
});

Обработчик устанавливается на корневом элементе Shadow DOM и обрабатывает клики на кнопках внутри этого Shadow DOM.

События, возникшие в Shadow DOM, всплывают до его корня, а затем продолжают всплывать уже во внешнем DOM, но с переопределённым target (на shadow host).

document.querySelector('my-custom-element').addEventListener('click', (event) => {
  console.log('Событие из Shadow DOM перехвачено во внешнем DOM');
});

Событие, возникшее внутри кастомного элемента с Shadow DOM, перехватывается обработчиком на элементе-хосте во внешнем DOM.

Метод event.composedPath() может быть использован для определения фактического пути всплытия события, включая элементы в Shadow DOM.

Если в Shadow DOM используются слоты для проецирования содержимого из внешнего DOM, события, сгенерированные этими проецированными элементами, будут вести себя так, как если бы они были частью внешнего DOM.

События, установленные внутри Shadow DOM, будут работать только внутри этого контекста. Они изолированы от основного DOM.

Для того чтобы обрабатывать события из Shadow DOM во внешнем DOM, необходимо установить слушатели на элемент-хост во внешнем DOM:

document.querySelector('my-custom-element').addEventListener('click', () => {
  console.log('Событие из Shadow DOM перехвачено во внешнем DOM');
});

Для предотвращения утечек памяти и корректной очистки ресурсов, удаляйте слушателей, когда они больше не нужны.

button.removeEventListener('click', clickHandler);

Если элементы внутри Shadow DOM проецируются через слоты во внешний DOM, события от них могут быть обработаны как обычные события во внешнем DOM.

document.querySelector('my-custom-element').addEventListener('click', () => {
  console.log('Событие из Shadow DOM в слоте перехвачено во внешнем DOM');
});

Примеры использования

Создание кастомной кнопки

class CustomButton extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    const button = document.createElement('button');
    button.textContent = 'Кликни меня';

    button.addEventListener('click', () => {
      console.log('Кнопка была нажата');
    });

    shadow.appendChild(button);
  }
}

customElements.define('custom-button', CustomButton);

Делегирование кликов на меню

document.getElementById('menu').addEventListener('click', (event) => {
  if (event.target.tagName === 'LI') {
    console.log(`Выбран пункт: ${event.target.textContent}`);
  }
});

Обработка валидации формы

class CustomForm extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    const form = document.createElement('form');

    form.addEventListener('submit', (event) => {
      event.preventDefault();
      const input = form.querySelector('input');
      if (input.value === '') {
        console.error('Поле обязательно для заполнения');
      } else {
        console.log('Данные отправлены:', input.value);
      }
    });

    shadow.appendChild(form);
  }
}

customElements.define('custom-form', CustomForm);

События в слотах

document.querySelector('task-list').addEventListener('task-click', (event) => {
  console.log(`Задача "${event.detail.task}" была нажата`);
});

Редактирование комментариев

class CommentEditor extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    const comment = document.createElement('div');
    comment.textContent = 'Это комментарий';

    const editButton = document.createElement('button');
    editButton.textContent = 'Редактировать';

    editButton.addEventListener('click', () => {
      comment.contentEditable = true;
      comment.focus();
    });

    shadow.appendChild(comment);
    shadow.appendChild(editButton);
  }
}

customElements.define('comment-editor', CommentEditor);

Взаимодействие с галереей изображений

document.querySelector('image-gallery').addEventListener('image-click', (event) => {
  const imageUrl = event.detail.imageUrl;
  console.log(`Просмотр изображения: ${imageUrl}`);
});

Модальное окно

class ModalWindow extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });

    const modal = document.createElement('div');
    modal.className = 'modal';

    const closeButton = document.createElement('button');
    closeButton.textContent = 'Закрыть';

    closeButton.addEventListener('click', () => {
      this.dispatchEvent(new Event('modal-close'));
    });

    shadow.appendChild(modal);
    modal.appendChild(closeButton);
  }
}

customElements.define('modal-window', ModalWindow);

Сортировка таблицы

document.querySelector('table').addEventListener('click', (event) => {
  if (event.target.tagName === 'TH') {
    const columnName = event.target.textContent;
    console.log(`Сортировка по столбцу: ${columnName}`);
  }
});

Взаимодействие с деревом элементов

document.querySelector('tree-view').addEventListener('node-click', (event) => {
  console.log(`Узел "${event.detail.node}" был нажат`);
});

Организация виджетов на странице

document.querySelector('page-container').addEventListener('widget-action', (event) => {
  const widgetName = event.detail.widgetName;
  console.log(`Действие в виджете "${widgetName}"`);
});

Shadow DOM позволяет создавать более чистый и поддерживаемый код, а также повысить эффективность веб-компонентов.

Статья подготовлена в рамках запуска специализации Fullstack Developer. По ссылке вы сможете узнать подробнее о специализации и зарегистрироваться на бесплатный урок.

Комментарии (1)


  1. gun_dose
    20.12.2023 09:51

    Статья интересная, непонятно только причём тут SSR?

    PS: слово Fullstack читается как "фуллстек". А "фуллстАк" - это full stuck - дословный перевод "полностью застрявший"