Реалізація Web Components для мікрофронтендної архітектури

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація Web Components для мікрофронтендної архітектури
Складна
від 2 тижнів до 3 місяців
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

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