Коли незалежні мікрофронти використовують різні підходи до стилів (CSS-фреймворки, методології на кшталт BEM або CSS-in-JS, глобальні стилі), виникають конфлікти з візуальною неузгодженістю в додатку. Червона кнопка раптом стає зеленою, шрифти починають стрибати і так далі.
Що з цим робити? У цій статті я розповім про конфлікти стилів: якими вони бувають, як з ними боротися і які стратегії краще підходять для додатків в різних випадках. І наведу приклади, як це працює на Angular і на React з використанням найбільш популярних бібліотек Angular Material і MUI. Поїхали.
Трохи про силу мікрофронтів
Мікрофронтенд — це архітектура, що дозволяє розділити додаток на незалежні або слабо пов'язані частини. Навіщо нам ця штука?
- Покращена масштабованість і гнучкість розробки. Ділимо наш додаток на менші, автономні, отримуємо незалежне розгортання, автономний деплой. Можна використовувати різні технології — зібрати host на Angular, підключити remote React або Vue — і все буде працювати як єдине ціле.
- Вища надійність — впало одне додаток, все інше працює.
- Більше можливостей впливати на швидкість розробки менеджментом — раз ми змогли логічно відокремитися від моноліту, то задачу по модулю можна віддати окремій команді.
Один з найчастіших питань — як працювати з CSS. Зрештою стилізація завжди необхідна для будь-якого фрагмента користувацького інтерфейсу. Але вона також є глобально розділеною і, отже, може стати джерелом конфліктів.
Існують різні підходи до створення такої архітектури, ми не будемо їх розглядати в рамках цієї статті, але що важливо відзначити — використання iframe для впровадження фрагментів інтерфейсу з різних мікрофронтендів. В цьому випадку обмежень немає, так як кожен фрагмент повністю ізольований.
У всіх інших випадках, незалежно від того, чи використовується у вашому рішенні композиція на стороні клієнта, сервера або щось проміжне, стилі все одно будуть оброблятися в браузері. У всіх цих випадках вам доведеться мати справу з CSS.
Конфлікти стилів
Уявіть, що у нас є глобальна таблиця стилів. Ми реалізували нову фічу і бачимо, що з якоїсь причини UI поводиться не зовсім передбачувано. Причина? Можливо, глобальні стилі мають занадто високу специфічність. Або були підключені пізніше потрібних нам правил. В результаті наші стилі перевизначаються, і інтерфейс відображається некоректно.
Ідемо далі. Підключили бібліотеку. У бібліотеки свої стилі, вони теж можуть впливати на наш компонент. І якщо у бібліотеки немає належної ізоляції всередині себе, то наші глобальні стилі теж можуть вплинути на неї, і все це також може призвести до непередбачуваних результатів.
Тепер припустимо, що у нас є хост-додаток (батьківський), який підключає мікрофронтенд (remote). Оскільки remote вбудований в хост, всі глобальні стилі хоста автоматично застосовуються і до нього.
Або ось уявіть: розробник вносить зміни в спільну таблицю стилів, щоб реалізувати свою фічу. У нього локально все працює нормально, але коли зміни потрапляють в основний проект, він починає сипатися.
У міру зростання складності додатка в цілому будь-які зміни в таблицях стилів можуть різко вплинути на те, що ми бачимо. Доводиться витрачати час на налагодження.
Власне кажучи, як ці конфлікти можуть виглядати?
Remote, я твій батько!
Приклад: в нашому remote-додатку є кнопка, яка за дизайном повинна бути червоною. Але через те, що стилі хоста проникають в remote і перевизначають наші правила, кнопка стає зеленою. І це лише один з безлічі можливих конфліктів.
Що робити?
Давайте розглянемо різні стратегії по ізоляції стилів
А почнемо ми з відсутності стратегії — просто залишимо як є. Уявіть, що ми одночасно завантажуємо відразу кілька мікрофронтендів на одній сторінці. Кожен мікрофронтенд завантажує свої стилі, при цьому жоден з них не має належної ізоляції.
Такий підхід рано чи пізно призведе до конфлікту. Як ми бачимо на картинці, у нас вже є проблема — обидва мікрофронтенди мають один і той же клас, і він буде перезаписаний тим додатком, що було підключено пізніше. Тому замість червоного фону зліва ми бачимо зелений. Для тестового проекту, в якому нам взагалі не важливі стилі, так можна, а для чогось серйозного точно не годиться.
Давайте подумаємо, як ми можемо вирішити ці конфлікти і покращити стратегію?
Можна домовитися про імена. Кожен мікрофронтенд використовує унікальні префікси в іменах класів, пов'язані з його назвою (наприклад, shopping-container, checkout-container), щоб мінімізувати конфлікти стилів.
Це робоча схема, але рано чи пізно хтось забуде про угоду. Або що робити, якщо потрібно перейменувати додатки? І не варто забувати про складніші архітектури, припустимо, коли один мікрофронт вкладений в інший.
Наприклад, div.shopping > div.checkout img може застосувати стилі з shopping до елементів checkout. В результаті з'явиться несподіваний бордер там, де ніхто не чекав.
Пошукаємо варіант надійніший.
Звичайно, хотілося б віддати весь статнеймінг на відкуп якій-небудь бібліотеці. І такі бібліотеки є. Перше, що приходить в голову, — це CSS Modules. CSS Modules дозволяють автоматично додавати префікси і уникати конфліктів імен класів завдяки генерації унікальних імен. Залежно від обраного збірника, це може бути доступно «з коробки» або через зміну конфігурації.
Імпортований модуль являє собою згенерований об'єкт, що містить зіставлення оригінальних імен класів (наприклад, active) зі згенерованими. Згенероване ім'я класу зазвичай являє собою хеш вмісту правила CSS, об'єднаний з оригінальним ім'ям класу. Це робить ім'я максимально унікальним.
Це вирішує проблему конфліктів, але вимагає деяких налаштувань:
- Потрібна попередня настройка збірника (наприклад, додавання плагіна)
- Розширення синтаксису стандартного CSS: з'являються додаткові синтаксичні елементи для розрізнення стилів, які імпортуються (і, отже, попередньо обробляються і хешуються), і стилів, які залишаються без змін (наприклад, для подальшого використання без імпорту).
- Іноді потрібно підключати CSS безпосередньо всередині JS-файлів, що ускладнює підтримку і робить код більш громіздким. Стилі імпортуються і застосовуються як змінні в JavaScript-файлах. Це відрізняється від звичайного підходу, де стилі безпосередньо прописуються в HTML або використовуються глобально.
Ідемо далі. Що ще можна використовувати? CSS-in-JS (або CSS-in-Components) дозволяє додавати стилі безпосередньо до компонентів (генерує унікальні класи), забезпечуючи локальну ізоляцію і уникаючи глобальних конфліктів. Стилі, створені за допомогою CSS-in-JS, автоматично застосовуються тільки до компонента, для якого вони були визначені. Це виключає можливість «витоку» стилів в інші частини додатка.
І виникає необхідність вибору відповідної бібліотеки (Emotion, Styled Components, Vanilla Extract) для роботи зі стилями.
Що ще можна взяти? Tailwind генерує стилі на основі використання утилітарних класів, мінімізуючи конфлікти. Кожен мікрофронтенд постачається тільки з необхідними для його відображення стилями.
Основні переваги такого підходу:
- Немає глобальних стилів: всі стилі задаються через утилітарні класи безпосередньо на рівні елементів.
- Локальність застосування: класи не перевизначають стилі за межами того компонента, до якого вони застосовані.
- Відсутність каскадів: мінімум специфічності і каскадної поведінки, завдяки чому немає випадкового «протікання» стилів.
- Генерація на основі використання: в фінальний CSS потрапляють тільки використовувані класи (весь перелік класів tailwind не потрапить).
- Можливість кастомних префіксів: легко додавати унікальні префікси, щоб забезпечити повну незалежність стилів.
До мінусів можна віднести ускладнену конфігурацію збірки (наприклад, для esbuild потрібен PostCSS і коректна настройка Tailwind).
А якщо вбудувати ізоляцію прямо в фреймворк? Візьмемо Angular: у нього з коробки Emulated Encapsulation з унікальними атрибутами для компонентів.
Angular з режимом Emulated для інкапсуляції стилів забезпечує локальну ізоляцію, додаючи автоматично згенеровані атрибути до елементів компонента і їх стилів. Це дозволяє застосовувати CSS тільки до конкретного компонента, запобігаючи впливу стилів на інші частини додатка. Така ізоляція досягається без необхідності використання зовнішніх бібліотек або інструментів. При цьому розмітка залишається в DOM, як звичайно, але стилі пов'язані з компонентом через унікальні атрибути.
До особливостей даної стратегії можна віднести складності в роботі з рідкісними кейсами, коли стилі повинні працювати глобально.
Ну і головний гравець — Shadow DOM. Це справжня і найчесніша ізоляція з усіх. Shadow DOM створює окреме ізольоване простір (міні-документ) для стилів і елементів всередині користувацького елемента. Окреме дерево елементів, невидиме і незалежне від основного DOM. Це запобігає впливу глобальних стилів на вміст тіньового DOM і навпаки, забезпечуючи локальну ізоляцію.
Але тут є і можливі труднощі:
- Проблеми з глобальними стилями: глобальні стилі і CSS-фреймворки (наприклад, Bootstrap) не працюють всередині Shadow DOM без явного імпорту, що вимагає додаткових зусиль для інтеграції.
- Збільшення обсягу коду: кожен компонент вимагає окремого блоку стилів і розмітки, що може призвести до збільшення розміру коду і зниження продуктивності при великій кількості компонентів.
- Сумісність зі сторонніми бібліотеками: не всі бібліотеки і плагіни коректно працюють з Shadow DOM, оскільки вони очікують стандартне DOM-дерево.
Підсумовуємо. Що вибрати?
А тепер трохи практики. І почнемо ми з Angular
Уявимо, що ми працюємо з великим додатком, де мікрофронти обчислюються десятками, а може, і сотнями. Наш MFE може бути вбудований в інші додатки. Ми не завжди знаємо, які це додатки, як вони реалізовані і які CSS-класи вони використовують.
Ми створили мікрофронт(remote), і він був вбудований в інший мікрофронт, який виступає в якості host (батько). Host завантажує remotes використовуючи Module Federation і відображає їх як Web Components, remote шарится як web component, що забезпечує його автономність і дозволяє йому працювати, як окрема додаток. А тепер подивимося на спрощений приклад взаємодії додатків в описаній вище ситуації:
Все, що ви бачите всередині червоної рамки — це наш remote (вбудована додаток). Все, що поза рамкою — host (батько).
В батьківському додатку створили деякі глобальні стилі, і уявимо, що ми визначили клас (button), який вже існує в стилях remote додатка.
Що ми бачимо? В глобальному стилі у батька (host) є деякий клас button. Такий же клас button є всередині нашого remote — виникає конфлікт: глобальні стилі .button з host перезаписали частину стилів .button в remote.
Варто звернути увагу, що Angular використовує вбудовану ізоляцію і додає унікальні атрибути (наприклад, _ngcontent-ng-c2543076085). Це підвищує специфічність класу всередині дочірнього компонента, тому color і background-color не успадковуються, а беруться з remote, але успадковуються всі інші стилі (border, border-radius, padding).
Це один з яскравих прикладів як стилі одного мікрофронта можуть зламати стилі іншого, навіть незважаючи на використання досить просунутих стратегій по ізоляції. Саме для таких випадків і існує Shadow DOM.
Як його впровадити? В Angular все досить просто. Так як ми використовуємо web components в якості архітектури мікрофронтів, то йдемо в рутовий app.component нашого додатка і змінюємо йому інкапсуляцію. Пишемо ViewEncapsulation.ShadowDom.
Shadow Dom був інтегрований, здавалося б, все, статтю можна закінчувати. Але не тут-то було.
Досягли ми повної ізоляції? В цілому так, але є нюанс…
Тут нас може чекати сюрприз. Є властивості, які все одно успадковуються, навіть при Shadow DOM.
Деякі стилі можуть передаватися від батьківського додатка до вбудованого навіть при використанні ViewEncapsulation.ShadowDom. Наприклад:
- color, font-family, line-height, visibility…
- Системні стилі браузера (наприклад, відступи для h1)
- Глобальні змінні CSS (користувацькі властивості), визначені в батьківському додатку, також можуть бути доступні всередині тіньового DOM — це особливий вид стилів, які працюють на рівні DOM-дерева, а не на рівні інкапсуляції компонентів.
Щоб вирішити ці проблеми, ми можемо скинути всі стилі CSS відразу. Так, ви правильно почули, скинути всі стилі в app.component.scss:
all: initial;
Дивимося на екран. Що бачимо? В нашому remote, після того як ми інтегрували Shadow DOM, кудись пропали всі стилі нашої кнопки.
Зворотна сторона shadow DOM — ми ізолювалися від усього, але деякі ресурси нам потрібні всередині дому, а доступу до них вже немає. Необхідно прокинути стилі всередину.
З чого почнемо?
По-перше, переконаємося, що всі ваші важливі посилання, наприклад, підключення шрифтів, розміщені всередині shadow DOM в app.component.html замість index.html.
Ті, хто використовує Angular Material і вирішив інтегрувати Shadow DOM помітить, що компоненти всередині дому також залишилися без стилів. Чому так відбувається? За замовчуванням в Angular Materials всі стилі інжектуються на рівні head нашого документа. Але ми знаходимося в Shadow DOM. Ми не бачимо, що там відбувається. Всі стилі, оголошені ззовні, не проникають в Shadow DOM. Виходить, вся тема не доступна нашому додатку. Що робити?
Рішення
- Ваша тема повинна бути створена всередині файлу app.component.scss і виключена з angular.json. Це робить тему частиною вашого мікрофронтенда.
- Якщо ви хочете використовувати кілька тем, ви можете додати додаткове посилання в ваш app.component.html для завантаження потрібної теми, а URL для цього посилання буде керуватися вашим додатком.
Нарешті, ізолюйте саму тему. Незалежно від того, де ви включаєте тему, вона повинна бути обгорнута всередині користувацького селектора, який представляє ваш мікрофронтенд. Важливо знати селектор вашого додатка. В нашому прикладі ми не змінили його за замовчуванням, тому він називається app-root. Щоб ізолювати стилі Angular Material, імпортуйте вашу тему всередині селектора:host(app-root). Це створить всі змінні CSS всередині вашого тіньового DOM замість стандартного:root.
Ми майже повністю вирішили нашу проблему. Що залишається? Компоненти, у яких є якийсь overlay. І мова піде, звичайно ж, про комбо-бокси.
За замовчуванням оверлеї додаються до кореня документа. Це логічно, тому що бібліотеки можуть правильно позиціонувати їх і очищати все після завершення роботи.
Але що відбувається в нашому випадку? Веб-компонент (мікрофронтенд) використовує ShadowDom і ігнорує всі зовнішні. Сам оверлей додається до кореня документа. Чи є у нас там стилі Material? Правильно, ні, адже на попередньому кроці ми перенесли їх на рівень Shadow Dom. І це проблема.
Щоб вирішити її, нам потрібно додати контейнер оверлея всередину Web Component — тобто всередину його shadow root.
Хороша новина полягає в тому, що Angular Material дозволяє легко впоратися з цим. Але майте на увазі — не всі бібліотеки в даний час дружать з shadow root.
А що React?
В React у нас аналогічна стратегія.
Давайте включимо Shadow DOM на рівні кореневого елемента додатка в файлі index.tsx. Щоб стилізувати елементи всередині Shadow DOM, ми можемо або підключити стилі за допомогою посилання на зовнішній файл (<link>), або додати стиль безпосередньо через тег <style> всередині самого Shadow DOM.
Переконайтеся, що всі необхідні посилання розміщені в файлі index.tsx
, а не в index.html
.
Для запобігання витоків скидаємо стилі:
MUI
Ось тільки як вирішити проблему з Material UI? На щастя, в документації MUI є розділ про те, як використовувати Shadow DOM. Для отримання додаткової інформації ви можете ознайомитися з офіційною документацією.
MUI, починаючи з версії MUI v5, переключилася на використання Emotion за замовчуванням для стилізації компонентів.
- Код створює кеш для Emotion (бібліотеки CSS-in-JS), щоб керувати стилями всередині Shadow DOM.
- Контейнер встановлюється в shadowRoot, щоб гарантувати, що стилі залишаються в області Shadow DOM.
- Потім цей кеш необхідно передати в CacheProvider, який надає Emotion-кеш для застосування стилів всередині Shadow DOM.
Портали...
Компоненти Material UI, такі як Menu, Dialog і Popover, використовують Portal для рендерингу нового піддерева в контейнері за межами поточної ієрархії DOM. За замовчуванням цей контейнер — document.body. Вміст порталу не успадковує стилі, задані для Shadow DOM. Так як стилі MUI тепер застосовуються тільки всередині дому, необхідно перенести туди і портали.
Щоб вирішити проблему при формуванні теми додатка для необхідних компонентів, задамо в якості контейнера наш Shadow Dom.
Ми перемогли проблеми ізоляції!
Кнопочка тепер має свій власний ніким не перезаписаний стиль.
Коментарі