Cours JavaScript et React · Module 10: Écosystème et avenir de React
Micro-frontends avec React - une architecture évolutive
Dans cette leçon4
La flotte s'agrandit : cinq équipes travaillent sur une seule application, et chaque déploiement exige l'accord de tout le monde, car le code est un seul monolithe. Un correctif dans le panier attend les tests du catalogue de produits, et un conflit de version d'une seule bibliothèque bloque tout l'équipage. Les micro-frontends sont un pattern d'architecture qui transpose l'idée des microservices au frontend : des équipes indépendantes développent, déploient et font évoluer des parties séparées de l'application.
Introduction aux micro-frontends
Que sont les micro-frontends ?
Les micro-frontends sont une technique qui consiste à découper une application frontend monolithique en applications plus petites et indépendantes (MFE), chacune avec son propre dépôt et son propre processus de déploiement. L'utilisateur voit une seule page, car une application-coque (shell) les assemble. Tu lis l'architecture de l'extérieur vers l'intérieur : le shell, à l'intérieur le routeur, le routeur charge les MFE distants, et chaque MFE rend ses propres composants internes. C'est comme une station spatiale assemblée à partir de modules de différentes agences, reliés par un dock commun.
Module Federation (Webpack 5)
La façon la plus répandue de relier des MFE est Module Federation, intégré à webpack 5 sous la forme require('webpack').container.ModuleFederationPlugin. Il permet à une application de charger à l'exécution des modules exposés par d'autres applications et de partager des dépendances, par exemple React. Voici à quoi ressemble la configuration du shell :
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 associe des noms aux adresses des fichiers remoteEntry.js que publie chaque MFE. shared avec singleton: true veille à ce qu'une seule copie de React tourne dans le navigateur, car les bibliothèques à état global supportent mal les doublons, et eager: true les inclut dans le bundle initial. Pour les extras, comme un manifeste et des plugins d'exécution, utilise Module Federation 2.0 du paquet @module-federation/enhanced.
Implémentation de la Shell Application
Le shell charge les MFE de façon paresseuse avec lazy, comme des composants ordinaires. Le chemin products/ProductsApp désigne un module du remote nommé 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// Chargement paresseux des micro-frontends
8const ProductsApp = lazy(() => import('products/ProductsApp'));
9const CartApp = lazy(() => import('cart/CartApp'));
10const UserApp = lazy(() => import('user/UserApp'));
11
12// Composants locaux du shell
13const HomePage = lazy(() => import('./pages/HomePage'));
14
15function App() {
16 return (
17 <ErrorBoundary>
18 <BrowserRouter>
19 <Layout>
20 <Suspense fallback={<div>Chargement...</div>}>
21 <Routes>
22 <Route path="/" element={<HomePage />} />
23
24 {/* Micro-frontend des produits */}
25 <Route
26 path="/products/*"
27 element={
28 <ErrorBoundary fallback={<div>Catalogue momentanément indisponible</div>}>
29 <ProductsApp />
30 </ErrorBoundary>
31 }
32 />
33
34 {/* Micro-frontend du panier */}
35 <Route
36 path="/cart/*"
37 element={
38 <ErrorBoundary fallback={<div>Panier momentanément indisponible</div>}>
39 <CartApp />
40 </ErrorBoundary>
41 }
42 />
43
44 {/* Micro-frontend du profil utilisateur */}
45 <Route
46 path="/user/*"
47 element={
48 <ErrorBoundary fallback={<div>Profil momentanément indisponible</div>}>
49 <UserApp />
50 </ErrorBoundary>
51 }
52 />
53 </Routes>
54 </Suspense>
55 </Layout>
56 </BrowserRouter>
57 </ErrorBoundary>
58 );
59}
60
61export default App;Chaque MFE a son propre ErrorBoundary, donc une panne du panier ne mettra pas le catalogue hors service, et Suspense affiche un état de chargement avant que le code distant n'arrive par le réseau. Les chemins terminés par /* confient la suite de l'adresse au routeur à l'intérieur du MFE.
Communication entre micro-frontends
Architecture orientée événements
Les micro-frontends ne devraient pas s'importer les uns les autres, car un monolithe se reformerait. Le plus souvent, ils communiquent par des événements : un Event Bus en mémoire et des CustomEvent natifs sur l'objet 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 // Renvoie la fonction de désabonnement
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 // Émet aussi un CustomEvent pour que les autres MFE l'entendent
27 window.dispatchEvent(new CustomEvent(`mfe:${eventName}`, {
28 detail: data
29 }));
30 }
31}
32
33export const eventBus = new EventBus();subscribe renvoie une fonction de désabonnement, donc un composant se détachera dans le cleanup de l'effet. emit envoie aussi un CustomEvent avec le préfixe mfe:, que chaque MFE entendra, même s'il est écrit dans un autre framework.
Gestion de l'état partagé
Certaines données, comme l'utilisateur connecté ou le panier, doivent être partagées. Un store de zustand/vanilla fonctionne sans React, donc il peut vivre dans un module partagé :
1// shared/stateManager.js
2import { createStore } from 'zustand/vanilla';
3
4// Store global accessible à tous les MFE
5const globalStore = createStore((set, get) => ({
6 // État de l'utilisateur
7 user: null,
8 isAuthenticated: false,
9
10 // État du panier
11 cartItems: [],
12 cartTotal: 0,
13
14 // Actions
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 augmente quantity quand le produit est déjà dans le panier, et cartTotal est recalculé à chaque changement. Un store partagé reste pourtant une dépendance entre équipes, donc je te conseille de n'y garder que les données vraiment globales, et de transmettre le reste par des événements.
Tester les micro-frontends
Un test d'intégration vérifie que le shell charge correctement le MFE, et le module distant est remplacé par un mock via 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 du MFE distant : le module n'existe pas sur le disque, d'où 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 rend lui-même BrowserRouter, donc on ne l'enveloppe pas dans un deuxième routeur
18 render(<App />);
19
20 // Navigue vers les produits
21 await user.click(screen.getByRole('link', { name: /products/i }));
22
23 // Vérifie que le MFE est chargé
24 await waitFor(() => {
25 expect(screen.getByTestId('products-mfe')).toBeInTheDocument();
26 });
27 });
28});Le test clique sur un lien, et waitFor attend que le mock chargé paresseusement apparaisse dans le DOM. Le module distant n'existe pas sur le disque, c'est pourquoi jest.mock reçoit l'option { virtual: true } (à la place, tu peux ajouter une entrée dans moduleNameMapper). App rend lui-même BrowserRouter, donc le test le rend sans deuxième routeur, car React Router signalerait une erreur d'imbrication.
Quand est-ce utile ?
Les micro-frontends offrent de puissantes possibilités pour faire évoluer des applications React dans les grandes organisations, mais ils exigent une architecture réfléchie et une gestion de la complexité : des pipelines séparés, des contrats d'événements et une surveillance des versions des dépendances partagées. Pour une seule équipe, c'est généralement disproportionné, car un monolithe bien découpé sera plus simple. Dans l'exercice, tu construiras l'orchestration de plusieurs MFE avec des dépendances partagées et une communication par événements.
Retiens : un micro-frontend est un module de station venu d'une autre agence - il s'amarre à la coque commune, mais décolle selon son propre calendrier. Dans l'exemple ci-dessous, change de section dans la navigation ou désactive un système : la messagerie reçoit un événement de l'Event Bus, et le shell entend un CustomEvent sur l'objet window.
Code de cette leçon : App.jsx
1import { useCallback, useEffect, useState } from 'react';
2
3// === Micro-frontends - une simulation dans un seul fichier ===
4// Chaque module MF joue le rôle d’une application séparée avec son propre état. Les modules
5// ne s’importent pas les uns les autres, ils communiquent par l’Event Bus, comme dans la leçon.
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 // Fonction de désabonnement pour le cleanup de l’effet
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 // Un CustomEvent avec le préfixe mfe: est entendu même par un MF écrit dans un autre framework
26 window.dispatchEvent(new CustomEvent('mfe:' + eventName, { detail: data }));
27 }
28}
29
30const eventBus = new EventBus();
31
32// Hook pour écouter les événements des autres modules
33function useEventBus(eventName, handler) {
34 useEffect(() => eventBus.subscribe(eventName, handler), [eventName, handler]);
35}
36
37// MF-1 : Navigation de la station spatiale
38function NavigationModule() {
39 const [activeSection, setActiveSection] = useState('panel');
40 const sections = [
41 { id: 'panel', label: 'Panneau principal' },
42 { id: 'crew', label: 'Équipage' },
43 { id: 'systems', label: 'Systèmes' },
44 { id: 'cargo', label: 'Soute' },
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 : Navigation</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 : Panneau d’état des systèmes
74function SystemStatusModule() {
75 const [systems, setSystems] = useState([
76 { name: 'Propulsion warp', online: true, health: 95 },
77 { name: 'Boucliers', online: true, health: 88 },
78 { name: 'Communications', online: true, health: 100 },
79 { name: 'Support vital', online: true, health: 92 },
80 ]);
81
82 const toggleSystem = index => {
83 const system = systems[index];
84 const online = !system.online;
85 // D’abord le nouvel état, puis l’événement - l’updater de setState reste pur
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 : État des systèmes</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 + '%' : 'hors ligne'}</span>
101 </button>
102 ))}
103 </div>
104 );
105}
106
107// MF-3 : Messagerie de l’équipage - écoute les événements de MF-1 et MF-2
108function CrewChatModule() {
109 const [messages, setMessages] = useState([
110 { from: 'Commandant Nova', text: 'Systèmes opérationnels, prêts pour le saut.', type: 'crew' },
111 { from: 'Astro', text: 'Propulsion warp chargée.', type: 'crew' },
112 ]);
113 const [input, setInput] = useState('');
114
115 // Fonctions stables (useCallback), pour ne pas se réabonner au bus à chaque rendu
116 const onStatusUpdate = useCallback(data => {
117 setMessages(prev => [...prev, { from: 'Système', text: data.system + (data.online ? ' en ligne' : ' hors ligne'), type: 'system' }]);
118 }, []);
119
120 const onNavigationChange = useCallback(data => {
121 setMessages(prev => [...prev, { from: 'Navigation', text: 'Passage à la section : ' + 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: 'Toi', 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 : Messagerie</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="Message..."
153 style={styles.input}
154 />
155 <button onClick={send} style={styles.sendButton}>Envoyer</button>
156 </div>
157 </div>
158 );
159}
160
161// Shell Application - assemble les modules et écoute les CustomEvent sur window
162export default function App() {
163 const [currentSection, setCurrentSection] = useState('Panneau principal');
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 - Station spatiale</h1>
175 <p style={styles.subtitle}>
176 Le shell (port 3000) charge trois modules indépendants. Section active d’après l’événement de 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' }}>Patterns clés des micro-frontends :</h4>
187 <ul style={{ margin: 0, paddingLeft: '20px', lineHeight: 1.8 }}>
188 <li><strong style={{ color: '#00d4ff' }}>Module Federation</strong> - chargement à l’exécution des modules d’autres applications</li>
189 <li><strong style={{ color: '#00ff88' }}>Event Bus et CustomEvent</strong> - une communication faiblement couplée</li>
190 <li><strong style={{ color: '#ffaa00' }}>Déploiements indépendants</strong> - chaque équipe déploie son module séparément</li>
191 <li><strong style={{ color: '#ff6b6b' }}>État propre</strong> - un store partagé seulement pour les données vraiment globales</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};Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. À quoi sert Module Federation dans le contexte des micro-frontends ?
2. Quel modèle de communication est le plus couramment utilisé entre les micro-frontends ?
Tâches pratiques dans le jeu
- Tri vertical
Ordonne les éléments de l'architecture des micro-frontends, du plus externe au plus interne :
- Éditeur de code
Relie deux micro-frontends indépendants par un bus d'événements : le catalogue de pièces (MFE de l'équipe Astro) envoie l'événement cargo:add, et la soute (MFE de l'équipe du commandant Nova) le reçoit. ___BLANK1___ : la fonction renvoyée par on() supprime uniquement cet abonnement (utilise la méthode off avec les mêmes event et handler). ___BLANK2___ : emit appelle chaque fonction enregistrée avec les données de l'événement (payload). ___BLANK3___ : l'effet dans CargoHold renvoie une fonction qui supprime l'abonnement, pour qu'aucun écouteur orphelin ne reste après avoir masqué la soute (et que le double effet de StrictMode n'ajoute pas les pièces deux fois).