
Продолжая тему из моей предыдущей статьи о веб-компонентах, я хочу подробнее рассмотреть их применение для решения реальных задач. Сегодня мы напишем простую, но полнофункциональную реализацию Слайдера, в процессе познакомившись с такими ключевыми концепциями, как Shadow DOM и Declarative Shadow DOM.
Что нам даёт использование Shadow DOM:
Возможность работать со слотами (
<slot>) для композиции контентаПолная изоляция стилей компонента от глобальных таблиц CSS
Инкапсуляция DOM-дерева компонента
Итак, существует два основных способа создания веб-компонента с Shadow DOM:
Императивный подход (в JavaScript-коде): использование метода
this.attachShadow({ mode: "open" })внутри класса компонента.Декларативный подход (в HTML-разметке): с помощью атрибута
shadowrootmode="open", который добавляется непосредственно к элементу<template>.
Рассмотрим каждый подробнее
Императивный подход
Рассмотрим базовую реализацию. Вот код, который создаёт основу для нашего компонента:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Components - Slider</title>
<link rel="stylesheet" href="/app.css" />
</head>
<body>
<slider-component></slider-component>
</body>
<script>
class SliderComponent extends HTMLElement {
constructor() {
super()
this._shadowRoot = this.attachShadow({ mode: "open" })
}
}
customElements.define('slider-component', SliderComponent)
</script>
</html
Как видите, в конструкторе класса мы вызываем метод attachShadow с параметром mode: "open". Это создаёт открытое (open) Shadow DOM-дерево, прикреплённое к нашему элементу.
В инструментах разработки Chrome (DevTools) теперь можно увидеть, что элемент <slider-component> содержит #shadow-root (open), который визуально подсвечивается, показывая границы изолированного DOM-поддерева.

Декларативный подход
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Components - Slider</title>
<link rel="stylesheet" href="/app.css" />
</head>
<body>
<slider-component>
<template shadowrootmode="open"></template>
</slider-component>
</body>
<script>
class SliderComponent extends HTMLElement {
constructor() {
super()
}
}
customElements.define('slider-component', SliderComponent)
</script>
</html>
Мы добились аналогичного результата — компонент теперь использует Shadow DOM. Однако ключевое отличие в том, что при декларативном подходе Shadow DOM формируется немедленно, в момент загрузки HTML, ещё до выполнения какого-либо JavaScript-кода. Это открывает возможности для SSR (Server-Side Rendering) и обеспечивает более предсказуемое отображение контента.
Теперь продолжим создание слайдера. Для этого наполним наш компонент и приложение стилями. Создадим отдельный файл CSS для приложения а также один для изолированных стилей.
app.css
body {
display: flex;
padding: 1em;
--bg: white;
--shadow: 1px solid #eee;
--slider--gap: 1em;
}
.slider {
width: 305px;
}
.slider__item {
background-color: #eee;
width: 80px;
height: 80px;
border-radius: .8em;
}
slider.css:
:host {
position: relative;
display: flex;
align-items: center;
width: 100%;
padding: 0 1em;
box-sizing: border-box;
}
.slides {
display: flex;
gap: var(--slider--gap, 1em);
overflow-x: scroll;
scroll-snap-type: x mandatory;
scrollbar-width: none;
-ms-overflow-style: none;
}
::slotted(.slider__item) {
flex-shrink: 0;
scroll-snap-align: center;
}
:host::-webkit-scrollbar {
display: none;
}
.navigation-control {
position: absolute;
width: 2em;
height: 2em;
background-color: var(--bg);
border-radius: 50%;
box-shadow: var(--shadow);
display: flex;
align-items: center;
display: flex;
align-items: center;
justify-content: center;
opacity: .5;
outline: none;
transition: .3s;
&:hover,
&:focus {
opacity: 1;
transform: scale(1.1);
}
}
.navigation-control__left {
left: .3em;
}
.navigation-control__right {
right: .3em;
}
Как уже упоминалось ранее, ключевое преимущество Shadow DOM — это полная изоляция стилей. Классы, объявленные внутри CSS-файла, подключённого к компоненту, не будут конфликтовать с глобальными стилями и не попадут под влияние внешних CSS-правил.
Кроме того, появляется доступ к специальным CSS-селекторам, таким как:
:host— для стилизации самого элемента-хозяина;::slotted()— для стилизации контента, проецируемого в слоты.
Подробную информацию обо всех доступных селекторах и областях их применения можно найти в документации на MDN.
И ещё один важный нюанс: несмотря на изоляцию, CSS-переменные (Custom Properties) наследуются через границы Shadow DOM. Это позволяет гибко настраивать тему компонента извне. Отличной практикой является указание значения по умолчанию на случай, если переменная не задана: var(--slider--gap, 1em)
Теперь разберём структуру HTML для нашего slider-component. Всё содержимое, расположенное внутри элемента <template>, будет помещено в Shadow DOM компонента. Контент, размещённый непосредственно между тегами <slider-component></slider-component> в основном документе, будет проецироваться внутрь элемента <slot>, определённого в шаблоне.
index.html:
<slider-component>
<template shadowrootmode="open">
<link rel="stylesheet" href="/slider.css">
<div class="navigation-control navigation-control__left" tabindex="0" data-control="prev">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" height="1em" width="1em">
<path d="M642-48 209-480l433-432 103 103-329 329 329 329L642-48Z" />
</svg>
</div>
<div class="navigation-control navigation-control__right" tabindex="0" data-control="next">
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 -960 960 960" width="1em">
<path d="M321-48 218-151l329-329-329-329 103-103 432 432L321-48Z" />
</svg>
</div>
<slot class="slides" data-id="slides"></slot>
</template>
<h1>Hello World!</h1>
</slider-component>
В инструментах разработки Chrome (DevTools):

