[Из песочницы] Веб-компоненты: обзор и использование в продакшне

Привет! Я использую веб-компоненты в разработке фронтэнда. В данной статье я опишу все возможности веб-компонентов, а также то, как их можно использовать сегодня с учётом пока ещё не высокой поддержки.


Кратко про веб-компоненты: это набор технологий, которые позволяют использовать компонентный подход с инкапсуляцией стилей и скриптов в вебе нативно, без подключения каких-либо библиотек или фейрмворков. Если вам интересно, что предлагают стандарты вместо привычных уже React или Angular, и как это использовать при разработке под старые браузеры, прошу под кат.


tueygtwxjcmcjfelgeozsvrlwcg.png


Список материалов для детального изучения — в конце статьи.


Содержание:


  • Вступление
  • Теория
  • Практика
  • Выводы
  • Ресурсы


Вступление

Я работаю фронтэнд-разработчиком сервисов в одной из крупных международных кампаний и в настоящий момент второй раз переписываю фронтэнд проекта.


В первой версии, написанной согласно канонам 1C-Bitrix, меня ожидало мясо из скриптов и стилей во множестве шаблонов. Скрипты, конечно, были на jQuery, а стили, разумеется, были абсолютно хаотичны, без какой-либо структуры или порядка. В связи с переездом на новую платформу выпал шанс полностью переписать проект. Для наведения порядка была разработана своя компонентная система с использованием БЭМ-методологии. На каждый экземпляр БЭМ-блока в разметке после загрузки страницы создаётся один объект соответствующего класса, который начинает управлять логикой. Таким образом, всё довольно строго систематизировано — блоки (логика и стили) реиспользуемы и изолированы друг от друга.


По прошествии года поддержки и доработки проекта обнаружился ряд недостатков такой системы. Разметка, на основе которой работают мои псевдокомпоненты, держится на внимательности и «честном слове» разработчика: JS надеется, что верстальщик правильно расставил все нужные элементы и прописал к ним классы. Если компонент модифицирует свой DOM, который содержит другие БЭМ-блоки, либо же разметка подгружается через ajax, компоненты этой разметки приходится инициализировать вручную. Это всё казалось простым при ежедневной работе до тех пор, пока на проекте не появился второй человек. Документация, к сожалению, хоть и была довольно объёмной, но охватывала только базовые принципы (объём в данном случае стал минусом). В жизни же шаг влево или шаг вправо ломал установленные «принципы», а чтение готовых компонентов только вносили сумбур, так как они были написаны по разному и в разное время.


Всё это, а также потенциальное увеличение количества проблем в разработке при запланированном переходе к SPA/PWA подтолкнули к очередной переработке фронта. Велосипеды, которым, в том числе, является моя компонентная система, очень полезны во время обучения чему-либо (в моём случае — JS), но в качественном проекте с несколькими разработчиками необходимо что-то более надёжное и структурированное. В настоящее время (впрочем, уже давно) в вебе мы имеем массу фреймворков, среди которых есть, что выбрать: Preact предлагает маленький размер и максимальное сходство в разработке с уже знакомым мне React-ом, Angular манит встроенным прелестным TypeScript-ом, Vue выглядывает из-за угла и хвастается своей простотой, и много другого. Особняком держится стандарт: оказывается, можно писать реиспользуемые веб-компоненты, с зашитой внутри логикой и стилями, которые не надо будет искусственно (за счёт БЭМ и дополнительного JS-а) связывать с уже написанной разметкой. И всё это должно работать из коробки. Чудо, не правда ли?


