Shadow DOM позволяет веб-разработчикам создавать изолированные DOM и CSS для веб-компонентов.
Сводка
Shadow DOM устраняет хрупкость создания веб-приложений. Это связано с глобальным характером HTML, CSS и JS. За эти годы мы изобрели огромное количество
инструментов для решения этих проблем. Например, если вы используете новый идентификатор или класс HTML, вы не можете знать, будет ли он конфликтовать с существующим именем, используемым на странице.
Появляются незаметные ошибки, специфика CSS становится огромной проблемой (!important все!), селекторы стилей выходят из-под контроля, а производительность может пострадать. и так далее.
Shadow DOM исправляет CSS и DOM. Она добавляет на веб-платформу ограниченные стили. Без инструментов или соглашений об именах вы можете объединять CSS с разметкой, скрывать детали реализации и создавать автономные компоненты на чистом JavaScript.
Введение
Shadow DOM – это один из трех стандартов веб-компонентов: HTML Templates, Shadow DOM и Custom elements. Раньше в этот список входил и импорт HTML, но теперь он считается устаревшим.
Вам не нужно создавать веб-компоненты, использующие Shadow DOM. Но когда вы это сделаете, вы сможете воспользоваться его преимуществами (область видимости CSS, инкапсуляция DOM, композиция) и создать многократно используемые пользовательские элементы, которые являются устойчивыми, легко настраиваемыми и чрезвычайно многократно используемыми. Если пользовательские элементы позволяют создавать новый HTML-код (с помощью JS API), то Shadow DOM – это способ предоставления HTML- и CSS-кода. Эти два API объединяются, чтобы создать компонент с автономными HTML, CSS и JavaScript.
Shadow DOM – это инструмент для создания приложений на основе компонентов. Поэтому он предлагает решения для распространенных проблем в веб-разработке:
- Изолированная модель DOM. Модель DOM компонента является автономной (например,
document.querySelector()не возвращает узлы в теневой модели DOM компонента). - Область действия CSS. Код CSS, определенный внутри Shadow DOM, действует только в его пределах. Правила стилей не выходят за пределы элемента, а стили страницы не проникают внутрь.
- Композиция. Разработайте декларативный API на основе разметки для своего компонента.
- Упрощает CSS. Ограниченная область видимости DOM позволяет использовать простые селекторы CSS и более общие названия идентификаторов и классов, не беспокоясь о конфликтах имен.
- Продуктивность. Представляйте приложения как фрагменты DOM, а не как одну большую (глобальную) страницу.
Демонстрация fancy-tabs
В этой статье мы будем использовать демонстрационный компонент (<fancy-tabs>) и фрагменты его кода. Если ваш браузер поддерживает API, вы увидите демо-версию ниже. Полный исходный код можно найти на GitHub.
Что такое теневое дерево DOM?
Общие сведения о DOM
HTML – это основа интернета, потому что с ним легко работать. Объявив несколько тегов, вы можете за секунды создать страницу, которая имеет как представление, так и структуру. Однако сам по себе HTML не так уж и полезен. Людям легко понять текстовый язык, но машинам нужно что-то большее. Входит в объектную модель документа, или DOM.
Когда браузер загружает веб-страницу, он делает много интересных вещей. В частности, он преобразует HTML-код автора в интерактивный документ. Чтобы понять структуру страницы, браузер преобразует HTML (статические строки текста) в модель данных (объекты/узлы). Браузер сохраняет иерархию HTML, создавая дерево этих узлов: DOM. Прелесть DOM в том, что это живое представление вашей страницы. В отличие от статического HTML-кода, который мы пишем, узлы, созданные браузером, содержат свойства, методы и, самое главное, могут управляться программами! Вот почему мы можем создавать элементы DOM напрямую с помощью JavaScript:
const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);
создает следующую разметку HTML:
<body>
<header>
<h1>Hello DOM</h1>
</header>
</body>
Это все хорошо. Тогда что такое теневое дерево DOM?
DOM… в тени
Теневое дерево DOM – это обычное дерево DOM, которое отличается от него только тем, как оно создается и используется, а также как оно ведет себя по отношению к остальной части страницы. Обычно вы создаете узлы DOM и добавляете их как дочерние элементы другого элемента. С помощью теневой модели DOM вы создаете дерево DOM с ограниченной областью видимости, которое прикреплено к элементу, но отделено от его фактических дочерних элементов. Это поддерево с ограниченной областью видимости называется теневым деревом. Элемент, к которому оно прикреплено, называется теневым хостом. Все, что вы добавляете в теневое дерево, становится локальным для элемента хоста, включая <style>. Вот как Shadow DOM
достигает области действия стиля CSS.
Создание теневой модели DOM
Корневой элемент Shadow DOM – это фрагмент документа, который прикрепляется к элементу-хосту.
Именно благодаря этому элементу у компонента появляется Shadow DOM. Чтобы создать Shadow DOM для элемента, вызовите element.attachShadow():
const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().
// header.shadowRoot === shadowRoot
// shadowRoot.host === header
Для заполнения теневого корня я использую .innerHTML, но вы можете использовать и другие API DOM. Это интернет. У нас есть выбор.
В спецификации перечислены элементы, которые не могут содержать теневое дерево. Элемент может быть в списке по нескольким причинам:
- Браузер уже содержит собственную внутреннюю модель Shadow DOM для элемента (
<textarea>,<input>). - Элемент не может содержать Shadow DOM (
<img>).
Например, не сработает следующее:
document.createElement('input').attachShadow({mode: 'open'});
// Error. `<input>` cannot host shadow dom.
Создание Shadow DOM для специального элемента
Shadow DOM особенно полезен при создании пользовательских элементов. Используйте Shadow DOM, чтобы разделить HTML, CSS и JS элемента и создать веб-компонент.
Пример. Специальный элемент прикрепляет к себе Shadow DOM, инкапсулируя свои DOM и CSS.
// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
// Attach a shadow root to <fancy-tabs>.
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
<div id="tabs">...</div>
<div id="panels">...</div>
`;
}
...
});
Здесь происходит несколько интересных вещей. Во-первых, пользовательский элемент создает собственный Shadow DOM при создании экземпляра <fancy-tabs>. Это можно сделать в constructor(). Во-вторых, поскольку мы создаем теневой корень, правила CSS внутри <style> будут ограничены <fancy-tabs>.
Композиция и слоты
Композиция – одна из наименее понятных функций Shadow DOM, но, пожалуй, самая важная.
В мире веб-разработки композиция – это способ создания приложений, декларативно из HTML. Приложения состоят из разных элементов (<div>, <header>, <form>, <input>). Некоторые из этих тегов даже работают
друг с другом. Именно благодаря композиции стандартные элементы, такие как <select>, <details>, <form> и <video>, настолько гибки. Каждый из этих тегов принимает определенный HTML-код в качестве дочернего и выполняет с ним определенные действия. Например, <select> может преобразовать <option> и <optgroup> в раскрывающиеся списки и виджеты с множественным выбором. Элемент <details> отображает <summary> как разворачиваемую стрелку. Даже <video> знает, как работать с определенными дочерними элементами: элементы <source> не отображаются, но влияют на поведение видео.
Какое волшебство!
Терминология: Light DOM и Shadow DOM
Композиция Shadow DOM вводит ряд новых фундаментальных понятий в веб-разработке. Прежде чем углубляться в детали, давайте договоримся о терминологии, чтобы говорить на одном языке.
Light DOM
Разметка, которую пишет пользователь вашего компонента. Этот DOM находится за пределами теневого DOM компонента. Это фактические дочерние элементы.
<better-button>
<!-- the image and span are better-button's light DOM -->
<img src="gear.svg" slot="icon">
<span>Settings</span>
</better-button>
Shadow DOM
DOM, который пишет автор компонента. Shadow DOM является локальным для компонента и определяет его внутреннюю структуру, CSS с ограниченной областью действия и инкапсулирует детали реализации. Он также может определять, как отображать разметку, созданную пользователем вашего компонента.
#shadow-root
<style>...</style>
<slot name="icon"></slot>
<span id="wrapper">
<slot>Button</slot>
</span>
Упрощенное дерево DOM
Результат распределения браузером Light DOM пользователя в Shadow DOM, который вы создали. Плоское дерево – это то, что вы в конечном итоге видите в DevTools и что отображается на странице.
<better-button>
#shadow-root
<style>...</style>
<slot name="icon">
<img src="gear.svg" slot="icon">
</slot>
<span id="wrapper">
<slot>
<span>Settings</span>
</slot>
</span>
</better-button>
Элемент <slot>
Shadow DOM объединяет разные деревья DOM с помощью элемента <slot>.
Слоты – это заполнители внутри компонента, которые пользователи могут заполнить собственной разметкой. Определив один или несколько слотов, вы разрешаете внешней разметке отображаться в теневой модели DOM вашего компонента. По сути, вы говорите: "Отрисуйте разметку пользователя здесь".
Элементы могут пересекать границу Shadow DOM, если их приглашает <slot>. Эти элементы называются распределенными узлами. Концептуально,
распределенные узлы могут показаться немного странными. Слоты не перемещают DOM физически, а отрисовывают его в другом месте внутри Shadow DOM.
Компонент может определять в своем Shadow DOM любое количество слотов. Слоты могут быть пустыми или содержать резервный контент. Если пользователь не предоставляет контент Light DOM, то слот отрисовывает резервный контент.
<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>
<slot>fallback content</slot> <!-- default slot with fallback content -->
<slot> <!-- default slot entire DOM tree as fallback -->
<h2>Title</h2>
<summary>Description text</summary>
</slot>
Вы также можете создавать именованные слоты. Именованные слоты – это определенные отверстия в вашем теневом DOM, на которые пользователи ссылаются по имени.
Пример – слоты в Shadow DOM элемента <fancy-tabs>:
#shadow-root
<div id="tabs">
<slot id="tabsSlot" name="title"></slot> <!-- named slot -->
</div>
<div id="panels">
<slot id="panelsSlot"></slot>
</div>
Пользователи компонента объявляют <fancy-tabs> следующим образом:
<fancy-tabs>
<button slot="title">Title</button>
<button slot="title" selected>Title 2</button>
<button slot="title">Title 3</button>
<section>content panel 1</section>
<section>content panel 2</section>
<section>content panel 3</section>
</fancy-tabs>
<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
<h2 slot="title">Title</h2>
<section>content panel 1</section>
<h2 slot="title" selected>Title 2</h2>
<section>content panel 2</section>
<h2 slot="title">Title 3</h2>
<section>content panel 3</section>
</fancy-tabs>
Если вам интересно, как выглядит плоское дерево, то вот пример:
<fancy-tabs>
#shadow-root
<div id="tabs">
<slot id="tabsSlot" name="title">
<button slot="title">Title</button>
<button slot="title" selected>Title 2</button>
<button slot="title">Title 3</button>
</slot>
</div>
<div id="panels">
<slot id="panelsSlot">
<section>content panel 1</section>
<section>content panel 2</section>
<section>content panel 3</section>
</slot>
</div>
</fancy-tabs>
Обратите внимание, что наш компонент может обрабатывать разные конфигурации, но плоское дерево DOM остается прежним. Мы также можем переключиться с <button> на <h2>. Этот компонент был создан для обработки различных типов дочерних элементов… точно так же, как и <select>!
Стилизация
Существует множество вариантов стилизации веб-компонентов. Компонент, использующий Shadow DOM, может быть стилизован основной страницей, определять собственные стили или предоставлять хуки (в виде пользовательских свойств CSS), чтобы пользователи могли переопределять значения по умолчанию.
Стили, заданные компонентом
Самая полезная функция Shadow DOM – это ограниченная область видимости CSS.
- Селекторы CSS внешней страницы не применяются внутри компонента.
- Стили, определенные внутри, не выходят за пределы. Они ограничены главным элементом.
Селекторы CSS, используемые в Shadow DOM, применяются локально к вашему компоненту. На практике это означает, что мы можем снова использовать общие имена id/class, не беспокоясь о конфликтах в других местах страницы. Внутри Shadow DOM рекомендуется использовать более простые селекторы CSS. и повысить эффективность рекламы.
Пример. Стили, определенные в корневом элементе Shadow DOM, являются локальными.
#shadow-root
<style>
#panels {
box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
background: white;
...
}
#tabs {
display: inline-flex;
...
}
</style>
<div id="tabs">
...
</div>
<div id="panels">
...
</div>
Таблицы стилей также ограничены теневым деревом:
#shadow-root
<link rel="stylesheet" href="styles.css">
<div id="tabs">
...
</div>
<div id="panels">
...
</div>
Вы когда-нибудь задумывались, как элемент <select> отображает виджет множественного выбора (вместо раскрывающегося списка), когда вы добавляете атрибут multiple:
<select multiple>
<option>Do</option>
<option selected>Re</option>
<option>Mi</option>
<option>Fa</option>
<option>So</option>
</select>
<select> может по-разному стилизовать себя в зависимости от объявленных вами атрибутов. Веб-компоненты могут задавать стили для самих себя с помощью селектора :host.
Пример – компонент, который стилизует сам себя
<style>
:host {
display: block; /* by default, custom elements are display: inline */
contain: content; /* CSS containment FTW. */
}
</style>
Однако у :host есть одна особенность: правила на родительской странице имеют более высокий приоритет, чем правила :host, заданные в элементе. То есть внешние стили побеждают. Это позволяет пользователям переопределять стили верхнего уровня извне. Кроме того, селектор :host работает только в контексте корневого элемента Shadow DOM, поэтому его нельзя использовать за пределами Shadow DOM.
Функциональная форма :host(<selector>) позволяет настроить таргетинг на хост, если он соответствует <selector>. Это отличный способ инкапсулировать в компоненте поведение, реагирующее на действия пользователя, или задать стиль внутренних узлов на основе хоста.
<style>
:host {
opacity: 0.4;
will-change: opacity;
transition: opacity 300ms ease-in-out;
}
:host(:hover) {
opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
background: grey;
pointer-events: none;
opacity: 0.4;
}
:host(.blue) {
color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>
Стилизация на основе контекста
:host-context(<selector>) соответствует компоненту, если он или любой из его предков соответствует <selector>. Обычно это используется для оформления на основе окружения компонента. Например, многие разработчики применяют класс к элементу <html> или <body>:
<body class="darktheme">
<fancy-tabs>
...
</fancy-tabs>
</body>
:host-context(.darktheme) будет стилизовать <fancy-tabs>, когда он является потомком .darktheme:
:host-context(.darktheme) {
color: white;
background: black;
}
:host-context() может быть полезен для создания тем, но ещё лучше создавать хуки стилей с помощью пользовательских свойств CSS.
Стилизация распределенных узлов
::slotted(<compound-selector>) соответствует узлам, распределенным в <slot>.
Предположим, мы создали компонент бейджа с именем:
<name-badge>
<h2>Eric Bidelman</h2>
<span class="title">
Digital Jedi, <span class="company">Google</span>
</span>
</name-badge>
Shadow DOM компонента может задавать стиль для элементов <h2> и .title:
<style>
::slotted(h2) {
margin: 0;
font-weight: 300;
color: red;
}
::slotted(.title) {
color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
text-transform: uppercase;
}
*/
</style>
<slot></slot>
Как вы помните, <slot> не перемещает light DOM пользователя. Когда узлы распределяются в <slot>, <slot> выполняет отрисовку их DOM, но узлы физически остаются на месте. Стили, которые применялись до распределения, продолжают применяться и после него. Однако при распределении Light DOM может принимать дополнительные стили, определенные Shadow DOM.
Ещё один, более подробный пример от <fancy-tabs>:
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<style>
#panels {
box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
background: white;
border-radius: 3px;
padding: 16px;
height: 250px;
overflow: auto;
}
#tabs {
display: inline-flex;
-webkit-user-select: none;
user-select: none;
}
#tabsSlot::slotted(*) {
font: 400 16px/22px 'Roboto';
padding: 16px 8px;
...
}
#tabsSlot::slotted([aria-selected="true"]) {
font-weight: 600;
background: white;
box-shadow: none;
}
#panelsSlot::slotted([aria-hidden="true"]) {
display: none;
}
</style>
<div id="tabs">
<slot id="tabsSlot" name="title"></slot>
</div>
<div id="panels">
<slot id="panelsSlot"></slot>
</div>
`;
В этом примере есть два слота: именованный слот для заголовков вкладок и слот для содержимого панели вкладок. Когда пользователь выбирает вкладку, мы выделяем ее жирным шрифтом и показываем ее панель. Для этого выбираются распределенные узлы с атрибутом selected. JS-код пользовательского элемента (здесь не показан) добавляет этот атрибут в нужный момент.
Стилизация компонента извне
Есть несколько способов стилизовать компонент извне. Проще всего использовать в качестве селектора название тега:
fancy-tabs {
width: 500px;
color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
box-shadow: 0 3px 3px #ccc;
}
Внешние стили всегда имеют приоритет над стилями, заданными в Shadow DOM. Например, если пользователь задаст селектор fancy-tabs { width: 500px; }, он будет иметь приоритет над правилом компонента :host { width: 650px;}.
Стилизация самого компонента позволит вам зайти так далеко. Но что, если вы хотите стилизовать внутренние элементы компонента? Для этого нам нужны пользовательские свойства CSS.
Как создавать хуки стилей с помощью пользовательских свойств CSS
Пользователи могут настраивать внутренние стили, если автор компонента предоставляет хуки стилей с помощью пользовательских свойств CSS. Концептуально эта идея похожа на <slot>. Вы создаете "плейсхолдеры стилей", которые пользователи могут переопределять.
Пример. <fancy-tabs> позволяет пользователям переопределять цвет фона:
<!-- main page -->
<style>
fancy-tabs {
margin-bottom: 32px;
--fancy-tabs-bg: black;
}
</style>
<fancy-tabs background>...</fancy-tabs>
Внутри Shadow DOM:
:host([background]) {
background: var(--fancy-tabs-bg, #9E9E9E);
border-radius: 10px;
padding: 10px;
}
В этом случае компонент будет использовать black в качестве значения фона, поскольку его предоставил пользователь. В противном случае по умолчанию будет использоваться #9E9E9E.
Дополнительные темы
Создание закрытых корней теневого дерева (не рекомендуется)
Существует ещё один тип Shadow DOM – закрытый. Если вы создадите закрытое теневое дерево, внешний код JavaScript не сможет получить доступ к внутренней структуре DOM вашего компонента. Это похоже на то, как работают нативные элементы, например <video>.
JavaScript не может получить доступ к теневой модели DOM элемента <video>, поскольку браузер реализует ее с помощью закрытого теневого корня.
Пример создания закрытого теневого дерева:
const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div
Закрытый режим также влияет на другие API:
Element.assignedSlot/TextNode.assignedSlotвозвращаетnullEvent.composedPath()для событий, связанных с элементами внутри теневой модели DOM, возвращает []
Вот краткое изложение того, почему вы никогда не должны создавать веб-компоненты с помощью {mode: 'closed'}:
Ложное чувство безопасности. Злоумышленник может легко взломать
Element.prototype.attachShadow.Закрытый режим запрещает коду пользовательского элемента доступ к собственной теневой модели DOM. Это полный провал. Вместо этого вам нужно будет сохранить ссылку на потом, если вы хотите использовать такие вещи, как
querySelector(). Это полностью противоречит первоначальной цели закрытого режима!customElements.define('x-element', class extends HTMLElement { constructor() { super(); // always call super() first in the constructor. this._shadowRoot = this.attachShadow({mode: 'closed'}); this._shadowRoot.innerHTML = '<div class="wrapper"></div>'; } connectedCallback() { // When creating closed shadow trees, you'll need to stash the shadow root // for later if you want to use it again. Kinda pointless. const wrapper = this._shadowRoot.querySelector('.wrapper'); } ... });Закрытый режим делает компонент менее гибким для конечных пользователей. При создании веб-компонентов вы можете забыть добавить какую-либо функцию. Вариант конфигурации. Вариант использования, который нужен пользователю. Например, часто забывают добавить подходящие хуки стилей для внутренних узлов. В закрытом режиме пользователи не могут переопределять настройки по умолчанию и изменять стили. Возможность доступа к внутренним компонентам очень полезна. В конечном счете, пользователи будут форкать ваш компонент, искать другой или создавать свой собственный, если он не делает то, что они хотят :(
Работа с рекламными местами в JS
Shadow DOM API предоставляет утилиты для работы со слотами и распределенными узлами. Они пригодятся при создании пользовательского элемента.
событие slotchange
Событие slotchange активируется, когда изменяются распределенные узлы слота. Например, если пользователь добавляет или удаляет дочерние элементы из light DOM.
const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
console.log('light dom children changed!');
});
Чтобы отслеживать другие типы изменений в Light DOM, вы можете настроить MutationObserver в конструкторе элемента.
Какие элементы отображаются в рекламном месте?
Иногда полезно знать, какие элементы связаны с рекламным местом. Вызовите slot.assignedNodes(), чтобы узнать, какие элементы отображаются в рекламном месте. Параметр {flatten: true} также возвращает резервный контент слота (если узлы не распределяются).
Предположим, что у вас есть следующий код Shadow DOM:
<slot><b>fallback content</b></slot>
| Использование | Позвонить | Результат |
|---|---|---|
| <my-component>текст компонента</my-component> | slot.assignedNodes(); |
[component text] |
| <my-component></my-component> | slot.assignedNodes(); |
[] |
| <my-component></my-component> | slot.assignedNodes({flatten: true}); |
[<b>fallback content</b>] |
Какому слоту назначен элемент?
Также можно ответить на обратный вопрос. element.assignedSlot указывает, какому из слотов компонента назначен ваш элемент.
Модель событий Shadow DOM
Когда событие всплывает из Shadow DOM, его цель корректируется, чтобы сохранить инкапсуляцию, которую обеспечивает Shadow DOM. Это означает, что события перенаправляются так, чтобы они выглядели как исходящие от компонента, а не от внутренних элементов в Shadow DOM. Некоторые события даже не распространяются за пределы теневой модели DOM.
События, которые пересекают границу теневого дерева:
- События фокуса:
blur,focus,focusin,focusout. - События мыши:
click,dblclick,mousedown,mouseenter,mousemoveи т. д. - События Wheel:
wheel - Входные события:
beforeinput,input - События клавиатуры:
keydown,keyup - События композиции:
compositionstart,compositionupdate,compositionend - DragEvent:
dragstart,drag,dragend,dropи т. д.
Советы
Если теневое дерево открыто, вызов event.composedPath() вернет массив узлов, через которые прошло событие.
Использование специальных событий
Специальные DOM-события, которые запускаются на внутренних узлах теневого дерева, не выходят за пределы теневой границы, если только событие не создано с помощью флага composed: true:
// Inside <fancy-tab> custom element class definition:
selectTab() {
const tabs = this.shadowRoot.querySelector('#tabs');
tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}
Если задано значение composed: false (по умолчанию), потребители не смогут прослушивать событие за пределами теневого корня.
<fancy-tabs></fancy-tabs>
<script>
const tabs = document.querySelector('fancy-tabs');
tabs.addEventListener('tab-select', e => {
// won't fire if `tab-select` wasn't created with `composed: true`.
});
</script>
Управление фокусом
Если вы помните модель событий теневой модели DOM, события, которые запускаются внутри теневой модели DOM, корректируются так, чтобы они выглядели так, как будто они исходят от элемента хостинга.
Например, вы нажимаете на элемент <input> внутри корня Shadow DOM:
<x-focus>
#shadow-root
<input type="text" placeholder="Input inside shadow dom">
Событие focus будет выглядеть так, как будто оно поступило из <x-focus>, а не из <input>.
Аналогично, document.activeElement будет <x-focus>. Если теневой корень был создан с помощью mode:'open' (см. закрытый режим), вы также сможете получить доступ к внутреннему узлу, который получил фокус:
document.activeElement.shadowRoot.activeElement // only works with open mode.
Если у вас несколько уровней Shadow DOM (например, один пользовательский элемент находится внутри другого), вам нужно рекурсивно углубиться в корни Shadow DOM, чтобы найти activeElement:
function deepActiveElement() {
let a = document.activeElement;
while (a && a.shadowRoot && a.shadowRoot.activeElement) {
a = a.shadowRoot.activeElement;
}
return a;
}
Ещё один вариант – параметр delegatesFocus: true, который расширяет поведение фокуса элементов в теневом дереве:
- Если вы нажмете на узел внутри Shadow DOM, который не является областью, доступной для фокусировки, фокус будет установлен на первую область, доступную для фокусировки.
- Когда узел внутри Shadow DOM получает фокус,
:focusприменяется к хосту в дополнение к элементу в фокусе.
Пример. Как delegatesFocus: true меняет поведение фокуса
<style>
:focus {
outline: 2px solid red;
}
</style>
<x-focus></x-focus>
<script>
customElements.define('x-focus', class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
const root = this.attachShadow({mode: 'open', delegatesFocus: true});
root.innerHTML = `
<style>
:host {
display: flex;
border: 1px dotted black;
padding: 16px;
}
:focus {
outline: 2px solid blue;
}
</style>
<div>Clickable Shadow DOM text</div>
<input type="text" placeholder="Input inside shadow dom">`;
// Know the focused element inside shadow DOM:
this.addEventListener('focus', function(e) {
console.log('Active element (inside shadow dom):',
this.shadowRoot.activeElement);
});
}
});
</script>
Результат
Выше показан результат, когда элемент <x-focus> находится в фокусе (пользователь нажал на него, перешел с помощью клавиши Tab, focus() и т. д.), нажат элемент Clickable Shadow DOM text или внутренний элемент <input> находится в фокусе (включая autofocus).
Если бы вы задали delegatesFocus: false, то увидели бы следующее:
delegatesFocus: false и внутренний <input> в фокусе.
delegatesFocus: false и <x-focus>
получают фокус (например, у них есть tabindex="0").
delegatesFocus: false и "Clickable Shadow DOM text"
нажаты (или нажата другая пустая область в теневом дереве DOM элемента).
Советы и подсказки
За эти годы я кое-чему научился в области создания веб-компонентов. Думаю, некоторые из этих советов будут полезны для создания компонентов и отладки Shadow DOM.
Используйте CSS-свойство contain
Обычно макет, стиль и отрисовка веб-компонента являются автономными. Используйте изоляцию CSS в :host, чтобы повысить производительность.
<style>
:host {
display: block;
contain: content; /* Boom. CSS containment FTW. */
}
</style>
Сброс наследуемых стилей
Наследуемые стили (background, color, font, line-height и т. д.) продолжают наследоваться в Shadow DOM. То есть по умолчанию они проникают за границу теневой модели DOM. Если вы хотите начать с чистого листа, используйте all: initial;, чтобы сбросить наследуемые стили до их исходного значения, когда они пересекают теневую границу.
<style>
div {
padding: 10px;
background: red;
font-size: 25px;
text-transform: uppercase;
color: white;
}
</style>
<div>
<p>I'm outside the element (big/white)</p>
<my-element>Light DOM content is also affected.</my-element>
<p>I'm outside the element (big/white)</p>
</div>
<script>
const el = document.querySelector('my-element');
el.attachShadow({mode: 'open'}).innerHTML = `
<style>
:host {
all: initial; /* 1st rule so subsequent properties are reset. */
display: block;
background: white;
}
</style>
<p>my-element: all CSS properties are reset to their
initial value using <code>all: initial</code>.</p>
<slot></slot>
`;
</script>
Как найти все пользовательские элементы на странице
Иногда бывает полезно найти специальные элементы, используемые на странице. Для этого вам нужно рекурсивно обойти теневую модель DOM всех элементов, используемых на странице.
const allCustomElements = [];
function isCustomElement(el) {
const isAttr = el.getAttribute('is');
// Check for <super-button> and <button is="super-button">.
return el.localName.includes('-') || isAttr && isAttr.includes('-');
}
function findAllCustomElements(nodes) {
for (let i = 0, el; el = nodes[i]; ++i) {
if (isCustomElement(el)) {
allCustomElements.push(el);
}
// If the element has shadow DOM, dig deeper.
if (el.shadowRoot) {
findAllCustomElements(el.shadowRoot.querySelectorAll('*'));
}
}
}
findAllCustomElements(document.querySelectorAll('*'));
Создание элементов из <template>
Вместо заполнения теневого корня с помощью .innerHTML мы можем использовать декларативный <template>. Шаблоны идеально подходят для объявления структуры веб-компонента.
Пример можно найти в статье Пользовательские элементы: создание многократно используемых веб-компонентов.
История и поддержка браузеров
Если вы следите за развитием веб-компонентов в последние пару лет, то знаете, что в Chrome 35+ и Opera уже некоторое время используется более старая версия Shadow DOM. Blink будет поддерживать обе версии параллельно в течение некоторого времени. В спецификации v0 использовался другой метод создания теневого корня (element.createShadowRoot вместо element.attachShadow в v1). При вызове старого метода по-прежнему создается теневой корень с семантикой v0, поэтому существующий код v0 не будет нарушен.
Если вас интересует старая спецификация v0, ознакомьтесь со статьями на сайте html5rocks: 1, 2, 3. Также рекомендуем ознакомиться со статьей, в которой сравниваются версии 0 и 1 Shadow DOM.
Поддержка браузеров
Shadow DOM v1 поддерживается в Chrome 53 (статус), Opera 40, Safari 10 и Firefox 63. Edge: разработка начата.
Чтобы определить, поддерживает ли браузер Shadow DOM, проверьте наличие attachShadow:
const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;
Полифил
Пока поддержка браузеров не станет широко доступной, полифилы shadydom и shadycss предоставляют вам функцию v1. Shady DOM имитирует область видимости DOM в Shadow DOM, а shadycss – полифилы пользовательских свойств CSS и область видимости стилей, которую предоставляет нативный API.
Установите полифилы:
bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss
Используйте полифилы:
function loadScript(src) {
return new Promise(function(resolve, reject) {
const script = document.createElement('script');
script.async = true;
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// Lazy load the polyfill if necessary.
if (!supportsShadowDOMV1) {
loadScript('/bower_components/shadydom/shadydom.min.js')
.then(e => loadScript('/bower_components/shadycss/shadycss.min.js'))
.then(e => {
// Polyfills loaded.
});
} else {
// Native shadow dom v1 support. Go to go!
}
Инструкции по добавлению стилей в область видимости и созданию прослоек для них можно найти на странице https://github.com/webcomponents/shadycss#usage.
Заключение
Впервые у нас есть примитив API, который правильно определяет область видимости CSS, область видимости DOM и имеет истинную композицию. В сочетании с другими API веб-компонентов, такими как пользовательские элементы, Shadow DOM позволяет создавать по-настоящему инкапсулированные компоненты без хаков и использования устаревших элементов, таких как <iframe>.
Не поймите меня неправильно. Shadow DOM – это, конечно, сложная штука. Но это зверь, которого стоит изучить. Попробуйте поработать с ним. Изучайте и задавайте вопросы!
Дополнительные сведения
- Различия между Shadow DOM v1 и v0
- Introducing Slot-Based Shadow DOM API (блог WebKit)
- Веб-компоненты и будущее модульного CSS Филип Уолтон (Philip Walton)
- Пользовательские элементы: создание многократно используемых веб-компонентов (статья из руководства Google Web Fundamentals).
- Спецификация Shadow DOM v1
- Спецификация пользовательских элементов v1
Часто задаваемые вопросы
Можно ли использовать Shadow DOM v1 уже сейчас?
Да, с помощью полифила. Подробнее о поддержке браузеров…
Какие функции безопасности предоставляет Shadow DOM?
Shadow DOM не является функцией безопасности. Это простой инструмент для определения области действия CSS и скрытия деревьев DOM в компоненте. Если вам нужна настоящая граница безопасности, используйте <iframe>.
Обязательно ли использовать Shadow DOM в веб-компонентах?
Нет. Создавать веб-компоненты, использующие Shadow DOM, необязательно. Однако создание специальных элементов, использующих Shadow DOM, позволяет использовать такие функции, как область видимости CSS, инкапсуляция DOM и композиция.
В чем разница между открытыми и закрытыми корнями Shadow DOM?
Подробнее о закрытых корнях теневой модели DOM…