Skip to main content

Що таке shadow DOM у веб-розробці

Shadow DOM - це браузерний API, який приєднує окреме ізольоване DOM-дерево до хост-елемента і тримає його HTML, CSS та JS окремо від решти сторінки.

Теорія

TL;DR

  • Як звуконепроникна квартира в гучному будинку: внутрішні стилі залишаються всередині, зовнішні не проникають.
  • Звичайний DOM змішує всі CSS-правила глобально; Shadow DOM проводить жорстку межу на рівні елемента.
  • Браузер виділяє окреме дерево вузлів для кожного shadow root; CSS-селектори зупиняються на цій межі і не перетинають її.
  • mode: 'open' відкриває shadow root для зовнішнього JS; mode: 'closed' ховає його, повертаючи null для host.shadowRoot.
  • Використовуй для повторно використовуваних UI-компонентів, сторонніх вбудовок або мікрофронтендів, де конфлікти стилів - реальна проблема.

Швидкий приклад

html
<!DOCTYPE html> <html> <head> <style>p { color: blue !important; }</style> </head> <body> <p>Звичайний параграф (синій, під впливом CSS сторінки).</p> <div id="host"></div> <script> const shadow = document.getElementById('host').attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style>p { color: red; }</style> <p>Shadow-параграф (червоний, CSS сторінки не має жодного ефекту).</p> `; </script> </body> </html>

Зовнішнє правило !important не може дістатися всередину shadow root. Червоний параграф ігнорує його повністю. Ось і вся суть.

Головна різниця

Без Shadow DOM весь CSS конкурує глобально через наслідування і специфічність. Один невдало названий клас .btn у сторонньому віджеті може перекрити твої стилі кнопки. Shadow DOM проводить жорстку межу: браузер рендерить окреме дерево, де внутрішні стилі відповідають лише внутрішнім елементам, а зовнішні не проникають. Єдиний місток - host.shadowRoot, і тільки коли mode є 'open'.

Коли використовувати

  • Повторно використовувані UI-віджети (dropdown-меню, модальні вікна, date picker), що постачаються з власними стилями.
  • Сторонні вбудовки (чат-віджети, кнопки оплати), які запускаються на невідомих хост-сторінках з непередбачуваними таблицями стилів.
  • Мікрофронтенди, де кілька команд ділять одну сторінку. Без Shadow DOM дві команди, що випадково назвали клас .card по-різному, будуть конфліктувати непомітно. Такі баги важко відстежити до першопричини.
  • Прості статичні сторінки або застосунки з повним контролем над CSS - Shadow DOM там не потрібен. Звичайний DOM простіший.

Відкритий і закритий режим

{ mode: 'open' } відкриває host.shadowRoot для зовнішнього JS. Більшість кастомних елементів і фреймворків використовують саме його.

{ mode: 'closed' } змушує host.shadowRoot повертати null, блокуючи зовнішні запити. Саме так реалізовані панелі Chrome DevTools.

Закритий режим не є межею безпеки. Браузерні DevTools все одно бачать дерево. Він лише запобігає випадковому зовнішньому доступу зі скриптів.

Слоти і проекція light DOM

<slot> всередині shadow root - це плейсхолдер для дочірніх елементів хоста. Ці елементи (light DOM, тобто зовнішній вміст) проектуються туди під час рендерингу.

html
<div id="host"> <span>Я light DOM</span> </div> <script> const shadow = document.getElementById('host').attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style>span { color: green; font-weight: bold; }</style> <slot></slot> `; </script>

<span> рендериться зеленим і жирним, бо прослотований light DOM успадковує shadow-стилі. Якщо потрібно контролювати це явно - використовуй ::slotted(span).

Як браузер це обробляє

Браузер виділяє окремий вузол ShadowRoot, прив'язаний до хост-елемента. Під час рендерингу рушій компонує дочірні елементи light DOM у <slot> тіньового дерева, але пропускає CSS-резолюцію через межу. document.querySelector('p') не знайде параграфи всередині shadow root. Зовнішній JS-доступ можливий лише при mode: 'open' через host.shadowRoot.querySelector('p').

Типові помилки

  1. Звернення до вмісту shadow в закритому режимі
js
const el = document.createElement('div'); document.body.appendChild(el); el.attachShadow({ mode: 'closed' }); // el.shadowRoot === null el.shadowRoot.querySelector('p'); // TypeError: Cannot read properties of null

Рішення: використовуй { mode: 'open' }, якщо потрібен зовнішній доступ, або передавай посилання через власний API компонента.

  1. Очікування, що зовнішній CSS проникне всередину
css
/* Цей селектор не може перетнути межу shadow */ my-element * { color: black !important; }

Для тематизації shadow-компонентів ззовні використовуй CSS custom properties (вони перетинають межу навмисно) або ::part() для відкритих частин.

  1. Забуття, що проекція слота застосовує shadow-стилі