Из-за моей дикой любви к стандартам и вере в светлое будущее, а также схожести имеющейся системы компонентов с веб-компонентами (один JS-класс на один «компонент» с изолированной логикой), решено было попробовать использовать именно веб-компоненты. Больше всего напрягала поддержка этого дела в браузерах: веб-компоненты слишком молоды, чтобы охватывать сколько-нибудь широкий круг браузеров, а уж тем более охватывать браузеры, которые необходимо поддерживать коммерческому продукту (например, Android 4.4 stock browser и Internet Explorer 11). Мысленно был принят некоторый уровень боли и ограничений, который могли меня ожидать, и рамок, в которые я согласен вписываться при разработке, и я погрузился в изучение теории и практические эксперименты: как писать фронт на веб-компонентах и выкатывать это в продакшн так, чтобы оно работало.


Необходимый теоретический минимум для чтения статьи: чистый JavaScript на уровне базовых манипуляций с DOM-деревом, понимание синтаксиса классов в ES2015, плюсом будет знакомство с каким-либо из фреймворков из разряда React.js/Angular/Vue.js.


Теория

Обзор


Веб-компоненты — это совокупность стандартов, которые позволяют делать декларативно описываемые, реиспользуемые «виджеты» с изолированными стилями и скриптами в виде собственных тегов. Стандарты развиваются независимо, и связываются в веб-компоненты довольно условно — в принципе, можно использовать каждую из используемых технологий отдельно. Но именно вместе они максимально эффективны.


Обычно все четыре стандарта — пользовательские элементы (Custom Elements), теневой DOM (Shadow DOM), шаблоны (HTML Templates) и HTML-импорты (HTML Imports) — рассматриваются отдельно, а уже потом соединяются вместе. Так как по отдельности они слабо полезны, мы рассмотрим все возможности стандартов кумулятивно, прибавляя их к уже изученному ранее.


Стоит напомнить, что веб-компоненты — довольно молодая технология, и стандарты претерпевали множество изменений. Для нас в основном это выражается в нескольких версиях стандартов Custom Elements и Shadow DOM — v0 и v1. v0 в настоящий момент не актуальны. Мы будем рассматривать только v1. Будьте внимательны при поиске дополнительных материалов! Версии v1 сформировались только в 2016 году, а значит, все статьи и видео до 2016 года гарантированно говорят именно о старой версии спецификации.


Пользовательские элементы (Custom Elements)


Пользовательские элементы — это возможность создавать новые HTML-теги с произвольными именами и поведением, например, или .


Регистрация пользовательского элемента


Конечно, мы и так можем «создать» собственный теги (например, браузеры вполне корректно обработают тег ), но при этом в DOM-дереве элемент регистрируется как объект класса HTMLUnknownElement и не имеет никакого поведения по умолчанию. «Оживлять» каждый такой элемент придётся вручную.


Спецификация пользовательских элементов позволяет регистрировать новые теги и задавать их поведение в соответствии с жизненным циклом — создание, вставка в DOM, изменение атрибутов, удаление из DOM. Для того чтобы предотвратить возможный конфликт новых тегов стандарта HTML и пользовательских тегов, имена последних обязаны содержать как минимум один дефис — например, или . Также пользовательские теги на текущий момент не могут быть самозакрывающимися, даже теги без содержимого должны быть парными.


Так как лучший способ обучения это практика, напишем элемент, схожий по функционалу с элементом

. Назовём его .


При добавлении такого элемента в DOM-дерево он также станет объектом класса HTMLUnknownElement. Для того, чтобы зарегистрировать элемент, как пользовательский, и добавить ему своё поведение, нужно воспользоваться методом define глобального объекта customElements. Первым аргументом передаётся имя тега, вторым — класс, описывающий поведение. Класс при этом должен расширять класс HTMLElement, чтобы наш элемент обладал всеми качествами и возможностями других HTML элементов. Итого:


class XSpoiler extends HTMLElement {}

customElements.define("x-spoiler", XSpoiler);


После этого браузер пересоздаст все имеющиеся в разметке теги x-spoiler как объекты класса XSpoiler, а не HTMLUnknownElement. Все новые теги x-spoiler, которые добавляются в документ через innerHTML, insertAdjacentHTML, append или другие методы для работы с HTML, сразу создаются на основе класса XSpoiler. Также такие DOM-элементы можно создавать и через document.createElement.


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


