Що таке 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-компонентів, сторонніх вбудовок або мікрофронтендів, де конфлікти стилів - реальна проблема.
Швидкий приклад
<!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 = ``;
</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, тобто зовнішній вміст) проектуються туди під час рендерингу.
<div id="host">
<span>Я light DOM</span>
</div>
<script>
const shadow = document.getElementById('host').attachShadow({ mode: 'open' });
shadow.innerHTML = ``;
</script><span> рендериться зеленим і жирним, бо прослотований light DOM успадковує shadow-стилі. Якщо потрібно контролювати це явно - використовуй ::slotted(span).
Як браузер це обробляє
Браузер виділяє окремий вузол ShadowRoot, прив'язаний до хост-елемента. Під час рендерингу рушій компонує дочірні елементи light DOM у <slot> тіньового дерева, але пропускає CSS-резолюцію через межу. document.querySelector('p') не знайде параграфи всередині shadow root. Зовнішній JS-доступ можливий лише при mode: 'open' через host.shadowRoot.querySelector('p').
Типові помилки
- Звернення до вмісту shadow в закритому режимі
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 компонента.
- Очікування, що зовнішній CSS проникне всередину
/* Цей селектор не може перетнути межу shadow */
my-element * { color: black !important; }Для тематизації shadow-компонентів ззовні використовуй CSS custom properties (вони перетинають межу навмисно) або ::part() для відкритих частин.
- Забуття, що проекція слота застосовує shadow-стилі
<my-el><p>Зовнішній дочірній елемент</p></my-el>Якщо в <my-el> є <slot>, той <p> рендериться всередині тіні та підхоплює shadow CSS. Явно пиши ::slotted(p) { color: inherit; }, щоб контролювати цю поведінку.
- Припущення, що вкладені 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 рядках
<!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 = ``;
</script>
</body>
</html>Два параграфи, два абсолютно незалежних CSS-контексти. Без !important. Без ігор із назвами класів.
Todo-елемент як кастомний компонент
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);<todo-item todo="Купити молоко"></todo-item>
<todo-item todo="Написати тести"></todo-item>Кожен <todo-item> працює у власному shadow root. CSS хост-сторінки не може перекрити закреслення. Ніяких спільних імен класів, ніяких конфліктів між компонентами.
Тематизація через CSS custom properties і ::part()
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 custom properties перетинають межу shadow */
fancy-button { --btn-bg: #e53e3e; }
/* ::part() явно таргетує відкриті частини */
fancy-button::part(btn) { border-radius: 0; }Саме так виробничі бібліотеки компонентів вирішують тематизацію без відкриття повних shadow-внутрішностей. Автор компонента сам вирішує, що є публічним (part="btn"), а решта залишається прихованою.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.