html
<my-el><p>Зовнішній дочірній елемент</p></my-el>

Якщо в <my-el> є <slot>, той <p> рендериться всередині тіні та підхоплює shadow CSS. Явно пиши ::slotted(p) { color: inherit; }, щоб контролювати цю поведінку.

  1. Припущення, що вкладені shadow root повністю ізольовані один від одного

Внутрішні shadow-стилі можуть взаємодіяти із зовнішнім тіньовим деревом через слот-композицію. Тестуй рендеринг явно. Подвійна тінь не означає подвійну ізоляцію автоматично.

Реальні приклади використання

  • Lit (Google): кожен компонент використовує Shadow DOM за замовчуванням, забезпечуючи нульові CSS-конфлікти.
  • Angular Elements: обертає Angular-компоненти в Shadow DOM для вбудовування в не-Angular хост-сторінки.
  • Chrome DevTools: кожна панель працює у закритому shadow root, щоб JS і CSS сторінки не зламали UI інструментів.
  • Vaadin: корпоративні компоненти таблиць і форм постачають узгоджені стилі через Shadow DOM у будь-якому хост-застосунку.

Питання на співбесіді

Q: У чому різниця між open і closed режимом?
A: open відкриває host.shadowRoot для зовнішнього JS. closed повертає там null. Жоден з режимів не заважає браузерним DevTools перевіряти дерево.

Q: Як CSS custom properties працюють через межу shadow?
A: Вони перетинають межу навмисно. Хост-сторінка задає --primary-color: red, і shadow-стилі зчитують його. Це стандартний спосіб тематизації shadow DOM компонентів без порушення ізоляції.

Q: Як працюють слоти і що таке проекція light DOM?
A: <slot> - це плейсхолдер для дочірніх елементів хоста. Безіменний слот бере всіх прямих дітей. Іменовані слоти використовують slot="name" на дочірньому елементі і <slot name="name"> в тіньовому шаблоні.

Q: Чи впливає Shadow DOM на доступність?
A: ARIA на хост-елементі поширюється коректно. Елементи всередині shadow-дерева потребують явних ролей і підписів. Скрінрідери обходять сплощене (composed) дерево, а не тіньове дерево окремо.

Q: (Senior) Як відкрити shadow-внутрішності для зовнішньої тематизації без повного відкриття shadow root?
A: Використовуй ::part(). Позначай внутрішні елементи атрибутом part="button" у shadow-шаблоні. Хост-сторінки стилізують їх через my-el::part(button) { background: var(--theme-color); }. Це явне, контрольоване відкриття без витоку внутрішньої структури.

Приклади

Ізоляція стилів у 10 рядках

html
<!DOCTYPE html> <html> <head> <style>p { color: blue; font-size: 24px; }</style> </head> <body> <p>Звичайний параграф (синій, 24px).</p> <div id="host"></div> <script> const shadow = document.getElementById('host').attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style>p { color: red; font-size: 14px; }</style> <p>Shadow-параграф (червоний, 14px). CSS сторінки його не торкається.</p> `; </script> </body> </html>

Два параграфи, два абсолютно незалежних CSS-контексти. Без !important. Без ігор із назвами класів.

Todo-елемент як кастомний компонент

js
class TodoItem extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> .todo { padding: 1rem; border: 1px solid #ccc; border-radius: 4px; } .done { text-decoration: line-through; opacity: 0.5; } </style> <div class="todo"> <input type="checkbox"> <span class="text"></span> </div> `; const text = shadow.querySelector('.text'); const checkbox = shadow.querySelector('input'); checkbox.addEventListener('change', () => { text.classList.toggle('done', checkbox.checked); }); } connectedCallback() { this.shadowRoot.querySelector('.text').textContent = this.getAttribute('todo') || 'Порожній todo'; } } customElements.define('todo-item', TodoItem);
html
<todo-item todo="Купити молоко"></todo-item> <todo-item todo="Написати тести"></todo-item>

Кожен <todo-item> працює у власному shadow root. CSS хост-сторінки не може перекрити закреслення. Ніяких спільних імен класів, ніяких конфліктів між компонентами.

Тематизація через CSS custom properties і ::part()

js
class FancyButton extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = ` <style> button { background: var(--btn-bg, #0070f3); /* fallback - синій */ color: var(--btn-color, white); padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; } </style> <button part="btn"><slot></slot></button> `; } } customElements.define('fancy-button', FancyButton);

Тематизація з хост-сторінки:

css
/* CSS custom properties перетинають межу shadow */ fancy-button { --btn-bg: #e53e3e; } /* ::part() явно таргетує відкриті частини */ fancy-button::part(btn) { border-radius: 0; }

Саме так виробничі бібліотеки компонентів вирішують тематизацію без відкриття повних shadow-внутрішностей. Автор компонента сам вирішує, що є публічним (part="btn"), а решта залишається прихованою.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?