Имя тега и имя класса совсем не обязательно должны совпадать. Поэтому мы можем при необходимости зарегистрировать два пользовательских элемента с одинаковым именем класса под разными тегами.


Теперь пользовательский элемент, конечно, зарегистрирован, но ничего полезного он не делает. Чтобы действительно оживить наш пользовательский элемент, рассмотрим его жизненный цикл.


Жизненный цикл пользовательского элемента


Мы можем добавить методы-коллбэки на создание элемента, добавление его в DOM, на изменение атрибутов, на удаление элемента из DOM и на изменение родительского документа. Мы воспользуемся этим, чтобы реализовать логику работы спойлера: компонент будет содержать кнопку с текстом «Свернуть»/«Развернуть» и секцию с изначальным содержимым тега. Видимость секции будет управляться кликом по кнопке или значением атрибута. Текст кнопок также можно будет настроить через атрибуты.


Коллбэком на создание элемента является конструктор класса. Чтобы он корректно отработал, сперва необходимо вызвать родительский конструктор через super. В конструкторе можно задать разметку, навесить обработчики событий, сделать какую-то другую подготовительную работу. В конструкторе, как и в других методах, this будет ссылаться на сам DOM-элемент, а благодаря тому, что наш пользовательский элемент расширяет HTMLElement, у this есть такие методы как querySelector и такие свойства как classList.


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


class XSpoiler extends HTMLElement {
  constructor() {
    super();

    this.text = {
      "when-close": "Развернуть",
      "when-open": "Свернуть",
    }

    this.innerHTML = `
      
      
${this.innerHTML}
`; this.querySelector("button").addEventListener("click", () => { const opened = (this.getAttribute("opened") !== null); if (opened) { this.removeAttribute("opened"); } else { this.setAttribute("opened", ""); } }); } }


Подробно разберём каждую часть конструктора.


super() вызывает конструктор класса HTMLElement. Это в данном случае обязательное действие, если нам нужен конструктор элемента.


this.text — так как this это объект, мы можем добавить и свои свойства. В данном случае я буду хранить в объекте text вспомогательные тексты, которые выводятся на кнопке.


this.innerHTML установит разметку нашего DOM-элемента. При этом мы используем текст, заданный чуть выше.


this.querySelector("button").addEventListener добавит обработчик события click по кнопке, который будет устанавливать или снимать атрибут opened. Мы будем работать с ним как с логическим значением — спойлер либо открыт, либо закрыт, следовательно, атрибут либо есть, либо нет. В обработчике мы будем проверять наличие атрибута через сравнение с null, а затем либо устанавливать, либо удалять атрибут.


Теперь при клике по созданной кнопке будет меняться атрибут opened. Пока что изменение атрибута ничего не даёт. Прежде чем перейти к этой тебе, немного модифицируем код.


Помните, что у тега

