Всем привет! Меня зовут Александр Битько, я фронтенд-разработчик в ПСБ. Сегодня поговорим об одной из частых болей в работе с микрофронтенд-архитектурой — поиске баланса между независимостью микрофронтов и согласованным UI. Когда независимые микрофронты используют разные подходы к стилям (CSS-фреймворки, методологии вроде BEM или CSS-in-JS, глобальные стили), возникают конфликты с визуальной несогласованностью в приложении. Красная кнопка вдруг становится зелёной, шрифты начинают прыгать и так далее. 

Что с этим делать? В этой статье я расскажу о конфликтах стилей: какими они бывают, как с ними бороться и какие стратегии лучше подходят для приложений в разных случаях. И приведу примеры, как это работает на Angular и на React с использованием наиболее популярных библиотек Angular Matherial и MUI. Поехали.

Немного о силе микрофронтов

Микрофронтенд — это архитектура, позволяющая разделить приложение на независимые или слабо связанные части. Зачем нам эта штука?

  1. Улучшенная масштабируемость и гибкость разработки. Делим наше приложение на более мелкие, автономные, получаем независимое развёртывание, автономный деплой. Можно использовать разные технологии — собрать host на Angular, подключить remote React или Vue — и всё будет работать как единое целое. 

  2. Выше надёжность — упало одно приложение, всё остальное работает.

  3. Больше возможностей влиять на скорость разработки менеджментом — раз мы смогли логически отделиться от монолита, то задачу по модулю можно отдать отдельной команде.

Сильно углубляться в описание архитектуры я не буду — на Хабре это делали много раз. Тем более, что рассказать я обещал про боли микрофронтов и UI. 

Один из самых частых вопросов — как работать с CSS. В конце концов стилизация всегда необходима для любого фрагмента пользовательского интерфейса. Но она также является глобально разделяемой и, следовательно, может стать источником конфликтов.

Существуют различные подходы к созданию такой архитектуры, мы не будем их рассматривать в рамках этой статьи, но что важно отметить — использование iframe для внедрения фрагментов интерфейса из разных микрофронтендов. В этом случае ограничений нет, так как каждый фрагмент полностью изолирован.

Во всех других случаях, независимо от того, используется ли в вашем решении композиция на стороне клиента, сервера или что-то промежуточное, стили всё равно будут обрабатываться в браузере. Во всех этих случаях вам придётся иметь дело с CSS.

Конфликты стилей

Представьте, что у нас есть глобальная таблица стилей. Мы реализовали новую фичу и видим, что по какой-то причине UI ведёт себя не вполне предсказуемо. Причина? Может быть, глобальные стили имеют слишком высокую специфичность. Или были подключены позже нужных нам правил. В итоге наши стили переопределяются, и интерфейс отображается некорректно. 

Идём дальше. Подключили библиотеку. У библиотеки свои стили, они тоже могут влиять на наш компонент. И если у библиотеки нет должной изоляции внутри себя, то наши глобальные стили тоже могут повлиять на неё, и всё это также может привести к непредсказуемым результатам.

Теперь допустим, что у нас есть хост-приложение (родительское), которое подключает микрофронтенд (remote). Поскольку remote встроен в хост, все глобальные стили хоста автоматически применяются и к нему.

Или вот представьте: разработчик вносит изменения в общую таблицу стилей, чтобы реализовать свою фичу. У него локально всё работает нормально, но когда изменения попадают в основной проект, он начинает сыпаться. 

По мере роста сложности приложения целиком любые изменения в таблицах стилей могут резко повлиять на то, что мы видим. Приходится тратить время на отладку. 

Собственно говоря, как эти конфликты могут выглядеть? 

Remote, я твой отец!
Remote, я твой отец!

Пример: в нашем remote-приложении есть кнопка, которая по дизайну должна быть красной. Но из-за того, что стили хоста проникают в remote и переопределяют наши правила, кнопка становится зелёной. И это лишь один из множества возможных конфликтов.

Что делать?

Давайте рассмотрим различные стратегии по изоляции стилей

А начнём мы с отсутствия стратегии — просто оставим как есть. Представьте, что мы одновременно загружаем сразу несколько микрофронтендов на одной странице. Каждый микрофронтенд грузит свои стили, при этом ни один из них не имеет должной изоляции.

Такой подход рано или поздно приведёт к конфликту. Как мы видим на картинке, у нас уже есть проблема — оба микрофронтенда имеют один и тот же класс, и он будет перезаписан тем приложением, что было подключено позже. Поэтому вместо красного бэкграунда слева мы видим зелёный. Для тестового проекта, в котором нам вообще не важны стили, так можно, а для чего-то серьёзного точно не годится.

Давайте подумаем, как мы можем решить эти конфликты и улучшить стратегию?

Можно договориться об именах. Каждый микрофронтенд использует уникальные префиксы в именах классов, связанные с его названием (например, shopping-container, checkout-container), чтобы минимизировать конфликты стилей.

Это рабочая схема, но рано или поздно кто-то забудет о соглашении. Или что делать, если нужно переименовать приложения? И не стоит забывать о более сложных архитектурах, допустим, когда один микрофронт вложен в другой.

<div class="shopping">
  <!-- Компонент checkout -->
  <div class="checkout">
    <img src="..." alt="Product">
  </div>
</div>
<style>
   .shopping img {
       border: 2px solid red;
    }
</style>

Например, 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 приложения.

