Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React
Micro-frontends z React - architektura skalowalna
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. Do czego służy Module Federation w kontekście micro-frontends?
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).