${this.innerHTML}
`; this.querySelector("button").addEventListener("click", () => { this.opened = !this.opened; }); } get opened() { return (this.getAttribute("opened") !== null); } set opened(state) { if (!!state) { this.setAttribute("opened", ""); } else { this.removeAttribute("opened"); } } }


Для строковых свойств (как для встроенных id у любых элементов и href у ссылок) геттер и сеттер будут выглядеть немного проще, но идея сохраняется.


Можно ещё добавить, что такое «отржание» может быть не всегда полезным с точки зрения производительности. Так, у элементов формы атрибут value работает не совсем так.


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


Вместо этого можно навесить обработчик на изменение значения атрибутов, используя метод attributeChangedCallback. Он будет вызываться каждый раз при изменении атрибута, таким образом, через атрибуты можно будет управлять компонентом как изнутри, так и снаружи.


Метод применяет три параметра: имя атрибута, старое значение, новое значение. Так как вызывать этот метод на изменение абсолютно ВСЕХ атрибутов было бы нерационально с точки зрения производительности, срабатывает он только при изменении свойств, которые перечислены в статическом массиве observedAttributes текущего класса.


Наш компонент должен реагировать на изменение трёх атрибутов — opened, text-when-open и text-when-close. Первый будет влиять на отображение спойлера, а два других будут управлять текстом кнопки. Первым делом добавим к нашему классу имена этих атрибутов в статический массив observedAttributes:


static get observedAttributes() {
  return [
    "opened",
    "text-when-open",
    "text-when-close",
  ]
}


Теперь добавим сам метод attributeChangedCallback, который в зависимости от изменённого атрибута будет менять либо видимость контента и выводить текст кнопки, либо менять текст кнопки и выводить его при необходимости. Для этого используем switch по первому аргументу метода.


attributeChangedCallback(attrName, oldVal, newVal) {
  switch (attrName) {
    case "opened":
      const opened = newVal !== null;
      const button = this.querySelector("button");
      const content = this.querySelector("section");
      const display = opened ? "block" : "none";
      const text = this.text[opened ? "when-open" : "when-close"];
      content.style.display = display;
      button.textContent = text;
      break;

    case "text-when-open":        
      this.text["when-open"] = newVal;
      if (this.opened) {
        this.querySelector("button").textContent = newVal;
      }
      break;

    case "text-when-close":
      this.text["when-close"] = newVal;
      if (!this.opened) {
        this.querySelector("button").textContent = newVal;
      }
      break;
  }
}


Обратите внимание, что метод attributeChangedCallback сработает даже тогда, когда требуемые атрибуты изначально присутствуют у элемента. То есть, если наш компонент будет вставлен в разметку сразу с атрибутом opened, спойлер действительно будет открыт, т.к. attributeChangedCallback сработает сразу после constructor. Поэтому никакой дополнительной работы по обработке начального значения атрибутов в конструкторе производить не нужно (если, конечно, атрибут — отслеживаемый).


Теперь наш компонент действительно работает! При клике по кнопке меняется значение атрибута opened, после этого срабатывает коллбэк attributeChangedCallback, который в свою очередь управляет видимостью содержимого. Управление состоянием именно через атрибуты и attributeChangedCallback позволяет управлять изначальным состоянием (мы можем добавить opened в разметку сразу, если хотим показать открытый спойлер) или управлять состоянием снаружи (любой другой JS-код может установить или снять атрибут с нашего элемента и это будет корректно обработано). В качестве бонуса мы можем настроить текст управляющей кнопки. Демо результата, смотреть в свежем Chrome!


Основной функционал готов, это самые часто используемые возможности пользовательских элементов. Теперь рассмотрим те коллбэки, которые используются реже.


На вставку элемента в DOM-дерево срабатывает метод connectedCallback. Если элемент уже находился в разметке на момент регистрации, или он создаётся через вставку HTML-строки, последовательно сработают constructor, при необходимости — attributeChangedCallback, а уже затем — connectedCallback. Этот коллбэк можно использовать, если, например, необходимо знать информацию о родителе в DOM-дереве, или мы хотим оптимизировать наш компонент и отложить какой-то тяжёлый код именно до момента использования элемента. Однако, при этом стоит помнить две вещи: во-первых, если constructor срабатывает единожды для одного элемента, то connectedCallback срабатывает каждый раз, когда элемент вставляется в DOM, и во-вторых, attributeChangedCallback может сработать раньше connectedCallback, поэтому, если отложить создание разметки с конструктора до connectedCallback, это может привести к ошибке. Метод можно использовать для назначения обработчиков событий или для дргих тяжёлых операций, например, соединения с сервером.


Точно так же, как можно отследить вставку элемента в DOM, можно отследить и удаление. За это отвечает метод disconnectedCallback. Он срабатывает, например, при удалении элемента из DOM методом remove(). Обратите внимание: если элемент удалён из DOM-дерева, но у вас есть ссылка на элемент, он опять может быть вставлен в DOM, и при этом повторно сработает connectedCallback. При удалении можно, например, прекратить обновлять данные в компоненте, удалить таймеры, удалить назначенные в connectedCallback обработчики событий или закрыть соединение с сервером. Обратите внимание, что disconnectedCallback не гарантирует выполнение своего кода — например, при закрытии пользователем страницы метод вызван не будет.


Самым редко используемым коллбэком является метод adoptedCallback. Он срабатывает, когда элемент меняет свойство ownerDocument. Это происходит, если, например, создать новое окно и переместить элемент в него.


Взаимодействие с пользовательским элементом


Управляется компонент, как мы уже поняли, значением атрибутов напрямую или через свойства. А вот для того чтобы передать данные из компонента наружу, можно использовать CustomEvents. В случае нашего компонента будет рационально добавить событие изменения состояния, чтобы его можно было прослушать и отреагировать. Для этого добавим в конструкторе свойство events с двумя объектами CustomEvent:


this.events = {
  "close": new CustomEvent("x-spoiler.changed", {
    bubbles: true,
    detail: {opened: false},
  }),
  "open": new CustomEvent("x-spoiler.changed", {
    bubbles: true,
    detail: {opened: true},
  }),
};


Также отредактируем attributeChangedCallback, чтобы при изменении opened отправлялось то или иное событие:


this.dispatchEvent(this.events[opened ? "open" : "close"]);


Теперь мы можем прослушивать интересующие нас события, чтобы узнавать об изменении состояния спойлера.


Новое демо.


Ещё немного о customElements


При регистрации элемента мы использовали метод define глобального объекта customElements. Помимо этого у него есть ещё два полезных метода.


customElements.get(name) вернёт конструктор пользовательского элемента, зарегистрированного под именем name, если такой есть, либо undefined.


customElements.whenDefined(name) вернёт промис, который будет выполнен успешно тогда, когда элемент с именем name будет зарегистрирован, или незамедлительно, если элемент уже зарегистрирован. Особенно удобно это с использванием await, но это уже другая тема.


Расширение пользовательских элементов


Мы можем наследоваться от класса пользовательского элемента, чтобы создавать новые пользовательские элементы на основе существующих. Например, мы можем расширить наш спойлер и добавить ему при необходимости какой-либо функционал. При этом важно не забыть вызвать аналогичный метод родительского класса через super.methodName(), если это необходимо (в случае конструктора и super() это обязательно).


Расширение стандартных элементов


Спецификация разрешает расширять и стандартные HTML-теги. Например, вам нужна своя реализация кнопки, но при этом вам желательно сохранить уже имеющийся функционал браузерных кнопок, например, правильную работу с атрибутами disabled, tabindex, type и прочими. Однако, тут есть ряд особенностей.


Во-первых, при объявлении класса расширять необходимо класс нужного вам тега. В случае с кнопкой, это будет класс HTMLButtonElement. Полный список классов можно найти в спецификации.


Во-вторых, при регистрации элемента третьим параметром необходимо передать объект опций, в котором указывается, какой именно тег вы хотите расширить (нескольким тегам может соответствовать один и тот же класс).


В-третьих, создаётся такой пользовательский элемент как обычный тег, который нужно было расширить, но с атрибутом is, равным имени пользовательского элемента. Если же элемент создается через document.createElement, is передаётся как свойство второго аргумента.


Выглядеть это будет так:


class FancyButton extends HTMLButtonElement { }

customElements.define("fancy-button", FancyButton, {extends: "button"});


// создание элемента через document.createElement
let button = document.createElement("button", {is: "fancy-button"});




Стилизация элементов до регистрации


Между тем, как пользователь получит HTML-разметку, и тем, как будет скачан и выполнен JavaScript-код, пройдёт некоторое время. Чтобы как-то стилизовать пользовательские элементы, которые отрисованы в DOM, но ещё не зарегистрированы и не работают должным образом, можно использовать псевдокласс :defined. Самый простой пример использования — скрыть все незарегистрированные пользовательские элементы:


*:not(:defined) {
  display: none;
}


Итого


Мы сделали реиспользуемый веб-компонент на основе технологии пользовательских элементов. Однако, у него есть много минусов. Так, например, у нас нет изоляции стилей: правило section {display: block !important} легко сломает логику работы нашего компонента. Да и вообще навешивать стили непосредственно в JS — плохой тон. Также сложно поменять содержимое спойлера: наши кнопка, секция и обработчик клика пропадут при установке нового содержимого через innerHTML. Для того чтобы действительно поменять содержимое, нужно будет знать и учитывать структуру компонента. А ещё и разметка у нас хранится прямо в конструкторе. Всё это явно не то, что мы хотим от простого реиспользуемого компонента. Для того, чтобы исправить все эти минусы, мы будем использовать другие спецификации.


Теневой DOM (Shadow DOM)


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


Инкапсуляция DOM


Стандартная модель DOM, к которой мы привыкли, предполагает, что все потомки элемента доступны через childNodes, их можно найти через querySelector(), и так далее. DOM — сквозной, где бы не находился параграф текста, он всегда будет найден через document.querySelectorAll("p"). Однако, есть возможность отображать не то, что находится в DOM-дереве, а какую-либо другую разметку, причём так, чтобы она была игнорировалась привычными childNode и querySelector. Самым простым примером такого поведения будет тег с несколькими внутри. Мы добавляем в DOM только , а видим полноценный видеопроигрыватель, с собственной изолированной разметкой (блоками, кнопками и прочим). Всё, что мы видим на экране, как раз располагается в теневом DOM. Как это работает?


В любой элемент времени мы можем добавить теневой DOM с помощью метода attachShadow(). В этот момент вместо обычного DOM-дерева у элемента появляется сразу три других: Shadow DOM, Light DOM и Flattened DOM. Рассмотрим их по отдельности.


Light DOM — это то, что раньше являлось обычным DOM-деревом элемента: все элементы, которые доступны через обычные innerHTML, childNodes или по которым ищет метод querySelectorAll.


Shadow DOM — это DOM-дерево, которое лежит в свойстве shadowRoot элемента. Сразу после вызова attachShadow свойство shadowRoot пустое, но мы можем задать какую-либо разметку через стандартные innerHTML, appendChild или другие способы работы с DOM, вызывая их относительно this.shadowRoot.


Flattened DOM — это результат объединения Shadow DOM и Light DOM. Это то, что пользователь в действительности видит на экране. Это искусственное понятие, необходимое только для понимания механизма работы Shadow DOM. Если получить разметку Light DOM можно через element.innerHTML, разметку Shadow DOM можно через element.shadowRoot.innerHTML, то на Flattened DOM можно только посмотреть в окне браузера. Flattened DOM строится на основе Shadow DOM и в самом простом варианте использования строго ему равен. Таким образом, Light DOM может вообще не отображаться на экране. Например:


Привет!


class Demo extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: "open"});
    this.shadowRoot.innerHTML = "Привет из тени...";
  }
}

customElements.define("x-demo", Demo);


До момента регистрации элемента пользователь будет видеть обычный DOM, т.е. в нашем случае текст «Привет!». Но как только элемент будет зарегистрирован, текст пропадёт, вместо него появится фраза «Привет из тени…».


Обратите внимание: при добавлении Shadow DOM нужно в объекте опций указать режим, в котором этот Shadow DOM будет создан. Для пользовательских элементов рекомендуется использовать open. Это позволит при необходимости взаимодействовать с Shadow DOM через свойство shadowRoot.


Пока не очень полезно. Гораздо больше возможностей нам дают тег и его атрибут name в Shadow DOM и атрибут slot в Light DOM. Они объяснят, где именно во Flattened DOM нужно отображать содержимое Lignt DOM. Работает это так: элементы (их может быть как 0, так и 1 и более) с атрибутом slot и каким-либо значением из Light DOM отображаются в качестве содержимого тега с соответствующим значением атрибута name в Shadow DOM. Если для какого-либо тега не нашлось подходящего содержимого, отображается его содержимое из Shadow DOM. Если есть тег без атрибута name, в нём отображается всё содержимое Light DOM без атрибута slot.


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


Этот вариант хорошо подходит для нашего компонента спойлера: попробуем убрать всю «обвязку» (секцию и кнопку) в Shadow DOM, и отобразим содержимое Light DOM с помощью тега . Для этого исправим конструктор:


this.attachShadow({mode: "open"});
this.shadowRoot.innerHTML = `
  
  
`;


Так как кнопка для открытия теперь находится в Shadow DOM, необходимо также заменить this.querySelector("button") на this.shadowRoot.querySelector("button"). Также нужно поступить и с поиском тега section.


Теперь мы можем безопасно менять текст спойлера, например, через textContent пользовательского элемента. Наша разметка инкапсулирована и никак не мешает работать с содержимым элемента. Разработчику не нужно знать, как устроен элемент изнутри, чтобы работать с ним. Демо.


Рассмотрим пример посложнее: это будет искусственный пример, который будет раскидывать по четырём цветным «коробкам» разных цветов DOM-элементы из Light DOM. Можете попробовать поменять содержимое тега, чтобы посмотреть, как оно работает: демо.


В этом примере видно, что если в Light DOM находится несколько элементов с одинаковым значением атрибута slot, все они попадут в  с аналогичным name (красная секция). Если у элемента в Light DOM не указан slot, он попадёт в Shadow DOM в тег , который не имеет атрибута name (белая секция). Ну и если у нас вообще нет элементов с атрибутом slot с каким-либо значением, соответствующий тег останется со своей собственной разметкой (желтая секция).


Инкапсуляция стилей


С изоляцией DOM и соотношением Light DOM / Shadow DOM вроде разобрались, теперь поговорим об изоляции стилей.


В последнем примере я уже начал использовать изоляцию стилей. Если в Shadow DOM находится тег


Вероятно, вам нужно предоставить какую-то возможность по стилизации вашего пользовательского элемента снаружи, помимо предустановленных через :host и :host-context стилей.


В случае с самим пользовательским элементом всё просто — стили на нём при совпадении со стилями в :host будут перебиты. Таким образом, стилизация по тегу будет перебивать стилизацию через :host. Это позволит управлять контейнером пользовательского элемента.


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


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


Пользовательские элементы с теневым DOM могут быть использованы в теневом DOM другого компонента, на это нет никаких ограничений. Также можно использовать и браузерные теги, которые используют теневой DOM, например, тег .


Доступ к теневому DOM открыт любому JavaScript-коду при условии, что теневой DOM был добавлен с опцией {mode: "open"}.


Что ещё?


У вас есть возможность создавать так называемый «закрытый» Shadow DOM, передав опцию mode со значением closed. При этом Shadow DOM будет недоступен через свойство shadowRoot, оно будет возвращать null. Ссылку на настоящий shadowRoot вернёт сам метод attachShadow(), и если сохранить её в переменную, можно работать с shadowRoot в пределах конструтора и нельзя как-то повлиять в последующем. Это сильно ограничивает работу с Shadow DOM, поэтому не рекомендуется к использованию в пользовательских элементах.


Теги генерируют событие slotchange, если после инициализации пользовательского элемента произошло изменение в Light DOM, которое затронуло этот слот. Таким образом, можно отслеживать изменения и реагировать на них.


Также теги имеют метод assignedNodes, который возвращает массив DOM-узлов из Light DOM, которые были помещены в этот слот. Если

© Habrahabr.ru