Je voulais comprendre les micro frontends en en construisant un, pas en lisant à leur sujet. Alors je me suis fixé une contrainte : l'expérience devait vivre dans une vraie page de ce site, et la page elle-même devait en être la preuve. Cet article est cette page.
Ce site tourne sur Next.js 16 avec React 19. Le panneau ci-dessous, non. C'est une app Angular 22, avec son propre dépôt, son propre build et son propre déploiement, chargée à l'exécution depuis un autre domaine. À part une URL, rien dans le build de ce site ne sait qu'elle existe.
Chargement du micro frontend Angular…
Aucun événement du micro frontend pour l'instant.
Essayez : changez le thème ou la langue du site, et le panneau suit. Cliquez sur son bouton, et la ligne juste en dessous, qui appartient au site et non à Angular, affiche l'événement. Tout dans le panneau est lu à l'exécution : la version d'Angular, le domaine d'où il vient, le commit à partir duquel il a été buildé et ce qu'il a coûté à télécharger.
Trois façons de composer un frontend
Il n'existe pas de standard unique pour les micro frontends. Il y en a trois courants, et ils résolvent des problèmes différents.
- Découpage par route. Chaque zone du produit est une app distincte, et un proxy ou l'edge envoie chaque chemin vers la bonne app. Dans Next.js, cela s'appelle multi-zones. C'est simple à exploiter ; le coût est un rechargement complet de la page chaque fois qu'on passe d'une app à l'autre.
- Module Federation. L'hôte charge à l'exécution des modules venant d'autres déploiements et partage des dépendances avec eux. Dans le monde Angular, Native Federation est l'option de fait. Elle brille quand toutes les pièces utilisent le même framework.
- Web Components. Le micro frontend est empaqueté en custom element, et l'hôte n'a qu'à afficher une balise. C'est la seule interface que React et Angular comprennent tous les deux nativement.
Ici, l'hôte est React et le micro frontend est Angular : c'est donc une composition entre frameworks. C'est là que les Web Components sont la réponse habituelle, et c'est exactement à ça que sert Angular Elements.
Le contrat
La façon la plus utile que j'ai trouvée de penser un micro frontend, c'est comme un contrat : tout ce qu'un côté sait de l'autre. Ici, ça tient en trois choses.
**Les entrées sont locale et theme.** Ce site les définit comme propriétés de l'élément ; une simple page HTML peut les définir comme attributs, et Angular Elements fait le lien dans les deux cas. Le panneau a son propre dictionnaire pour les cinq langues que ce site prend en charge et ses propres couleurs pour chaque thème. Rien n'est hérité du CSS du site.
La sortie est un événement DOM. Le panneau ne sait pas que React existe. Il émet un CustomEvent, et le site l'écoute :
this.host.nativeElement.dispatchEvent(
new CustomEvent("mfe:ping", {
detail: { angularVersion: this.angularVersion, at: new Date().toISOString() },
bubbles: true,
composed: true,
}),
)L'isolation, c'est le Shadow DOM. Les styles du panneau ne débordent pas sur la page, et le Tailwind du site ne déborde pas dans le panneau.
Côté Angular, tout le micro frontend se résume à enregistrer une balise :
import { provideZonelessChangeDetection } from "@angular/core"
import { createApplication } from "@angular/platform-browser"
import { createCustomElement } from "@angular/elements"
import { InspectorComponent } from "./app/inspector.component"
export const TAG = "mfe-inspector"
export async function registerInspector(): Promise<void> {
if (customElements.get(TAG)) return
const app = await createApplication({ providers: [provideZonelessChangeDetection()] })
// Checked again: two evaluations of this file can both pass the first check.
if (customElements.get(TAG)) return
customElements.define(TAG, createCustomElement(InspectorComponent, { injector: app.injector }))
}Côté site, le charger revient à importer une URL à laquelle le bundler ne doit pas toucher, puis à attendre que la balise soit définie :
loadRemoteModule(async () => {
// The bundler must not resolve this: the URL belongs to another deploy.
await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ src)
await customElements.whenDefined(tag)
})Ce que ça a coûté
Ces chiffres ont été mesurés sur le fichier publié, pas estimés :
- Le panneau est livré dans un seul fichier de 119 Ko, soit 42 Ko compressé. Pour un panneau avec un titre, une courte liste et un bouton, c'est le prix d'un second framework sur la page.
- Sur ma connexion, la médiane de cinq chargements à froid était de 44 ms pour télécharger le fichier et de 50 ms jusqu'à ce que l'élément soit prêt à l'emploi.
- Si le fichier ne se charge pas, le site affiche un court avertissement à la place du panneau en environ un tiers de seconde, et le reste de l'article continue de fonctionner. Pour le cas où le serveur ne répond jamais, il y a un timeout de 8 secondes.
Le fichier n'est demandé que lorsque le panneau approche de l'écran : un lecteur qui ne descend jamais jusqu'ici ne paie jamais pour Angular.
Ce que les tutoriels passent sous silence
L'essentiel du travail n'était pas le composant. C'étaient les bords.
- Faire confiance à du code d'un autre domaine. Le site exécute désormais du JavaScript qu'il n'a pas buildé. Les données de l'article n'acceptent un micro frontend que s'il vient d'une origine autorisée, en HTTPS ; tout le reste est écarté avant le rendu de la page.
- Un seul fichier, volontairement. Le site importe une seule URL. Si un jour le build Angular se découpe en chunks, ce contrat casse en silence, donc le build échoue bruyamment à la place.
- S'enregistrer deux fois. Je m'attendais à ce que quitter la page et y revenir réexécute le module et lève une erreur à la seconde définition de la balise. Ce n'est pas le cas : le navigateur évalue un module une seule fois par URL. Ce qui lève une erreur, c'est le même fichier chargé depuis deux URL, et là une simple vérification ne suffisait pas non plus, car les deux évaluations la passent avant que l'une ou l'autre n'enregistre. La vérification est donc refaite après le démarrage d'Angular.
- Mesurer entre origines. Sans l'en-tête
Timing-Allow-Origin, le navigateur indique 0 octet pour les fichiers d'autres domaines, et le panneau ne pourrait pas distinguer son propre poids d'une copie en cache. Une revalidation 304 a le même piège : seuls les en-têtes transitent, donc le panneau indique cache au lieu d'affirmer que le module pèse 0,3 Ko. - Déployer indépendamment. Le site charge le fichier à l'exécution : un nouveau déploiement du micro frontend arrive dans cet article sans rebuild du site. Je l'ai testé en publiant un changement visible sur le panneau seul. Le cache court (60 secondes, plus jusqu'à 5 minutes à servir la version précédente pendant la revalidation) limite seulement combien de temps un visiteur qui revient peut voir l'ancienne.
Quand ça n'en vaut pas la peine
Pour ce site, un micro frontend est excessif, et c'était tout l'intérêt : je voulais connaître le coût avant d'avoir besoin de le payer. Un second framework, un second pipeline, un contrat à garder synchronisé, et du code d'un autre domaine qui tourne sur la page. Avec une seule équipe et une seule base de code, un monolithe bien organisé apporte presque tous les bénéfices pour une fraction de ce coût.
Ça commence à payer quand le problème, ce sont les gens et non le code : plusieurs équipes, chacune avec son propre rythme de release, qui se marchent dessus dans le même frontend. Ce site n'a pas ce problème. Maintenant, je sais ce que coûte de le résoudre.
Le code du micro frontend est public : github.com/ViniAguiar1/mfe-angular-inspector.
