Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React

Micro-frontends z React - architektura skalowalna

6 min czytania
W tej lekcji4

Flota rośnie: nad jedną aplikacją pracuje pięć zespołów, a każde wdrożenie wymaga zgody wszystkich, bo kod jest jednym monolitem. Poprawka w koszyku czeka na testy katalogu produktów, a konflikt wersji jednej biblioteki blokuje całą załogę. Micro-frontends to wzorzec architektoniczny, który przenosi ideę mikroserwisów na frontend: niezależne zespoły rozwijają, wdrażają i skalują osobne części aplikacji.

Wprowadzenie do Micro-frontends

Czym są Micro-frontends?

Micro-frontends to technika dzielenia monolitycznej aplikacji frontendowej na mniejsze, niezależne aplikacje (MFE), z których każda ma własne repozytorium i proces wdrożenia. Użytkownik widzi jedną stronę, bo aplikacja-powłoka (shell) składa je w całość. Architekturę czytasz od zewnątrz: shell, w nim router, router ładuje zdalne MFE, a każde MFE renderuje własne komponenty wewnętrzne. To jak stacja kosmiczna złożona z modułów różnych agencji, spiętych wspólnym dokiem.

Module Federation (Webpack 5)

Najpopularniejszym sposobem łączenia MFE jest Module Federation, wbudowane w webpack 5 jako require('webpack').container.ModuleFederationPlugin. Pozwala aplikacji ładować w czasie działania moduły wystawione przez inne aplikacje i współdzielić zależności, np. Reacta. Tak wygląda konfiguracja powłoki:

1// webpack.config.js - Shell Application
2const { ModuleFederationPlugin } = require('webpack').container;
3
4module.exports = {
5  mode: 'development',
6  devServer: {
7    port: 3000,
8  },
9  plugins: [
10    new ModuleFederationPlugin({
11      name: 'shell',
12      remotes: {
13        products: 'products@http://localhost:3001/remoteEntry.js',
14        cart: 'cart@http://localhost:3002/remoteEntry.js',
15        user: 'user@http://localhost:3003/remoteEntry.js',
16      },
17      shared: {
18        react: { singleton: true, eager: true },
19        'react-dom': { singleton: true, eager: true },
20        '@shared/ui-kit': { singleton: true },
21        '@shared/utils': { singleton: true },
22      },
23    }),
24  ],
25};

remotes mapuje nazwy na adresy plików remoteEntry.js, które publikuje każde MFE. shared z singleton: true pilnuje, żeby w przeglądarce działała jedna kopia Reacta, bo biblioteki z globalnym stanem źle znoszą duplikaty, a eager: true dołącza je do początkowej paczki. Po dodatki, jak manifest i wtyczki runtime, sięgnij po Module Federation 2.0 z pakietu @module-federation/enhanced.

Implementacja Shell Application

Powłoka ładuje MFE leniwie przez lazy, jak zwykłe komponenty. Ścieżka products/ProductsApp wskazuje moduł z remote o nazwie products:

1// src/App.jsx - Shell Application
2import React, { Suspense, lazy } from 'react';
3import { BrowserRouter, Routes, Route } from 'react-router-dom';
4import ErrorBoundary from './components/ErrorBoundary';
5import Layout from './components/Layout';
6
7// Leniwe ładowanie micro-frontendów
8const ProductsApp = lazy(() => import('products/ProductsApp'));
9const CartApp = lazy(() => import('cart/CartApp'));
10const UserApp = lazy(() => import('user/UserApp'));
11
12// Lokalne komponenty powłoki
13const HomePage = lazy(() => import('./pages/HomePage'));
14
15function App() {
16  return (
17    <ErrorBoundary>
18      <BrowserRouter>
19        <Layout>
20          <Suspense fallback={<div>Ładowanie...</div>}>
21            <Routes>
22              <Route path="/" element={<HomePage />} />
23
24              {/* Micro-frontend produktów */}
25              <Route
26                path="/products/*"
27                element={
28                  <ErrorBoundary fallback={<div>Katalog chwilowo niedostępny</div>}>
29                    <ProductsApp />
30                  </ErrorBoundary>
31                }
32              />
33
34              {/* Micro-frontend koszyka */}
35              <Route
36                path="/cart/*"
37                element={
38                  <ErrorBoundary fallback={<div>Koszyk chwilowo niedostępny</div>}>
39                    <CartApp />
40                  </ErrorBoundary>
41                }
42              />
43
44              {/* Micro-frontend profilu użytkownika */}
45              <Route
46                path="/user/*"
47                element={
48                  <ErrorBoundary fallback={<div>Profil chwilowo niedostępny</div>}>
49                    <UserApp />
50                  </ErrorBoundary>
51                }
52              />
53            </Routes>
54          </Suspense>
55        </Layout>
56      </BrowserRouter>
57    </ErrorBoundary>
58  );
59}
60
61export default App;