Слоты можно именовать. Для этого используется атрибут name: <slot name="slot-1"></slot>. Чтобы направить контент именно в этот слот, элементу в основном документе нужно указать соответствующий атрибут slot: <h1 slot="slot-1">Hello World!</h1>:
<slider-component>
<template shadowrootmode="open">
<link rel="stylesheet" href="/slider.css">
<div class="navigation-control navigation-control__left" tabindex="0" data-control="prev">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" height="1em" width="1em">
<path d="M642-48 209-480l433-432 103 103-329 329 329 329L642-48Z" />
</svg>
</div>
<div class="navigation-control navigation-control__right" tabindex="0" data-control="next">
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 -960 960 960" width="1em">
<path d="M321-48 218-151l329-329-329-329 103-103 432 432L321-48Z" />
</svg>
</div>
<slot class="slides" data-id="slides"></slot>
</template>
<div class="slider__item" data-id="slider-item"></div>
<div class="slider__item" data-id="slider-item"></div>
<div class="slider__item" data-id="slider-item"></div>
<div class="slider__item" data-id="slider-item"></div>
<div class="slider__item" data-id="slider-item"></div>
<div class="slider__item" data-id="slider-item"></div>
<div class="slider__item" data-id="slider-item"></div>
<div class="slider__item" data-id="slider-item"></div>
<div class="slider__item" data-id="slider-item"></div>
<div class="slider__item" data-id="slider-item"></div>
</slider-component>
Добавим корневому элементу slider-component CSS-класс slider:
<slider-component class="slider"></slider-component>
Поскольку этот класс применяется к самому пользовательскому элементу (к «хосту»), а не к содержимому внутри Shadow DOM, он будет доступен в глобальной области видимости. Соответственно, стили для этого класса будут применяться из основной таблицы стилей app.css.
Осталось добавить логику обработки клика:
class SliderComponent extends HTMLElement {
constructor() {
super()
this.slidesEl = this.shadowRoot.querySelector('[data-id=slides]')
}
get deltaX() {
return this.hasAttribute('delta-x') ? Number(this.getAttribute('delta-x')) : 100
}
connectedCallback() {
this.shadowRoot.addEventListener('click', e => {
const { target } = e
const controlEl = target.closest('[data-control]')
if (!controlEl) {
return
}
const { control } = controlEl.dataset
if (control === 'next') {
this.#handleNext()
} else if (control === 'prev') {
this.#handlePrev()
} else {
console.error('Invalid control value')
}
})
}
#handleNext() {
this.slidesEl.scrollTo({ behavior: "smooth", left: this.slidesEl.scrollLeft + this.deltaX })
}
#handlePrev() {
this.slidesEl.scrollTo({ behavior: "smooth", left: this.slidesEl.scrollLeft - this.deltaX })
}
}
customElements.define('slider-component', SliderComponent)
Итак, в конструкторе я добавил ссылку на элемент слайдера this.slidesEl, чтобы избежать многократного поиска в DOM при каждом клике.
Также было добавлено вычисляемое свойство deltaX, которое определяет величину сдвига слайдов при клике на кнопки навигации. Его значение берётся из атрибута компонента delta-x или, если атрибут не задан, используется значение по умолчанию — 100.
В методе connectedCallback (который вызывается когда компонент добавляется в DOM) регистрируется обработчик кликов. Он анализирует атрибут data-control нажатой кнопки и определяет направление прокрутки — влево или вправо, после чего выполняет соответствующий сдвиг на вычисленное значение deltaX.
Таким образом, мы получаем готовый к использованию слайдер. Для управления его поведением можно:
Задать атрибут
delta-x=для контроля величины сдвигаНастраивать внешний вид через CSS-переменные, например
--bg--slider--gap
В этой статье мы создали полнофункциональный слайдер с использованием Web Components. Получился универсальный компонент, который можно легко встроить в любой проект и гибко настроить через CSS-переменные и HTML-атрибуты.
В рамках следующей статьи я планирую рассмотреть интеграцию Веб-компонентов с популярными JavaScript-фреймворками и библиотеками, такими как React, Vue и Angular. Мы разберём ключевые аспекты взаимодействия, потенциальные проблемы и лучшие практики совместного использования этих технологий.
zababurin
А уничтожение где стоит ? по идее в disconectedCallback должно быть
Dima-Andreev Автор
Хорошее замечание! В данном примере отписку (removeeventlistener) делать не нужно тк Garbage Collection сделает свое дело. При удалении элемента из DOM дерева все слушатели автоматически удаляются. Но! есть нюанс: если бы я делал подписку скажем на document или любой внешний элемент. То да отписка в данном случае была бы обязательна.
Еще ксати заметил данный компонент который создан с использрованием declarative shadow dom нет возможности создать динамически или склонировать тк shadow dom в данном случае привязывается при загрузке страницы. И тут возможно стоит рассмотреть вариант с attachShadow в дополнении к declarative (на случай клонирования или если браузер не поддерживает)
zababurin
Я так обычно последнее время делаю. вроде работает
zababurin
Вы правы и как вы написали так и есть. Я исхожу из того просто, что
Контент должен максимально быстро появляться, по этому откладывать появление теневого дерева нет. Чем раньше тем лучше
В дальнейшем не должно быть проблем в рендером шаблона на стороне сервера. Декларативный подход позволяет на сервере формировать шаблон. Тогда на стороне клиента можно дата атрибутом отключить формирование темплейта, оставив только гидрацию шаблона. Здесь без declarative shadow dom не обойтись просто.
Шаблон компонента рендерится полностью только один раз в идеале. Дальше либо части темплейта обновляются либо значения в полях. По этому динамические части так же остаются как и раньше, только они уже работают с темплейтом, а не выполняют какие то действия до того, как atache не сработал.