Реалізація 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/ # хост, маршрутизація, макет
├── 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 з типами та чейнджлогом мікрофронтенди швидко перетворюються на розподілений монліт.







