Реализация Web Components для микрофронтендной архитектуры
Микрофронтенды решают одну конкретную проблему: как дать нескольким командам независимо деплоить части одного интерфейса, не превращая сборку в монолит. Web Components — нативный браузерный механизм, который даёт технологическую изоляцию без единого фреймворка-диктатора.
Почему Web Components, а не Module Federation
Module Federation (Webpack 5) — мощный инструмент, но он завязывает все команды на одну систему сборки. Если одна команда хочет Vite, другая — Rollup, третья вообще пишет на Svelte — начинаются компромиссы.
Web Components работают на уровне браузера. customElements.define('order-cart', OrderCartElement) — и этот компонент подключает любой хост, не зная про React, Vue или ванильный JS внутри.
Ограничения тоже реальные: Shadow DOM усложняет глобальные стили, событийная коммуникация требует дисциплины, SSR — отдельная боль (хотя Declarative Shadow DOM в Chrome 90+ частично закрывает вопрос).
Структура проекта
Типовая схема: shell-приложение (хост) + N микрофронтендов, каждый публикует один или несколько кастомных элементов.
monorepo/
├── shell/ # хост, маршрутизация, layout
├── mfe-catalog/ # продуктовый каталог
├── mfe-cart/ # корзина и чекаут
├── mfe-account/ # личный кабинет
└── shared/
├── design-tokens/ # CSS-переменные, общие токены
└── events/ # типизированные события (TypeScript)
Каждый mfe-* собирается в один JS-файл и публикуется в CDN или внутренний npm. Shell подключает их через <script type="module">.
Реализация базового Web Component
// mfe-cart/src/CartWidget.ts
export class CartWidget extends HTMLElement {
private shadow: ShadowRoot;
private _items: CartItem[] = [];
static get observedAttributes() {
return ['user-id', 'currency'];
}
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this.loadItems();
// слушаем события от других MFE
window.addEventListener('product:added', this.handleProductAdded);
}
disconnectedCallback() {
window.removeEventListener('product:added', this.handleProductAdded);
}
attributeChangedCallback(name: string, _old: string, next: string) {
if (name === 'user-id' && next) {
this.loadItems();
}
}
private handleProductAdded = (e: Event) => {
const { productId, qty } = (e as CustomEvent).detail;
this.addToCart(productId, qty);
};
private async loadItems() {
const userId = this.getAttribute('user-id');
if (!userId) return;
const res = await fetch(`/api/cart/${userId}`);
this._items = await res.json();
this.render();
}
private async addToCart(productId: string, qty: number) {
await fetch('/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, qty }),
});
await this.loadItems();
// сообщаем shell и другим MFE
this.dispatchEvent(new CustomEvent('cart:updated', {
detail: { count: this._items.length },
bubbles: true,
composed: true, // пробивает Shadow DOM
}));
}
private render() {
this.shadow.innerHTML = `
<style>
:host {
display: block;
font-family: var(--font-sans, system-ui);
}
.cart-count {
background: var(--color-accent, #e53e3e);
color: white;
border-radius: 50%;
padding: 2px 6px;
font-size: 12px;
}
</style>
<button part="trigger">
Корзина
<span class="cart-count">${this._items.length}</span>
</button>
`;
this.shadow.querySelector('button')
?.addEventListener('click', () => this.openCart());
}
private openCart() {
window.dispatchEvent(new CustomEvent('cart:open'));
}
}
customElements.define('cart-widget', CartWidget);
Межкомпонентная коммуникация
Прямые вызовы между MFE — антипаттерн. Нужна шина событий. Самый простой вариант — window с типизацией:
// shared/events/index.ts
export type AppEvents = {
'product:added': { productId: string; qty: number };
'cart:updated': { count: number };
'user:authenticated': { userId: string; token: string };
'navigation:requested': { path: string };
};
type EventMap = {
[K in keyof AppEvents]: CustomEvent<AppEvents[K]>;
};
declare global {
interface WindowEventMap extends EventMap {}
}
export function emit<K extends keyof AppEvents>(
type: K,
detail: AppEvents[K],
target: EventTarget = window
) {
target.dispatchEvent(new CustomEvent(type, { detail, bubbles: true }));
}
export function on<K extends keyof AppEvents>(
type: K,
handler: (detail: AppEvents[K]) => void,
target: EventTarget = window
) {
const listener = (e: Event) => handler((e as CustomEvent<AppEvents[K]>).detail);
target.addEventListener(type, listener);
return () => target.removeEventListener(type, listener);
}
Shell-приложение и динамическая загрузка
Shell не знает про внутренности MFE — только про их URL и публичное API (атрибуты + события).
// shell/src/registry.ts
interface MFEManifest {
name: string;
url: string;
elements: string[];
}
const manifest: MFEManifest[] = [
{
name: 'cart',
url: 'https://cdn.example.com/[email protected]/index.js',
elements: ['cart-widget', 'cart-drawer'],
},
{
name: 'catalog',
url: 'https://cdn.example.com/[email protected]/index.js',
elements: ['product-card', 'product-list', 'product-filter'],
},
];
export async function loadMFE(name: string): Promise<void> {
const entry = manifest.find(m => m.name === name);
if (!entry) throw new Error(`Unknown MFE: ${name}`);
// проверяем, что элементы ещё не зарегистрированы
const alreadyLoaded = entry.elements.every(
el => customElements.get(el) !== undefined
);
if (alreadyLoaded) return;
await import(/* @vite-ignore */ entry.url);
}
// shell/src/router.ts
import { loadMFE } from './registry';
const routes: Record<string, () => Promise<void>> = {
'/catalog': () => loadMFE('catalog'),
'/cart': () => loadMFE('cart'),
'/account': () => loadMFE('account'),
};
export async function navigate(path: string) {
const loader = routes[path];
if (loader) await loader();
document.querySelector('#app-root')!.innerHTML = getTemplate(path);
history.pushState(null, '', path);
}
Стили: изоляция vs дизайн-система
Shadow DOM изолирует стили полностью. CSS-переменные пробиваются насквозь — это и есть механизм для дизайн-системы:
/* shell/src/global.css — токены, доступные всем MFE */
:root {
--color-primary: #1a56db;
--color-accent: #e3a008;
--color-surface: #f9fafb;
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--radius-md: 8px;
--shadow-sm: 0 1px 3px rgba(0,0,0,.1);
--spacing-unit: 4px;
}
Для более сложной передачи стилей (например, шрифт через @font-face) — Constructable Stylesheets:
// shared/design-tokens/stylesheet.ts
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host { font-family: var(--font-sans, system-ui); }
* { box-sizing: border-box; }
`);
export const baseStyles = sheet;
// В компоненте:
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.adoptedStyleSheets = [baseStyles];
}
Сборка и версионирование
Каждый MFE собирается независимо. Пример конфига Vite:
// mfe-cart/vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: 'src/index.ts',
formats: ['es'],
fileName: 'index',
},
rollupOptions: {
// React/Vue как внешние зависимости только если shell их предоставляет
// Иначе бандлим внутрь — каждый MFE самодостаточен
external: [],
},
target: 'es2020',
},
});
Версионирование через семантический тег в URL CDN. При несовместимом изменении API — мажорная версия, shell переезжает на новый URL явно. Никакой автоматической подтяжки latest.
Тестирование
Unit-тесты компонентов через @web/test-runner (поддерживает реальный DOM, в отличие от jsdom):
// mfe-cart/test/cart-widget.test.ts
import { fixture, html, expect } from '@open-wc/testing';
import '../src/CartWidget';
describe('cart-widget', () => {
it('renders empty cart count', async () => {
const el = await fixture<HTMLElement>(
html`<cart-widget user-id=""></cart-widget>`
);
const count = el.shadowRoot!.querySelector('.cart-count');
expect(count?.textContent).to.equal('0');
});
it('dispatches cart:updated after add', async () => {
const el = await fixture<HTMLElement>(
html`<cart-widget user-id="user-123"></cart-widget>`
);
let eventFired = false;
el.addEventListener('cart:updated', () => { eventFired = true; });
window.dispatchEvent(new CustomEvent('product:added', {
detail: { productId: 'prod-1', qty: 1 }
}));
await new Promise(r => setTimeout(r, 50));
expect(eventFired).to.be.true;
});
});
E2E через Playwright — запускаем shell локально и проверяем интеграцию всех MFE вместе.
Сроки и этапы
Проект с нуля для трёх-четырёх микрофронтендов занимает от шести до десяти недель:
Первые две недели — проектирование: определяем границы MFE, схему событий, стратегию стилей, CI/CD под независимые деплои.
Третья-четвёртая неделя — инфраструктура: shell, шина событий, дизайн-токены, сборочные конфиги, CDN-публикация.
Пятая-восьмая неделя — разработка MFE командами параллельно.
Девятая-десятая — интеграционное тестирование, нагрузочные проверки (lazy-loading не должен давать заметных задержек), production-деплой.
Зрелость подхода прямо зависит от дисциплины команды с версионированием и контрактами событий. Без формализованного shared/events с типами и чейнджлогом микрофронтенды быстро превращаются в распределённый монолит.







