Методи ізоляції стилів у CSS
Ізоляція стилів у CSS (style isolation) обмежує дію стилів одного компонента так, щоб вони не потрапляли в інші частини сторінки.
Теорія
TL;DR
- Аналогія: квартири в будинку. У кожній свій ремонт і своє освітлення. Сусідів це не стосується.
- Глобальний CSS - один спільний простір імен. Будь-яке правило
.buttonзачіпає всі елементи з цим класом на сторінці. - Ізоляція автоматизує обмеження стилів через конвенції іменування, хешування при збиранні або нативну інкапсуляцію браузера.
- Швидка відповідь на «який метод вибрати»: простий сайт? BEM. React/Vue? CSS Modules. Web Components? Shadow DOM. Динамічні теми? CSS-in-JS.
Швидкий приклад
Глобальний CSS витікає за своєю природою. Ізоляція це зупиняє.
/* Глобальний CSS - витікає */
.primary { background: blue; }
/* Тепер <nav class="primary"> теж стане синім. Ненавмисно. *//* CSS Modules - ізольовано */
/* Button.module.css */
.primary { background: blue; }
/* Після збирання: .Button_primary__abc123 - тільки цей компонент отримає стиль */
import styles from './Button.module.css';
<button className={styles.primary}>Зберегти</button>
/* <nav class="primary"> залишиться незміненим */Інструмент збирання перейменовує .primary на унікальний хеш. Більше нічого на сторінці не може випадково збігтися з ним.
Головна різниця
Браузер парсить CSS у глобальний stylesheet. Специфічність і порядок DOM вирішують, яке правило переважає. Для невеликих сайтів це нормально. Але в компонентному додатку з 50 розробниками .button в одному файлі рано чи пізно зіткнеться з .button в іншому. Ізоляція стилів вирішує цю проблему: перетворює CSS зі спільного простору імен на ізольовані відсіки для кожного компонента. Кожен компонент керує своїми стилями. Без колізій. Без несподіванок каскаду.
Коли що використовувати
- Чистий CSS без інструментів збирання: BEM. Імена класів на кшталт
.button__iconі.button--primaryзапобігають перетинам завдяки конвенції. - React, Vue або Svelte: CSS Modules. Крок збирання автоматично хешує імена класів.
- Багаторазові веб-компоненти: Shadow DOM. Браузер сам забезпечує межу ізоляції.
- Динамічні теми або бібліотеки компонентів: CSS-in-JS (styled-components, Emotion). Ізоляція відбувається в рантаймі для кожного екземпляра.
- Великі команди та монорепозиторії: CSS Modules з TypeScript. Імпорти імен класів стають типобезпечними.
Таблиця порівняння
| Метод | Механізм ізоляції | Інструменти | Вартість у рантаймі | Підходить для |
|---|---|---|---|---|
| BEM | Конвенція іменування: .block__element--modifier | Не потрібні | Немає | Vanilla CSS, прості сайти |
| CSS Modules | Хешування класів при збиранні: Button_button__xYz | Webpack, Vite | Немає | Фреймворки компонентів |
| Shadow DOM | Межа DOM через attachShadow() | Не потрібні (нативно) | Низька | Web Components |
| CSS-in-JS | Унікальний клас на екземпляр у рантаймі | styled-components, Emotion | Середня | Динамічна стилізація |
| Atomic CSS | Утилітарні класи з одним призначенням | Tailwind, UnoCSS | Немає | Utility-first команди |
| Коли використовувати | Без збирання: BEM. Фреймворк: Modules. Кастомні елементи: Shadow. Динамічно: CSS-in-JS. Утилітарно: Tailwind |
Як це працює всередині
Браузер парсить CSS у глобальний stylesheet. BEM вирішує проблему іменуванням: якщо класи достатньо довгі й унікальні, конфлікти малоймовірні. CSS Modules (через PostCSS або Webpack-лоадер) перейменовує класи на хеші до того, як браузер побачить CSS. Тобто .primary стає .Button_primary__abc123 вже на етапі збирання. Shadow DOM іде іншим шляхом: attachShadow({mode: 'open'}) вставляє вузол #shadow-root. Рушії Blink і WebKit примусово застосовують межу. Зовнішні стилі не можуть її перетнути. Внутрішні не можуть витекти.
Типові помилки
1. BEM: одне підкреслення замість двох
/* Неправильно - пласка назва, може збігтися з несумісним селектором */
.button_icon { color: red; }
/* Правильно - подвійне підкреслення позначає відношення «елемент» */
.button__icon { color: red; }Одне підкреслення виглядає як BEM, але порушує ієрархію. Два компоненти можуть мати .button_icon і ти повертаєшся до тієї ж колізії.
2. CSS Modules: спроба змінити об'єкт styles
/* Неправильно - об'єкт зафіксований після збирання, це нічого не дасть */
styles.primary = 'my-override';
/* Правильно - комбінуй класи через шаблонний рядок */
<button className={`${styles.primary} ${styles.active}`}>ОК</button>Імпортований об'єкт styles відображає назви на хешовані рядки. З нього можна читати, але не записувати.
3. Shadow DOM: mode: 'closed' без усвідомленої причини
/* Закритий режим - shadowRoot повертає null ззовні */
this.attachShadow({ mode: 'closed' });
this.shadowRoot.querySelector('button'); /* null - ламає Puppeteer, Playwright, юніт-тести */
/* Відкритий режим - доступний для тестування і DevTools */
this.attachShadow({ mode: 'open' });Закритий режим блокує зовнішній доступ до shadowRoot. Здається безпечнішим, але також ламає тести й більшість інструментів автоматизації. Використовуй 'closed' тільки якщо є конкретна причина для безпеки.
4. Shadow DOM: забули :host для стилів хост-елемента
<my-button class="warning"></my-button>
<script>
/* Неправильно - зовнішній клас не застосується всередині shadow */
shadow.innerHTML = ``;
/* Правильно - :host() таргетує сам кастомний елемент */
shadow.innerHTML = ``;
</script>Стилі всередині shadow root не можуть таргетувати хост-елемент звичайними селекторами. Для цього є тільки :host і :host().
5. CSS-in-JS: відсутній ThemeProvider
Компонент styled.div без обгортки <ThemeProvider> використовує глобальний контекст. Токен теми резолвується в undefined, і ти отримуєш або порожні стилі, або несподівані дефолтні значення. Документація styled-components v6 називає це найпоширенішою проблемою в продакшені.
Де зустрічається на практиці
- Next.js 14+: файли
page.module.cssу кожному новому застосунку. CSS Modules за замовчуванням. - Vue 3 і Nuxt 3:
<style scoped>на будь-якому компоненті. Компілятор Vue хешує класи так само, як CSS Modules. - SvelteKit: ізоляція вбудована в компілятор. Жодного додаткового конфігурування.
- Lit (використовується в розширеннях Chrome DevTools): Shadow DOM. Кастомні елементи на кшталт
<time-ago>повністю інкапсульовані. - Shopify Hydrogen: styled-components для рантайм-теміщення різних крамниць.
Помилку з :host у Shadow DOM я бачив приблизно в половині веб-компонентного коду, який переглядав. Вона не очевидна з документації і стабільно ловить людей, які добре знають звичайний CSS.
Питання для поглиблення
Q: Як CSS-каскад працює в Shadow DOM порівняно зі звичайним документом?
A: У звичайному документі успадкування і специфічність перетинають всі DOM-вузли. У Shadow DOM межа обриває зовнішні стилі. Тільки :host() дає змогу стилям всередині таргетувати хост-елемент, а ::part() дозволяє зовнішньому CSS дістатися до іменованих частин shadow tree.
Q: Як CSS Modules підтримує медіа-запити і вкладеність?
A: Хешування стосується лише імен класів. Медіа-запит @media (hover: hover) { .Button_primary__xyz { ... } } - цілком валідний результат збирання. Sass-вкладеність теж працює через відповідний Webpack або Vite-лоадер.
Q: Яка різниця у продуктивності між Shadow DOM і CSS Modules?
A: CSS Modules не має витрат у рантаймі. Хешування відбувається при збиранні. Shadow DOM додає невеликий оверхед у рушієві Blink, близько 5-10% за метриками Chromium, бо браузер підтримує окремі scope стилів. Для більшості застосунків ця цифра несуттєва, але вартість реальна.
Q: Як у мікрофронтенд-архітектурі ізолювати стилі між бандлами без Shadow DOM?
A: CSS Modules з хешуванням при збиранні запобігає конфліктам імен класів між бандлами, навіть якщо вони шерять одну сторінку. Для синхронізації теми між мікрофронтендами підійде postMessage для передачі значень CSS custom properties. Глобальних імен класів краще уникати. iframe дає повну ізоляцію, але додає оверхед на комунікацію.
Q: Як дебагити Shadow DOM стилі в Chrome DevTools?
A: У панелі Elements розгорни #shadow-root. Панель Styles показує тільки правила, що діють всередині shadow scope. Зовнішні стилі там не відображаються - що само по собі підтверджує ізоляцію.
Приклади
Багаторазова кнопка в React dashboard
Реальний кейс: кнопка дизайн-системи, яка повинна залишатись синьою при hover незалежно від CSS батьківської сторінки.
/* Button.module.css */
.primary {
background: #007bff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.primary:hover {
background: #0056b3;
}
/* Webpack компілює це в: .Button_primary__abc123 *//* Button.jsx */
import styles from './Button.module.css';
function DashboardButton({ children }) {
return (
<button className={styles.primary}>
{children}
</button>
);
}
/* Dashboard.jsx */
<DashboardButton>Зберегти</DashboardButton>
<aside className="primary">Контент сайдбара</aside>
/* aside не синій - скомпільоване ім'я класу відрізняється */aside використовує звичайний клас .primary. Кнопка - .Button_primary__abc123. Перетину немає.
Веб-компонент зі стилями хост-елемента через Shadow DOM
Псевдоклас :host - це місце, де більшість розробників наштовхуються на стіну вперше.
<my-button class="warning" id="host"></my-button>
<script>
class MyButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = ``;
}
}
customElements.define('my-button', MyButton);
document.querySelector('my-button').innerHTML =
'<span class="label">Slotted текст</span>';
</script>Результат: хост-елемент отримує відступи і жовтий фон від класу .warning. Внутрішня кнопка зелена. Слотований span червоний. Без :host(.warning) жовтий фон не застосується, навіть якщо клас є на елементі.
BEM в компоненті навігації
Простий приклад того, як подвійне підкреслення і подвійний дефіс запобігають колізіям на практиці.
/* nav.css */
.nav {}
.nav__item {}
.nav__item--active {
font-weight: bold;
color: #007bff;
}
.nav__icon {
width: 16px;
height: 16px;
}<nav class="nav">
<a class="nav__item nav__item--active" href="/">
<img class="nav__icon" src="home.svg" alt="">
Головна
</a>
<a class="nav__item" href="/about">Про нас</a>
</nav>.nav__item--active збігається тільки з елементами цього компонента. Клас .active десь в іншому місці сторінки не зачепить його.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.