Часть 2: О внутреннем устройстве V8 и оптимизации кода
Часть 3: Управление памятью, четыре вида утечек памяти и борьба с ними
Часть 4: Цикл событий, асинхронность и пять способов улучшения кода с помощью async / await
Часть 5: WebSocket и HTTP/2+SSE. Что выбрать?
Часть 6: Особенности и сфера применения WebAssembly
Часть 7: Веб-воркеры и пять сценариев их использования
Часть 8: Сервис-воркеры
Часть 9: Веб push-уведомления
Часть 10: Отслеживание изменений в DOM с помощью MutationObserver
Часть 11: Движки рендеринга веб-страниц и советы по оптимизации их производительности
Часть 12: Сетевая подсистема браузеров, оптимизация её производительности и безопасности
Часть 12: Сетевая подсистема браузеров, оптимизация её производительности и безопасности
Часть 13: Анимация средствами CSS и JavaScript
Часть 14: Как работает JS: абстрактные синтаксические деревья, парсинг и его оптимизация
Часть 15: Как работает JS: классы и наследование, транспиляция в Babel и TypeScript
Часть 16: Как работает JS: системы хранения данных
Сегодня, в переводе 17 части материалов, посвящённых особенностям всего, что так или иначе связано с JavaScript, речь пойдёт о веб-компонентах и о различных стандартах, которые направлены на работу с ними. Особое внимание здесь будет уделено технологии Shadow DOM.
Обзор
Веб-компоненты — это семейство API, предназначенных для описания новых элементов DOM, подходящих для повторного использования. Функционал таких элементов отделён от остального кода, их можно применять в веб-приложениях собственной разработки.
Существует четыре технологии, относящиеся к веб-компонентам:
- Shadow DOM (теневой DOM)
- HTML Templates (HTML-шаблоны)
- Custom Elements (пользовательские элементы)
- HTML Imports (HTML-импорт)
В этом материале мы поговорим о технологии Shadow DOM, которая разработана для создания приложений, основанных на компонентах. Она предлагает способы решения распространённых проблем веб-разработки, с которыми вы, возможно, уже сталкивались:
- Изоляция DOM: компонент обладает изолированным деревом DOM (это означает, что команда
document.querySelector()
не позволит обратиться к узлу в теневом DOM компонента). Кроме того, это упрощает систему CSS-селекторов в веб-приложениях, так как компоненты DOM изолированы, что даёт разработчику возможность использовать одни и те же универсальные идентификаторы и имена классов в различных компонентах, не беспокоясь о возможных конфликтах имён. - Изоляция CSS: CSS-правила, описанные внутри теневого DOM, ограничены им. Эти стили не покидают пределов элемента, они не смешиваются с другими стилями страницы.
- Композиция: разработка декларативного API для компонентов, основанного на разметке.
Технология Shadow DOM
Тут предполагается, что вы уже знакомы с концепцией DOM и с соответствующими API. Если это не так — можете почитать этот материал.
Shadow DOM — это, в целом, то же самое, что и обычный DOM, но с двумя отличиями:
- Первое заключается в том, как Shadow DOM создают и используют, в частности, речь идёт об отношении Shadow DOM к остальным частям страницы.
- Второе заключается в поведении Shadow DOM по отношению к странице.
При работе с DOM создаются узлы DOM, которые присоединяются, в качестве дочерних элементов, к другим элементам страницы. В случае с технологией Shadow DOM создают изолированное дерево DOM, которое присоединяется к элементу, но оно отделено от его обычных дочерних элементов.
Это изолированное поддерево называют shadow tree (теневое дерево). Элемент, к которому присоединено такое дерево, называется shadow host (теневой хост-элемент). Всё, что добавляется в теневое поддерево DOM, оказывается локальным для элемента, к которому оно присоединено, в том числе — стили, описываемые с помощью тегов <style>
. Именно так в рамках технологии Shadow DOM обеспечивается изоляция CSS.
Создание Shadow DOM
Shadow root (теневой корневой элемент) — это фрагмент документа, который присоединяется к хост-элементу. Элемент обзаводится теневым DOM тогда, когда к нему присоединяют теневой корневой элемент. Для того, чтобы создать для некоего элемента теневой DOM, нужно воспользоваться командой вида element.attachShadow()
:
var header = document.createElement('header');
var shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.appendChild(document.createElement('<p> Shadow DOM </p>');
Надо отметить, что в спецификации Shadow DOM имеется список элементов, к которым нельзя подключать теневые поддеревья DOM.
Композиция в Shadow DOM
Композиция — это одна из важнейших возможностей Shadow DOM, это способ создания веб-приложений, который применяется в процессе написания HTML-кода. В ходе этого процесса программист комбинирует различные строительные блоки (элементы), из которых состоит страница, вкладывая их, при необходимости, друг в друга. Например, это такие элементы, как <div>
, <header>
, <form>
, и другие, используемые для создания интерфейсов веб-приложений, в том числе, выступающие в роли контейнеров для других элементов.
Композиция определяет возможности элементов, таких, как <select>
, <form>
, <video>
, по включению в их состав других HTML-элементов в качестве дочерних, и возможности организации особого поведения таких конструкций, состоящих из разных элементов.
Например, элемент <select>
имеет средства для рендеринга элементов <option>
в виде выпадающего списка с заранее заданным содержимым элементов такого списка.
Рассмотрим некоторые возможности Shadow DOM применяемые при композиции элементов.
Light DOM
Light DOM — это разметка, создаваемая пользователем вашего компонента. Этот DOM находится за пределами теневого DOM компонента и представляет собой дочерний элемент компонента. Представьте себе, что вы создали пользовательский компонент, называемый <better-button>
, который расширяет возможности стандартного HTML-элемента <button>
, и пользователю нужно добавить в этот новый элемент изображение и какой-то текст. Вот как это выглядит:
<extended-button>
<!-- теги img и span - это Light DOM элемента extended-button -->
<img align="center" src="boot.png" slot="image">
<span>Launch</span>
</extended-button>
Элемент <extended-button>
— это пользовательский компонент, описанный программистом самостоятельно, а HTML-код внутри этого компонента — это его Light DOM — то, что добавил в него пользователь этого компонента.
Теневой DOM в этом примере — это компонент <extended-button>
. Это — локальная объектная модель компонента, которая описывает его внутреннюю структуру, изолированный от внешнего мира CSS, и инкапсулирует детали реализации компонента.
Flattened DOM
Дерево Flattened DOM представляет собой то, как браузер выводит компонент на экран, объединяя Light DOM и Shadow DOM. Именно такое дерево DOM можно видеть в инструментах разработчика, и именно оно выводится на страницу. Выглядеть это может примерно так:
<extended-button>
#shadow-root
<style>…</style>
<slot name="image">
<img align="center" src="boot.png" slot="image">
</slot>
<span id="container">
<slot>
<span>Launch</span>
</slot>
</span>
</extended-button>
Шаблоны
Если вам приходится постоянно применять одни и те же структуры в HTML-разметке веб-страниц, полезно будет воспользоваться неким шаблоном вместо того, чтобы снова и снова писать один и тот же код. Подобное было возможно и раньше, но теперь всё значительно упростилось благодаря появлению HTML-тега <template>
, который пользуется отличной поддержкой современных браузеров. Этот элемент и его содержимое не выводится в DOM, но с ним можно работать из JavaScript. Рассмотрим простой пример:
<template id="my-paragraph">
<p> Paragraph content. </p>
</template>
Если включить такую конструкцию в состав HTML-разметки страницы, содержимое описываемого ей тега <p>
не появится на экране до тех пор, пока не будет явным образом присоединено к DOM документа. Например, это может выглядеть так:
var template = document.getElementById('my-paragraph');
var templateContent = template.content;
document.body.appendChild(templateContent);
Существуют и другие средства, позволяющие достичь того же эффекта, но, как уже было сказано, шаблоны — очень удобный стандартный инструмент, пользующийся хорошей поддержкой браузеров.
Поддержка HTML-шаблонов современными браузерами
Шаблоны полезны и сами по себе, но в полной мере их возможности раскрываются при использовании с пользовательскими элементами. Пользовательские элементы — это тема для отдельного материала, а сейчас, для понимания происходящего, достаточно учитывать то, что API браузеров customElement
позволяет программисту описывать собственные HTML-теги и задавать то, как элементы, создаваемые с помощью этих тегов, будут выглядеть на экране.
Определим веб-компонент, который использует наш шаблон в качестве содержимого для своего теневого DOM. Назовём этот новый элемент <my-paragraph>
:
customElements.define('my-paragraph',
class extends HTMLElement {
constructor() {
super();
let template = document.getElementById('my-paragraph');
let templateContent = template.content;
const shadowRoot = this.attachShadow({mode: 'open'}).appendChild(templateContent.cloneNode(true));
}
});
Самое важное, на что тут надо обратить внимание — это то, что мы присоединили клон содержимого шаблона, сделанный с помощью метода Node.cloneNode(), к теневому корню.
Так как мы присоединяем содержимое шаблона к теневому DOM, мы можем включить в шаблон некую информацию о стилизации, в элементе <style>, которая затем будет инкапсулирована в пользовательский элемент. Вся эта схема не будет работать так, как ожидается, если вместо Shadow DOM работать с обычным DOM.
Например, шаблон можно доработать следующим образом, включив в него сведения о стилях:
<template id="my-paragraph">
<style>
p {
color: white;
background-color: #666;
padding: 5px;
}
</style>
<p>Paragraph content. </p>
</template>
Теперь описанный нами пользовательский элемент можно использовать на обычных веб-страницах следующим образом:
<my-paragraph></my-paragraph>
Слоты
У HTML-шаблонов есть несколько недостатков, главный из них заключается в том, что шаблоны содержат статическую разметку, что не позволяет, например, выводить с их помощью содержимое неких переменных для того, чтобы работать с ними так же, как работают со стандартными HTML-шаблонами. Здесь в дело вступает тег <slot>
.
Слоты можно воспринимать как местозаполнители, которые позволяют включать в шаблон собственный HTML-код. Это позволяет создавать универсальные HTML-шаблоны, а затем делать их настраиваемыми, добавляя в них слоты.
Взглянем на то, как будет выглядеть вышеописанный шаблон с использованием тега <slot>
:
<template id="my-paragraph">
<p>
<slot name="my-text">Default text</slot>
</p>
</template>
Если содержимое слота не задано когда элемент включается в разметку, или если браузер не поддерживает работу со слотами, элемент <my-paragraph>
будет включать в себя лишь стандартное содержимое Default text
.
Для того чтобы задать содержимое слота, нужно включить в элемент <my-paragraph>
HTML-код с атрибутом slot
, значение которого эквивалентно имени слота, в который нужно поместить этот код.
Как и ранее, тут может быть всё, что угодно. Например:
<my-paragraph>
<span slot="my-text">Let's have some different text!</span>
</my-paragraph>
Элементы, которые можно помещать в слоты, называются Slotable-элементами.
Обратите внимание на то, что в предыдущем примере мы добавили в слот элемент <span>
, он является так называемым slotted-элементом. У него есть атрибут slot
, которому присвоено значение my-text
, то есть — то же самое значение, которое использовано в атрибуте name
слота, описанного в шаблоне.
После обработки вышеописанной разметки браузером будет создано следующее дерево Flattened DOM:
<my-paragraph>
#shadow-root
<p>
<slot name="my-text">
<span slot="my-text">Let's have some different text!</span>
</slot>
</p>
</my-paragraph>
Обратите внимание на элемент #shadow-root
. Это — всего лишь индикатор существования Shadow DOM.
Стилизация
Компоненты, которые используют технологию Shadow DOM, можно стилизовать на общих основаниях, они могут определять собственные стили, или предоставлять хуки в форме пользовательских свойств CSS, которые позволяют пользователям компонентов переопределять стили, заданные по умолчанию.
▍Стили, описываемые в компонентах
Изоляция CSS — это одно из самых замечательных свойств технологии Shadow DOM. А именно, речь идёт о следующем:
- CSS-селекторы страницы, на которой размещён соответствующий компонент, не влияют на то, что имеется у него внутри.
- Стили, описанные внутри компонента, не оказывают воздействия на страницу. Они изолированы в хост-элементе.
CSS-селекторы, использованные внутри теневого DOM, применяются к содержимому компонента локально. На практике это означает возможность многократного использования одних и тех же идентификаторов и имён классов в разных компонентах и отсутствие необходимости беспокоиться о конфликтах имён. Простые CSS-селекторы означают и более высокую производительность решений, в которых они используются.
Взглянем на элемент #shadow-root
, который определяет некоторые стили:
#shadow-root
<style>
#container {
background: white;
}
#container-items {
display: inline-flex;
}
</style>
<div id="container"></div>
<div id="container-items"></div>
Все вышеописанные стили являются локальными для #shadow-root
.
Кроме того, для включения в #shadow-root
внешних таблиц стилей можно использовать тег <link>
. Такие стили тоже будут локальными.
▍Псевдокласс :host
Псевдокласс :host
позволяет обращаться к элементу, содержащему теневое дерево DOM и стилизовать этот элемент:
<style>
:host {
display: block; /* по умолчанию у пользовательских элементов это display: inline */
}
</style>
Пользуясь псевдоклассом :host
следует помнить о том, что правила родительской страницы имеют более высокий приоритет, чем те, которые заданы в элементе с использованием этого псевдокласса. Это позволяет пользователям переопределять стили хост-компонента, заданные в нём, извне. Кроме того, псевдокласс :host
работает лишь в контексте теневого корневого элемента, за пределами теневого дерева DOM пользоваться им нельзя.
Функциональная форма псевдокласса, :host(<selector>)
, позволяет обращаться к хост-элементу, если он соответствует заданному элементу <selector>
. Это — отличный способ, позволяющий компонентам инкапсулировать поведение, которое реагирует на действия пользователя или на изменение состояния компонента, и позволяет стилизовать внутренние узлы, основываясь на хост-компоненте:
<style>
:host {
opacity: 0.4;
}
:host(:hover) {
opacity: 1;
}
:host([disabled]) { /* стилизация при условии наличия у хост-элемента атрибута disabled. */
background: grey;
pointer-events: none;
opacity: 0.4;
}
:host(.pink) > #tabs {
color: pink; /* задаёт цвет внутреннего узла #tabs если у хост-элемента есть class="pink". */
}
</style>
▍Темы и элементы с псевдоклассом :host-context(<selector>)
Псевдокласс :host-context(<selector>)
соответствует хост-элементу, если он или любые его предки соответствуют заданному элементу <selector>
.
Обычный вариант использования этой возможности заключается в стилизации элементов с помощью тем. Например, часто темы применяют, назначая соответствующий класс тегам <html>
или <body>
:
<body class="lightheme">
<custom-container>
…
</custom-container>
</body>
Псевдокласс :host-context(.lightheme)
будет применяться к <fancy-tabs>
в том случае, если этот элемент является потомком .lightteme
:
:host-context(.lightheme) {
color: black;
background: white;
}
Конструкция :host-context()
может быть полезной для применения тем, но для этой цели лучше использовать хуки с применением пользовательских свойств CSS.
▍Стилизация хост-элемента компонента извне
Хост-элемент компонента можно стилизовать извне, используя имя его тега в качестве селектора:
custom-container {
color: red;
}
Внешние стили имеют более высокий приоритет, чем стили, определённые в теневом DOM.
Предположим, пользователь создал следующий селектор:
custom-container {
width: 500px;
}
Он переопределит правило, заданное в самом компоненте:
:host {
width: 300px;
}
Используя этот подход можно стилизовать лишь сам компонент. Как стилизовать внутренние структуры компонента? Для этой цели используются пользовательские свойства CSS.
▍Создание хуков стилей с использованием пользовательских свойств CSS
Пользователи могут настраивать стили внутренних структур компонентов если автор компонента предоставляет им хуки стилей, применяя пользовательские свойства CSS.
В основе этого подхода лежит механизм, похожий на тот, которым пользуются при работе с тегами <slot>
, но он, в данном случае, применяется к стилям.
Рассмотрим пример:
<!-- main page -->
<style>
custom-container {
margin-bottom: 60px;
- custom-container-bg: black;
}
</style>
<custom-container background>…</custom-container>
Вот что находится внутри теневого дерева DOM:
:host([background]) {
background: var( - custom-container-bg, #CECECE);
border-radius: 10px;
padding: 10px;
}
В данном случае компонент, в качестве цвета фона, использует чёрный, так как именно его задал пользователь. В противном случае цветом фона будет #CECECE
.
В роли автора компонента вы ответственны за то, чтобы сообщить его пользователям о том, какие именно пользовательские CSS-свойства они могут использовать. Считайте это частью открытого интерфейса вашего компонента.
API JavaScript для работы со слотами
API Shadow DOM предоставляет возможности работы со слотами.
▍Событие slotchange
Событие slotchange
вызывается при изменении узлов, помещённых в слот. Например, если пользователь добавляет дочерние узлы в Light DOM или удаляет их из него:
var slot = this.shadowRoot.querySelector('#some_slot');
slot.addEventListener('slotchange', function(e) {
console.log('Light DOM change');
});
Для отслеживания других типов изменений в Light DOM, можно, в конструкторе элемента, использовать MutationObserver
. Подробнее об этом читайте здесь.
▍Метод assignedNodes()
Метод assignedNodes()
может оказаться полезным в том случае, если нужно узнать о том, какие элементы связаны со слотом. Вызов метода slot.assignedNodes()
позволяет узнать о том, какие именно элементы выводятся средствами слота. Использование опции {flatten: true}
позволяет получить стандартное содержимое слота (выводимое в том случае, если к нему не было присоединено никаких узлов).
Рассмотрим пример:
<slot name=’slot1’><p>Default content</p></slot>
Представим, что этот слот размещён в компоненте <my-container>
.
Взглянем на различные варианты использования этого компонента, и на то, что будет выдано при вызове метода assignedNodes()
.
В первом случае мы добавляем в слот собственное содержимое:
<my-container>
<span slot="slot1"> container text </span>
</my-container>
В данном случае вызов assignedNodes()
вернёт [ container text ]
. Обратите внимание на то, что это значение является массивом узлов.
Во втором случае мы не заполняем слот собственным содержимым:
<my-container> </my-container>
Вызов assignedNodes()
вернёт пустой массив — []
.
Если, однако, передать этому методу параметр {flatten: true}
, то его вызов для того же самого элемента выдаст его содержимое, выводимое по умолчанию: [
Default content
].
Кроме того, для того, чтобы получить доступ к элементу внутри слота, вы можете вызвать assignedNodes()
, что позволить узнать о том, какому из слотов компонента назначен ваш элемент.
Модель событий
Поговорим о том, что происходит при всплытии события, возникшего в теневом дереве DOM. Цель события задаётся с учётом инкапсуляции, поддерживаемой технологией Shadow DOM. Когда событие перенаправляется, это выглядит так, как будто оно исходит от самого компонента, а не от его внутреннего элемента, который находится в теневом дереве DOM и является частью этого компонента.
Вот список событий, которые передаются из теневого дерева DOM (некоторым событиям такое поведение не свойственно):
- События фокуса (Focus Events):
blur
,focus
,focusin
,focusout
. - События мыши (Mouse Event)s:
click
,dblclick
,mousedown
,mouseenter
,mousemove
и другие. - События колеса мыши (Wheel Events):
wheel
. - События ввода (Input Events):
beforeinput
,input
. - События клавиатуры (Keyboard Events):
keydown
,keyup
. - События композиции (Composition Events):
compositionstart
,compositionupdate
,compositionend
. - События перетаскивания (Drag Events):
dragstart
,drag
,dragend
,drop
, и так далее.
Пользовательские события
Пользовательские события по умолчанию не покидают пределов теневого дерева DOM. Если вы хотите вызвать событие, и требуется, чтобы оно покинуло пределы Shadow DOM, нужно снабдить его параметрами bubbles: true
и composed: true
. Вот как выглядит вызов подобного события:
var container = this.shadowRoot.querySelector('#container');
container.dispatchEvent(new Event('containerchanged', {bubbles: true, composed: true}));
Поддержка Shadow DOM браузерами
Для того чтобы узнать, поддерживает ли браузер технологию Shadow DOM, можно проверить наличие attachShadow
:
const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;
Вот сведения о поддержке этой технологии различными браузерами.
Поддержка технологии Shadow DOM в браузерах
Итоги
Теневое дерево DOM ведёт себя не так, как обычное дерево DOM. В частности, по словам автора данного материала, в библиотеке SessionStack это выражается в усложнении процедуры отслеживания изменений DOM, сведения о которых нужны для воспроизведения того, что происходило со страницей. А именно, для отслеживания изменений используется MutationObserver
. При этом теневое дерево DOM не вызывает события MutationObserver
в глобальной области видимости, что приводит к необходимости использования особых подходов для работы с компонентами, использующими Shadow DOM.
Практика показывает, что всё больше современных веб-приложений используют Shadow DOM, что позволяет говорить о том, что эту технологию, вероятно, ждёт дальнейшее развитие и распространение.
Уважаемые читатели! Пользуетесь ли вы веб-компонентами, построенными на основе технологии Shadow DOM?
Автор: ru_vds