Każde MFE ma własny ErrorBoundary, więc awaria koszyka nie wyłączy katalogu, a Suspense pokazuje stan ładowania, zanim kod remote dotrze przez sieć. Ścieżki z /* oddają dalsze adresy routerowi wewnątrz MFE.

Komunikacja między Micro-frontends

Architektura sterowana zdarzeniami

Micro-frontendy nie powinny importować siebie nawzajem, bo wtedy znowu powstaje monolit. Najczęściej rozmawiają przez zdarzenia: Event Bus w pamięci i natywne CustomEvent na obiekcie window:

1// shared/eventBus.js
2class EventBus {
3  constructor() {
4    this.events = {};
5  }
6
7  subscribe(eventName, callback) {
8    if (!this.events[eventName]) {
9      this.events[eventName] = [];
10    }
11    this.events[eventName].push(callback);
12
13    // Zwróć funkcję wypisania
14    return () => {
15      this.events[eventName] = this.events[eventName].filter(
16        cb => cb !== callback
17      );
18    };
19  }
20
21  emit(eventName, data) {
22    if (this.events[eventName]) {
23      this.events[eventName].forEach(callback => callback(data));
24    }
25
26    // Wyślij też CustomEvent, żeby usłyszały go inne MFE
27    window.dispatchEvent(new CustomEvent(`mfe:${eventName}`, {
28      detail: data
29    }));
30  }
31}
32
33export const eventBus = new EventBus();

subscribe zwraca funkcję wypisania, więc komponent odłączy się w cleanupie efektu. emit wysyła też CustomEvent z prefiksem mfe:, który usłyszy każde MFE, nawet napisane w innym frameworku.

Współdzielony stan

Niektóre dane, jak zalogowany użytkownik czy koszyk, muszą być wspólne. Store z zustand/vanilla działa bez Reacta, więc może żyć we współdzielonym module:

1// shared/stateManager.js
2import { createStore } from 'zustand/vanilla';
3
4// Globalny store dostępny dla wszystkich MFE
5const globalStore = createStore((set, get) => ({
6  // Stan użytkownika
7  user: null,
8  isAuthenticated: false,
9
10  // Stan koszyka
11  cartItems: [],
12  cartTotal: 0,
13
14  // Akcje
15  setUser: (user) => set({ user, isAuthenticated: !!user }),
16
17  addToCart: (product) => set(state => {
18    const existingItem = state.cartItems.find(item => item.id === product.id);
19    let newItems;
20
21    if (existingItem) {
22      newItems = state.cartItems.map(item =>
23        item.id === product.id
24          ? { ...item, quantity: item.quantity + 1 }
25          : item
26      );
27    } else {
28      newItems = [...state.cartItems, { ...product, quantity: 1 }];
29    }
30
31    const newTotal = newItems.reduce((sum, item) =>
32      sum + (item.price * item.quantity), 0
33    );
34
35    return { cartItems: newItems, cartTotal: newTotal };
36  }),
37
38  removeFromCart: (productId) => set(state => {
39    const newItems = state.cartItems.filter(item => item.id !== productId);
40    const newTotal = newItems.reduce((sum, item) =>
41      sum + (item.price * item.quantity), 0
42    );
43
44    return { cartItems: newItems, cartTotal: newTotal };
45  }),
46}));

addToCart zwiększa quantity, gdy produkt już leży w koszyku, a cartTotal przelicza się przy każdej zmianie. Wspólny store to jednak zależność między zespołami, więc radzę Ci trzymać w nim tylko naprawdę globalne dane, a resztę przesyłać zdarzeniami.

Testowanie micro-frontendów

Test integracyjny sprawdza, czy powłoka poprawnie ładuje MFE, a zdalny moduł zastępuje mock z jest.mock:

1// __tests__/integration/mfe-integration.test.jsx
2import { render, screen, waitFor } from '@testing-library/react';
3import userEvent from '@testing-library/user-event';
4import App from '../App';
5
6// Mock zdalnego MFE: modułu nie ma na dysku, dlatego virtual: true
7jest.mock('products/ProductsApp', () => {
8  return function MockProductsApp() {
9    return <div data-testid="products-mfe">Products MFE Loaded</div>;
10  };
11}, { virtual: true });
12
13describe('MFE Integration', () => {
14  test('loads products MFE when navigating to /products', async () => {
15    const user = userEvent.setup();
16
17    // App sam renderuje BrowserRouter, więc nie owijamy go drugim routerem
18    render(<App />);
19
20    // Przejdź do produktów
21    await user.click(screen.getByRole('link', { name: /products/i }));
22
23    // Sprawdź, czy MFE się załadował
24    await waitFor(() => {
25      expect(screen.getByTestId('products-mfe')).toBeInTheDocument();
26    });
27  });
28});

Test klika link, a waitFor czeka, aż leniwie załadowany mock pojawi się w DOM. Zdalnego modułu nie ma na dysku, dlatego jest.mock dostaje opcję { virtual: true } (zamiast niej możesz dodać wpis w moduleNameMapper). App sam renderuje BrowserRouter, więc test renderuje go bez drugiego routera, bo React Router zgłosiłby błąd zagnieżdżenia.

Kiedy warto?

Micro-frontends oferują potężne możliwości skalowania aplikacji React w dużych organizacjach, ale wymagają przemyślanej architektury i zarządzania złożonością: osobnych pipeline'ów, kontraktów zdarzeń i pilnowania wersji wspólnych zależności. Dla jednego zespołu to zwykle przerost formy, bo dobrze podzielony monolit będzie prostszy. W ćwiczeniu zbudujesz orkiestrację kilku MFE ze współdzielonymi zależnościami i komunikacją przez zdarzenia.

Pamiętaj: micro-frontend to moduł stacji od innej agencji - dokuje do wspólnej powłoki, ale startuje według własnego harmonogramu. W przykładzie poniżej przełącz sekcję w nawigacji albo wyłącz system: komunikator dostanie zdarzenie z Event Busa, a powłoka usłyszy CustomEvent na obiekcie window.

Kod do tej lekcji: App.jsx
1import { useCallback, useEffect, useState } from 'react';
2
3// === Micro-frontends - symulacja w jednym pliku ===
4// Każdy moduł MF udaje osobną aplikację z własnym stanem. Moduły nie importują
5// siebie nawzajem, tylko rozmawiają przez Event Bus, jak w lekcji.
6
7class EventBus {
8  constructor() {
9    this.events = {};
10  }
11
12  subscribe(eventName, callback) {
13    if (!this.events[eventName]) this.events[eventName] = [];
14    this.events[eventName].push(callback);
15
16    // Funkcja wypisania do cleanupu efektu
17    return () => {
18      this.events[eventName] = this.events[eventName].filter(cb => cb !== callback);
19    };
20  }
21
22  emit(eventName, data) {
23    (this.events[eventName] || []).forEach(callback => callback(data));
24
25    // CustomEvent z prefiksem mfe: usłyszy też MF napisany w innym frameworku
26    window.dispatchEvent(new CustomEvent('mfe:' + eventName, { detail: data }));
27  }
28}
29
30const eventBus = new EventBus();
31
32// Hook do nasłuchiwania zdarzeń z innych modułów
33function useEventBus(eventName, handler) {
34  useEffect(() => eventBus.subscribe(eventName, handler), [eventName, handler]);
35}
36
37// MF-1: Nawigacja stacji kosmicznej
38function NavigationModule() {
39  const [activeSection, setActiveSection] = useState('panel');
40  const sections = [
41    { id: 'panel', label: 'Panel główny' },
42    { id: 'crew', label: 'Załoga' },
43    { id: 'systems', label: 'Systemy' },
44    { id: 'cargo', label: 'Ładownia' },
45  ];
46
47  const navigate = section => {
48    setActiveSection(section.id);
49    eventBus.emit('navigation:change', { label: section.label });
50  };
51
52  return (
53    <div style={{ ...styles.module, border: '2px solid #00d4ff' }}>
54      <div style={styles.moduleHeader}>
55        <h3 style={{ ...styles.moduleTitle, color: '#00d4ff' }}>MF-1: Nawigacja</h3>
56        <span style={styles.port}>port 3001</span>
57      </div>
58      <div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
59        {sections.map(section => (
60          <button
61            key={section.id}
62            onClick={() => navigate(section)}
63            style={{ ...styles.navButton, ...(activeSection === section.id ? styles.navActive : null) }}
64          >
65            {section.label}
66          </button>
67        ))}
68      </div>
69    </div>
70  );
71}
72
73// MF-2: Panel statusu systemów
74function SystemStatusModule() {
75  const [systems, setSystems] = useState([
76    { name: 'Napęd warp', online: true, health: 95 },
77    { name: 'Tarcze', online: true, health: 88 },
78    { name: 'Komunikacja', online: true, health: 100 },
79    { name: 'Podtrzymywanie życia', online: true, health: 92 },
80  ]);
81
82  const toggleSystem = index => {
83    const system = systems[index];
84    const online = !system.online;
85    // Najpierw nowy stan, potem zdarzenie - updater setState zostaje czysty
86    setSystems(prev => prev.map((s, i) => (i === index ? { ...s, online } : s)));
87    eventBus.emit('status:update', { system: system.name, online });
88  };
89
90  return (
91    <div style={{ ...styles.module, border: '2px solid #00ff88' }}>
92      <div style={styles.moduleHeader}>
93        <h3 style={{ ...styles.moduleTitle, color: '#00ff88' }}>MF-2: Status systemów</h3>
94        <span style={styles.port}>port 3002</span>
95      </div>
96      {systems.map((system, i) => (
97        <button key={system.name} onClick={() => toggleSystem(i)} style={styles.systemRow}>
98          <span style={{ ...styles.dot, background: system.online ? '#00ff88' : '#ff6b6b' }} />
99          <span style={{ flex: 1, textAlign: 'left' }}>{system.name}</span>
100          <span style={{ color: '#778da9' }}>{system.online ? system.health + '%' : 'wyłączony'}</span>
101        </button>
102      ))}
103    </div>
104  );
105}
106
107// MF-3: Komunikator załogi - słucha zdarzeń z MF-1 i MF-2
108function CrewChatModule() {
109  const [messages, setMessages] = useState([
110    { from: 'Komandor Nova', text: 'Systemy sprawne, gotowi do skoku.', type: 'crew' },
111    { from: 'Astro', text: 'Napęd warp naładowany.', type: 'crew' },
112  ]);
113  const [input, setInput] = useState('');
114
115  // Stabilne funkcje (useCallback), żeby nie zapisywać się do szyny przy każdym renderze
116  const onStatusUpdate = useCallback(data => {
117    setMessages(prev => [...prev, { from: 'System', text: data.system + (data.online ? ' włączony' : ' wyłączony'), type: 'system' }]);
118  }, []);
119
120  const onNavigationChange = useCallback(data => {
121    setMessages(prev => [...prev, { from: 'Nawigacja', text: 'Przejście do sekcji: ' + data.label, type: 'system' }]);
122  }, []);
123
124  useEventBus('status:update', onStatusUpdate);
125  useEventBus('navigation:change', onNavigationChange);
126
127  const send = () => {
128    if (!input.trim()) return;
129    setMessages(prev => [...prev, { from: 'Ty', text: input, type: 'crew' }]);
130    setInput('');
131  };
132
133  return (
134    <div style={{ ...styles.module, border: '2px solid #ffaa00' }}>
135      <div style={styles.moduleHeader}>
136        <h3 style={{ ...styles.moduleTitle, color: '#ffaa00' }}>MF-3: Komunikator</h3>
137        <span style={styles.port}>port 3003</span>
138      </div>
139      <div style={styles.chat}>
140        {messages.map((msg, i) => (
141          <div key={i} style={{ marginBottom: '4px' }}>
142            <strong style={{ color: msg.type === 'system' ? '#ff6b6b' : '#00d4ff' }}>{msg.from}: </strong>
143            <span>{msg.text}</span>
144          </div>
145        ))}
146      </div>
147      <div style={{ display: 'flex', gap: '6px' }}>
148        <input
149          value={input}
150          onChange={e => setInput(e.target.value)}
151          onKeyDown={e => e.key === 'Enter' && send()}
152          placeholder="Wiadomość..."
153          style={styles.input}
154        />
155        <button onClick={send} style={styles.sendButton}>Wyślij</button>
156      </div>
157    </div>
158  );
159}
160
161// Shell Application - składa moduły i słucha CustomEvent na window
162export default function App() {
163  const [currentSection, setCurrentSection] = useState('Panel główny');
164
165  useEffect(() => {
166    const onNavigation = event => setCurrentSection(event.detail.label);
167    window.addEventListener('mfe:navigation:change', onNavigation);
168    return () => window.removeEventListener('mfe:navigation:change', onNavigation);
169  }, []);
170
171  return (
172    <div style={styles.page}>
173      <div style={{ maxWidth: '620px', margin: '0 auto' }}>
174        <h1 style={styles.title}>Shell Application - Stacja Kosmiczna</h1>
175        <p style={styles.subtitle}>
176          Shell (port 3000) ładuje trzy niezależne moduły. Aktywna sekcja według zdarzenia z MF-1: <strong style={{ color: '#00d4ff' }}>{currentSection}</strong>
177        </p>
178
179        <div style={{ display: 'grid', gap: '12px' }}>
180          <NavigationModule />
181          <SystemStatusModule />
182          <CrewChatModule />
183        </div>
184
185        <div style={styles.patterns}>
186          <h4 style={{ margin: '0 0 8px', color: '#778da9' }}>Kluczowe wzorce micro-frontends:</h4>
187          <ul style={{ margin: 0, paddingLeft: '20px', lineHeight: 1.8 }}>
188            <li><strong style={{ color: '#00d4ff' }}>Module Federation</strong> - ładowanie modułów innych aplikacji w czasie działania</li>
189            <li><strong style={{ color: '#00ff88' }}>Event Bus i CustomEvent</strong> - luźno powiązana komunikacja</li>
190            <li><strong style={{ color: '#ffaa00' }}>Niezależne wdrożenia</strong> - każdy zespół wdraża swój moduł osobno</li>
191            <li><strong style={{ color: '#ff6b6b' }}>Własny stan</strong> - wspólny store tylko dla naprawdę globalnych danych</li>
192          </ul>
193        </div>
194      </div>
195    </div>
196  );
197}
198
199const styles = {
200  page: { background: '#0d1b2a', minHeight: '100vh', padding: '20px', color: '#e0e1dd', fontFamily: 'system-ui, sans-serif', fontSize: '13px' },
201  title: { color: '#00d4ff', textAlign: 'center', fontSize: '19px', margin: '0 0 6px' },
202  subtitle: { color: '#778da9', textAlign: 'center', margin: '0 0 16px' },
203  module: { background: '#1b2838', padding: '14px', borderRadius: '12px' },
204  moduleHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' },
205  moduleTitle: { margin: 0, fontSize: '14px' },
206  port: { fontSize: '10px', color: '#778da9', border: '1px solid #415a77', padding: '2px 8px', borderRadius: '4px' },
207  navButton: { padding: '6px 12px', borderRadius: '6px', border: '1px solid #415a77', background: 'transparent', color: '#778da9', cursor: 'pointer' },
208  navActive: { border: '1px solid #00d4ff', background: 'rgba(0,212,255,0.15)', color: '#00d4ff' },
209  systemRow: { display: 'flex', alignItems: 'center', gap: '10px', width: '100%', background: '#0d1b2a', color: '#e0e1dd', border: 'none', padding: '8px 12px', borderRadius: '8px', cursor: 'pointer', marginBottom: '6px', fontSize: '13px' },
210  dot: { width: '8px', height: '8px', borderRadius: '50%', flexShrink: 0 },
211  chat: { background: '#0d1b2a', padding: '10px', borderRadius: '8px', maxHeight: '140px', overflowY: 'auto', marginBottom: '8px' },
212  input: { flex: 1, padding: '6px 10px', borderRadius: '6px', border: '1px solid #415a77', background: '#0d1b2a', color: '#e0e1dd' },
213  sendButton: { padding: '6px 12px', borderRadius: '6px', border: 'none', background: '#ffaa00', color: '#0d1b2a', fontWeight: 'bold', cursor: 'pointer' },
214  patterns: { marginTop: '16px', background: '#1b2838', padding: '12px', borderRadius: '8px', border: '1px solid #415a77' },
215};

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Do czego służy Module Federation w kontekście micro-frontends?

  2. 2. Który wzorzec komunikacji jest najczęściej używany między micro-frontends?

Zadania praktyczne w grze

  • Układanie w pionie

    Uporządkuj elementy architektury micro-frontends od zewnętrznego do wewnętrznego:

  • Edytor kodu

    Połącz dwa niezależne micro-frontendy szyną zdarzeń: katalog części (MFE zespołu Astro) wysyła zdarzenie cargo:add, a ładownia (MFE zespołu Komandor Nova) je odbiera. ___BLANK1___: funkcja zwracana przez on() wypisuje tylko tę jedną subskrypcję (użyj metody off z tym samym event i handler). ___BLANK2___: emit wywołuje każdą zapisaną funkcję z danymi zdarzenia (payload). ___BLANK3___: efekt w CargoHold zwraca funkcję wypisującą subskrypcję, żeby po ukryciu ładowni nie zostawał osierocony słuchacz (i żeby podwójny efekt w StrictMode nie dodawał części dwa razy).

Przydatne artykuły