Micro-Frontends: модульная архитектура

Module Federation, Single-SPA и разделение монолита на микросервисы

Micro-frontends — архитектурный подход, при котором монолитное фронтенд-приложение разбивается на независимые модули, каждый из которых разрабатывается, тестируется и разворачивается отдельной командой. Концепция заимствует принципы микросервисов и переносит их на уровень пользовательского интерфейса. Для крупных продуктов с множеством функциональных областей и командами от 10 разработчиков это не просто модный термин, а практическое решение реальных болей: замедление релизных циклов, конфликты при слиянии кода, невозможность независимо масштабировать части приложения. В 404gen мы внедряли micro-frontend архитектуру на проектах с бюджетом от 2,5 млн рублей, где монолит начинал тормозить рост продукта. В этой статье — честный разбор: когда стоит переходить, как проектировать, что использовать из инструментов в 2024–2025 году и каких ошибок лучше избежать с самого начала. Когда монолитный фронтенд становится проблемой Монолитная SPA-архитектура прекрасно работает, пока команда небольшая, а продукт не вышел за рамки одного домена. Проблемы появляются по мере роста. Признаки, что вы достигли потолка монолита: Время сборки превышает 10–15 минут. Разработчики ждут CI/CD вместо того, чтобы итерировать. Конфликты слияния каждый день. Несколько команд редактируют одни и те же файлы — роутинг, shared-компоненты, конфиги. Релизные циклы синхронизированы принудительно. Команда корзины не может выкатить фикс, не дождавшись готовности команды каталога. Разные части приложения требуют разных технологий. Легаси на AngularJS, новые фичи хочется писать на React, BI-дашборды — на Vue. Страницы грузятся медленно из-за единого бандла. Пользователь скачивает весь JS продукта, даже если зашёл только на одну страницу. По данным исследования State of JS 2024, около 34% команд, работающих с приложениями более 100 тысяч строк кода, сталкивались с критическими замедлениями CI из-за монолитной сборки. Для российского рынка характерна ещё одна проблема: переход сотрудников между компаниями нередко разрушает владение отдельными модулями — в монолите никто не знает, кто отвечает за конкретный кусок кода. Основные паттерны интеграции micro-frontends Не существует единственного «правильного» способа реализовать micro-frontend архитектуру. Выбор паттерна зависит от текущего стека, требований к производительности и зрелости команды. Build-time интеграция (npm-пакеты) Самый простой вариант: каждый micro-frontend публикуется как npm-пакет, shell-приложение импортирует их как зависимости. Плюс — простота настройки, нет runtime-оверхеда. Минус — потеря независимости деплоя: обновление пакета требует пересборки и редеплоя shell. // package.json в shell-приложении { "dependencies": { "@company/catalog-mfe": "^2.4.1", "@company/checkout-mfe": "^1.8.0", "@company/account-mfe": "^3.1.2" } } // App.tsx import { CatalogApp } from '@company/catalog-mfe'; import { CheckoutApp } from '@company/checkout-mfe'; Этот паттерн подходит, если команды готовы координировать релизы и независимость деплоя не является критичным требованием. Run-time интеграция через iframes Исторически первый способ изоляции. Каждый micro-frontend — отдельное приложение в iframe. Полная изоляция CSS и JS, простота реализации. Существенные минусы: сложная коммуникация между фреймами, проблемы с SEO, accessibility-ограничения, визуальные артефакты при разных viewport. В 2024 году iframes — последний выбор, оправданный только для встройки сторонних приложений с недоверенным кодом. Run-time интеграция через JavaScript Каждый micro-frontend разворачивается как отдельный бандл по отдельному URL. Shell-приложение динамически загружает нужный модуль и монтирует его в DOM. Это доминирующий паттерн в 2024–2025. // Shell загружает micro-frontend динамически async function loadMicroFrontend(name, containerId) { const manifest = await fetch(`https://catalog.company.ru/asset-manifest.json`) .then(r => r.json()); const script = document.createElement('script'); script.src = manifest['main.js']; script.onload = () => { window[name].mount(document.getElementById(containerId)); }; document.head.appendChild(script); } // Монтирование loadMicroFrontend('CatalogApp', 'mfe-catalog-container'); Module Federation (Webpack 5 / Rspack) Module Federation, представленный в Webpack 5 в 2020 году, стал стандартом де-факто для крупных проектов. Он позволяет приложениям динамически загружать модули из других приложений в runtime, при этом разделяя общие зависимости (React, ReactDOM, lodash) без дублирования. // webpack.config.js в micro-frontend "catalog" const { ModuleFederationPlugin } = require('webpack').container; module.exports = { plugins: [ new ModuleFederationPlugin({ name: 'catalog', filename: 'remoteEntry.js', exposes: { './CatalogApp': './src/CatalogApp', './ProductCard': './src/components/ProductCard', }, shared: { react: { singleton: true, requiredVersion: '^18.0.0' }, 'react-dom': { singleton: true, requiredVersion: '^18.0.0' }, }, }), ], }; // webpack.config.js в shell-приложении module.exports = { plugins: [ new ModuleFederationPlugin({ name: 'shell', remotes: { catalog: 'catalog@https://catalog.company.ru/remoteEntry.js', checkout: 'checkout@https://checkout.company.ru/remoteEntry.js', }, shared: { react: { singleton: true, requiredVersion: '^18.0.0' }, 'react-dom': { singleton: true, requiredVersion: '^18.0.0' }, }, }), ], }; В shell-приложении импорт выглядит как обычный ES-модуль, но под капотом загружается удалённый бандл: // App.tsx в shell import React, { lazy, Suspense } from 'react'; const CatalogApp = lazy(() => import('catalog/CatalogApp')); const CheckoutApp = lazy(() => import('checkout/CheckoutApp')); function App() { return ( Router Routes Route path="/catalog/*" element={ Suspense fallback={ Skeleton / } CatalogApp / /Suspense } / Route path="/checkout/*" element={ Suspense fallback={ Skeleton / } CheckoutApp / /Suspense } / /Routes /Router ); } Проектирование границ между командами Самая сложная часть micro-frontend архитектуры — не технический стек, а организационное решение: как провести границы между командами. Ошибка здесь дороже любой технической проблемы, потому что рефакторинг границ в production — это практически полная перестройка. Принципы проведения границ: По бизнес-доменам, не по технической функции. Команда владеет вертикальным срезом: от UI до API-слоя своего домена. Не «команда компонентов», а «команда каталога», «команда чекаута», «команда личного кабинета». Закон Конвея работает в обе стороны. Архитектура ПО отражает коммуникационные структуры организации. Спроектируйте команды так, как хотите видеть архитектуру — или наоборот. Минимум межкомандных зависимостей. Если команда A не может задеплоить без согласования с командой B — граница проведена неправильно. Один micro-frontend — одна команда. Shared ownership убивает независимость и размывает ответственность. «Любая организация, которая проектирует систему, неизбежно производит дизайн, чья структура является копией коммуникационной структуры этой организации.» — Мелвин Конвей, 1967. Спустя 57 лет этот закон стал основой микросервисной и micro-frontend архитектуры. Коммуникация между micro-frontends Изолированные приложения всё равно должны общаться: пользователь добавляет товар в корзину в каталоге, и счётчик в шапке (принадлежащей shell) должен обновиться. Как это реализовать без создания жёстких зависимостей? Custom Events (браузерные события) Нативный механизм без внешних зависимостей. Micro-frontend публикует событие, shell или другой micro-frontend подписывается. // В catalog micro-frontend: публикация события function addToCart(product) { // Бизнес-логика... window.dispatchEvent(new CustomEvent('mfe:cart:item-added', { detail: { productId: product.id, quantity: 1, price: product.price }, bubbles: true, })); } // В shell или cart micro-frontend: подписка window.addEventListener('mfe:cart:item-added', (event) => { const { productId, quantity } = event.detail; cartStore.addItem(productId, quantity); }); Важное правило: называйте события с namespace (`mfe:domain:action`), документируйте «контракт» событий как публичное API. Изменение payload без уведомления — это breaking change. Shared State через PubSub или Event Bus Для более сложных сценариев — библиотека-посредник, доступная всем micro-frontends через shared-зависимость: // shared/event-bus.ts — публикуется как shared в Module Federation class EventBus { private subscribers: Map string, Function[] = new Map(); publish(event: string, payload: unknown) { (this.subscribers.get(event) ?? []).forEach(fn => fn(payload)); } subscribe(event: string, callback: Function) { const existing = this.subscribers.get(event) ?? []; this.subscribers.set(event, [...existing, callback]); return () => { this.subscribers.set( event, this.subscribers.get(event)!.filter(fn => fn !== callback) ); }; } } export const eventBus = new EventBus(); URL как общее состояние Для навигационного состояния URL — лучший «шаред стейт». Micro-frontends читают параметры из URL и не нуждаются в прямой коммуникации. Это также решает проблему deep linking и браузерных закладок. Единый дизайн и UX при разных командах Главный риск micro-frontend архитектуры с точки зрения пользовательского опыта — визуальная и поведенческая несогласованность. Если каждая команда пишет кнопки и формы по-своему, пользователь чувствует «лоскутное одеяло», а не единый продукт. Решение — Design System как отдельная команда и отдельный артефакт. Несколько уровней: Design Tokens — JSON/CSS-переменные с цветами, типографикой, отступами, радиусами. Единый источник правды для всех команд и для дизайнеров. Инструменты: Style Dictionary, Theo. UI Kit — библиотека компонентов (Button, Input, Modal, Toast) с жёстко заданным дизайном. Публикуется как npm-пакет, подключается во все micro-frontends через shared в Module Federation. Pattern Library — составные паттерны (формы, таблицы данных, навигационные структуры) с готовыми вариантами поведения. // Design tokens как CSS custom properties :root { --color-primary: #eb4545; --color-primary-hover: #c93838; --color-surface: #1a1a1a; --color-border: rgba(255, 255, 255, 0.12); --radius-sm: 4px; --radius-md: 8px; --radius-lg: 16px; --font-body: 'Inter', system-ui, sans-serif; --font-mono: 'JetBrains Mono', monospace; --spacing-xs: 4px; --spacing-sm: 8px; --spacing-md: 16px; --spacing-lg: 24px; --spacing-xl: 40px; } Критически важно: Design System Team должна работать как внутренний продукт с реальными потребителями, SLA на обновления и публичным changelog. Команды-потребители голосуют за фичи. Это не «отдел вёрстки», а платформенная команда с собственными метриками. Производительность и оптимизация загрузки Micro-frontend архитектура по умолчанию несёт риски для производительности: несколько отдельных бандлов, потенциальная загрузка React несколько раз, waterfall-запросы при последовательной загрузке зависимостей. Без правильной настройки Core Web Vitals упадут. Дедупликация зависимостей Module Federation решает дедупликацию через опцию singleton: true в конфигурации shared. React должен быть singleton — это обязательное требование. Если два micro-frontend загрузят разные экземпляры React, хуки сломаются с неочевидными ошибками. Стратегия загрузки Critical path first. Shell с навигацией и глобальными компонентами загружается первым и должен быть минимального размера (цель: 50 KB gzipped). Route-based lazy loading. Micro-frontends загружаются только при навигации на соответствующий раздел. Prefetching. Предзагружайте вероятные следующие маршруты в idle-время браузера. Кеширование на CDN. Каждый micro-frontend деплоится с content-hash в имени файла. Агрессивное кеширование (1 год) — без инвалидации при деплое других команд. // Prefetch следующего вероятного micro-frontend function prefetchCheckout() { const link = document.createElement('link'); link.rel = 'prefetch'; link.href = 'https://checkout.company.ru/remoteEntry.js'; document.head.appendChild(link); } // Запускаем prefetch когда пользователь добавил товар в корзину window.addEventListener('mfe:cart