<style>
      /* Global styles */
      .button {
        background-color: blue;
        color: white;
        border: 2px solid aqua;
        border-radius: 8px;
        padding: 8px 16px;
      }
      .row h1 {
        color: blue;
      }
      app-root h1 {
        background-color: red;
      }
</style>

Что мы видим? В глобальном стиле у родителя (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. 

@Component({
 ...
  template: `
  <h1>{{ name }}</h1>
    <div class="row">
      <div class="button">Remote Btn</div>
    </div>

  `,
  encapsulation: ViewEncapsulation.ShadowDom, // ←--
})
export class App {
  name = 'Angular';
}

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 — мы изолировались от всего, но некоторые ресурсы нам нужны внутри дома, а доступа к ним уже нет. Необходимо прокинуть стили внутрь.

C чего начнём?

Во‑первых, убедимся, что все ваши важные ссылки, например, подключение шрифтов, размещены внутри shadow DOM в app.component.html вместо index.html.

@Component({
  selector: 'app-root',
  ...
  template: `
<!-- Подключаем необходимые ссылки в app.component.html -->
 <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<h1>{{ name }}!</h1>
 <div class="row">
   <div class="button">Remote Btn</div>
   <button mat-flat-button color="primary">Material button</button>
 </div>`,
  encapsulation: ViewEncapsulation.ShadowDom,
})

Те, кто использует 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.

:host(app-root) {  /* Оборачиваем тему в селектор app-root */
@include mat.core();
  $demo-primary: mat.define-palette(mat.$indigo-palette);
  ...
  $demo-theme: mat.define-light-theme((
 color: (
   primary: $demo-primary,
   ...
 ),
 typography: mat.define-typography-config(),
 ));
  @include mat.all-component-themes($demo-theme);
}

Мы почти полностью решили нашу проблему. Что остаётся? Компоненты, у которых есть какой‑то overlay. И речь пойдет, конечно же, про комбо‑боксы.

По умолчанию оверлеи добавляются к корню документа. Это логично, потому что библиотеки могут правильно позиционировать их и очищать всё после завершения работы.

Но что происходит в нашем случае? Веб‑компонент (микрофронтенд) использует ShadowDom и игнорирует все внешние. Сам оверлей добавляется к корню документа. Есть ли у нас там стили Material? Правильно, нет, ведь на предыдущем шаге мы перенесли их на уровень Shadow Dom. И это проблема.

Чтобы решить её, нам нужно добавить контейнер оверлея внутрь Web Component — то есть внутрь его shadow root.

Хорошая новость заключается в том, что Angular Material позволяет легко справиться с этим. Но имейте в виду — не все библиотеки в настоящее время дружат с shadow root.

{
   provide: OverlayContainer,
   useClass: WebComponentOverlayContainer,
},
@Injectable({ providedIn: 'root' })
export class WebComponentOverlayContainer extends OverlayContainer  {
  public constructor(
    @Inject(DOCUMENT)
    private readonly document: Document,
    platform: Platform,
  ) {
    super(document, platform);
  }

А что React?

В React у нас аналогичная стратегия.

Давайте включим Shadow DOM на уровне корневого элемента приложения в файле index.tsx. Чтобы стилизовать элементы внутри Shadow DOM, мы можем либо подключить стили с помощью ссылки на внешний файл (<link>), либо добавить стиль напрямую через тег <style> внутри самого Shadow DOM.

// index.tsx
connectedCallback() {
   const shadowRoot = this.attachShadow({ mode: 'open' })
   this.style.display = 'contents'
   this.root = createRoot(shadowRoot)
   this.root.render(
<React.Fragment>
    <link rel='stylesheet' href='index.css'></link>
    <App />
</React.Fragment>
)
}

Убедитесь, что все необходимые ссылки размещены в файле index.tsx, а не в index.html.

Для предотвращения утечек сбрасываем стили:

// index.tsx
<link
  href="https://fonts.googleapis.com/icon?family=Material+Icons"
  rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
  rel="stylesheet" />
// index.scss
:host {
  all: initial;
}

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 теперь применяются только внутри дома, необходимо перенести туда и порталы.

const theme = createTheme({
  components: {
    MuiModal: {
      defaultProps: {
        container: shadowRoot,
    ...

    MuiDialog: {
      defaultProps: {
        container: shadowRoot,
   ...
}

Чтобы решить проблему при формировании темы приложения для необходимых компонентов, зададим в качестве контейнера наш Shadow Dom.

Мы победили проблемы изоляции!

Кнопочка теперь имеет свой собственный никем не перезаписанный стиль. 

В завершение

Как видите, проблема чужих стилей глубже и обширнее, чем может показаться. Но решения есть всегда. Успехов вам в проектах с микрофронтендами и не забывайте проверять ваш UI.

И ещё приведу пару ссылок на статьи, которые я ранее писал с коллегами по этой же тематике:

Styles Isolation in microfrontends with React (including Material styles) | by Alexander Bitko | Medium https://share.google/6DftjnRWikShxHJgM

Microfrontends or Monolithic architectures? How to make the right choice? | by Alexander Bitko | Medium https://share.google/ObyDRiNb9cLvOPFmj

Advanced Style Isolation Techniques in Angular with Angular Material | by Oleksandr Koshevierov | Medium https://share.google/7I7CunZwdLtlgAmer

Приходите в комментарии с вопросами, буду рад ответить.

Комментарии (0)