Cours JavaScript et React · Module 10: Écosystème et avenir de React

Micro-frontends avec React - une architecture évolutive

7 min de lecture
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. 1. À quoi sert Module Federation dans le contexte des micro-frontends ?

  2. 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).

Articles utiles