Die Micro-Frontend-Revolution: Webpack 5 Module Federation
Das neue Feature des Bundlers fĂĽr JavaScript-Module erlaubt das Laden von Anwendungsteilen aus separat kompilierten und bereitgestellten Anwendungen.

(Bild: QuinceMedia, gemeinfrei)
- Manfred Steyer
Es ist hinlänglich bekannt, dass monolithische Anwendungen schwierig zu warten sind. Im Backend sind deswegen Microservices derzeit äußerst beliebt. Jeder Microservice repräsentiert eine in sich geschlossene Domäne und soll möglichst wenig von anderen Microservices wissen. Einzelne Teams können somit weitgehend autark an einem Microservice arbeiten.
Analog dazu ist bei Clients von Micro Frontends die Rede: kleine Anwendungen, die gemeinsam ein größeres Ganzes ergeben, ohne voneinander abhängig zu sein. Die Implementierung solcher Software war bis jetzt jedoch schwierig. Es fehlte an guten Mechanismen, um die einzelnen Micro Frontends ineinander zu integrieren.
Die populäre Bundling-Software Webpack ändert das nun mit Version 5, indem es ein Feature namens Module Federation einführt. Damit hat eine Anwendung die Möglichkeit, Programmteile aus einer anderen Anwendung zu laden. Die einzelnen Applikationen lassen sich separat entwickeln, kompilieren und bereitstellen. Dieser Artikel zeigt, wie sich Module Federation nutzen lässt. Der verwendete Quellcode findet sich auf GitHub.
Die Notwendigkeit fĂĽr Module Federation
Es mag verwundern, dass fĂĽr das Laden von Programmteilen aus anderen Webanwendungen eine eigene Funktionsweise wie Module Federation notwendig ist. Letztlich bestehen sie aus einzelnen Dateien, die ĂĽber eine URL erreichbar sind. Jede Applikation kann also prinzipiell auch Dateien aus dem Hoheitsgebiet anderer Anwendungen laden. Insofern mĂĽsste ein Aufruf wie dieser hier genĂĽgen:
const comp = await import('http://otherApp/comp'); // does not work!
Leider ist das jedoch zu kurz gedacht, denn Bundler wie Webpack erlauben so etwas nicht. Der Grund ist, dass Webpack vorsieht, dass sämtliche Abhängigkeiten beim Bauen der Anwendung vorhanden sind. Er kompiliert den gesamten Quellcode zu möglichst kleinen Bundles. Dank des sogenannten Tree-Shaking entfernt er auch nicht benötigte Codestrecken wie nie durchlaufene Framework-Bestandteile. Erst dann splittetet Webpack den Code in sogenannte Chunks, die sich per Lazy Loading in die Anwendung laden lassen.
Die Idee hinter Module Federation
Module Federation definiert zwei Arten von Anwendungen: den Host und den Remote. In einem Micro Frontend ist der Host die Rahmenanwendung (Shell), der Micro Frontends lädt, die als Remote auftreten. Lädt ein Micro Frontend weitere Micro Frontends, tritt es in beiden Rollen auf. Die Details hierzu sind in der Webpack-Konfiguration der Anwendungen einzutragen (s. Abb. 1).
Der Host definiert URLs, die auf einzelne Remotes verweisen. Im gezeigten Fall verweist jede URL, die mit mfe1
(Micro Frontend 1) beginnt, auf das daneben dargestellte Micro Frontend mit demselben Namen.
Das Micro Frontend veröffentlicht Teile seines Programmcodes. Jenes in Abbildung 1 veröffentlicht beispielsweise eine Komponente unter dem Namen Component
. Der Host kann diese Komponente nun mit einem dynamischen import
zur Laufzeit laden. Dazu nutzt er eine URL, die sich aus den auf beiden Seiten konfigurierten Namen zusammensetzt.
Damit die Shell weiĂź, wo sie das Micro Frontend findet und wie sie damit kommunizieren kann, generiert Webpack fĂĽr das Micro Frontend einen sogenannten Remote Entry. Dabei handelt es sich um eine minimale JavaScript-Datei, die in die Shell zu laden ist (s. Abb. 2).
Der Remote Entry lässt sich auf verschiedene Weisen laden. Man kann ihn beispielsweise direkt in der Konfiguration des Hosts hinterlegen oder mit einem Script-Tag referenzieren. Letzterer lässt sich auch dynamisch mit JavaScript erzeugen.
Daneben kann eine Anwendung den Eintrag auch erst bei Bedarf mit der Webpack Runtime API laden. Das erlaubt dynamische Szenarien, bei denen sich die Shell zum Beispiel erst zur Laufzeit ĂĽber die einzelnen Micro Frontends informiert.
Teilen von Abhängigkeiten
Es ist sehr wahrscheinlich, dass mehrere Remotes und Hosts von denselben Bibliotheken abhängen. Glücklicherweise erlaubt Module Federation in diesen Fällen das Teilen dieser Abhängigkeiten, sodass die Anwendung sie nicht mehrfach in den Browser laden muss. Sämtliche zu teilenden Abhängigkeiten sind dazu lediglich in der Konfiguration unter shared
einzutragen (s. Abb. 3).
Der Host handelt sogar mit sämtlichen Remotes, die beim Programmstart bekannt sind, die zu nutzenden Versionen dieser Bibliotheken aus. Entsprechend der Regeln der Semantischen Versionierung einigen sich der Host und die Remotes standardmäßig auf die höchste kompatible Version der einzelnen Bibliotheken. Verwendet beispielsweise der Host Angular 10 und ein Remote Angular 10.1, fällt die Wahl demnach auf Version 10.1.
Allerdings existieren auch Konstellationen, in denen Module Federation keine höchste kompatible Version identifizieren kann. Da laut Semantic Versioning unterschiedliche Hauptversionen nicht zueinander kompatibel sein müssen, ergibt sich beispielsweise solch eine Konstellation, wenn der Host Angular 10 und der Remote Angular 9 verwendet. Für diese Fälle erlaubt Module Federation jedoch die Konfiguration zahlreicher Fallback-Strategien, die zum Beispiel zum Laden zweier unterschiedlicher Versionen, zur Ausgabe einer Warnung beziehungsweise eines Fehlers oder zum Zurückgreifen auf eine – zumindest offiziell nicht kompatiblen Version – führen.
Module Federation in Aktion
Zur besseren Veranschaulichung kommt in Abbildung 4 ein einfaches Beispiel zum Einsatz, das ein Micro Frontend in eine Shell lädt, zum Einsatz. Das Micro Frontend, das sich auch ohne Shell ausführen lässt, findet sich hier innerhalb der gestrichelten Linien wieder.
Beide Anwendungen basieren auf Angular. Da das Angular CLI Webpack abstrahiert, kommt die Community-Lösung @angular-architects/module-federation
zum Einsatz. Sie bewegt das Webpack unter der Motorhaube der CLI zur Nutzung von Module Federation, indem es dessen Konfiguration anpasst.
Zusätzlich benötigt man auch eine CLI-Version, die auf Webpack 5 basiert. Für dieses Beispiel kam deswegen die Vorab-Version v11.0.0-next.6 zum Einsatz, welche Webpack 5 als Opt-in unterstützt, zum Einsatz. Über npm erfährt man, wie dieser Opt-in durchzuführen ist.
Bei React- oder Vue-Anwendung, die Webpack direkt verwenden, sind diese Ăśberlegungen nicht notwendig. Hier ist lediglich Webpack 5 mit den nachfolgend beschriebenen Einstellungen zu nutzen.
Konfiguration des Micro Frontend
Die Webpack-Konfiguration des Micro Frontend nutzt das ModuleFederationPlugin
, um ein Angular-Modul zu veröffentlichen.
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
output: {
publicPath: "http://localhost:3000/",
uniqueName: "mfe1"
},
optimization: {
// Only needed to bypass a temporary bug in Angular CLI 11 Beta
runtimeChunk: false
},
plugins: [
new ModuleFederationPlugin({
// For remotes (please adjust)
name: "mfe1",
library: { type: "var", name: "mfe1" },
filename: "remoteEntry.js",
exposes: {
'./Module': './projects/mfe1/src/app/flights/flights.module.ts',
},
shared: ["@angular/core", "@angular/common", "@angular/router"]
})
],
};
Dieses Beispiel beschränkt sich auf jene Webpack-Einstellungen, die sich unmittelbar auf Module Federation auswirken. Den Rest generiert, wie in der Angular-Welt üblich, die CLI.
Die Eigenschaft name
legt jenen Namen fest, über den die Shell das Micro Frontend später referenzieren kann. Außerdem gibt filename
den Namen des zu generierenden Remote Entry an. Die zu teilenden Abhängigkeiten finden sich unter shared
.
Jenseits der fĂĽr das ModuleFederationPlugin
hinterlegten Einstellungen, gilt es auch, die Optionen im Abschnitt output
zu beachten. Der publicPath
legt die URL fest, unter der die Anwendung später zu finden ist. Hierdurch wird bekannt, wo die einzelnen Bundles der Anwendung aber auch deren Assets, zum Beispiel Bilder oder Styles, zu finden sind.
Der uniqueName
definiert einen eindeutigen Namen, der in den generierten Bundles den Host oder den Remote repräsentiert. Standardmäßig verwendet Webpack hierzu den Namen aus der package.json. Um Namenskonflikte beim Einsatz von Monorepos mit mehreren Anwendungen zu vermeiden, empfiehlt es sich, den uniqueName
manuell festzulegen.
Konfiguration der Shell
Die Konfiguration der Shell ähnelt der von Micro Frontends. Für jedes Micro Frontend legt die Konfiguration einen Remote fest. Dazu bildet sie den Shell-internen Namen auf den Namen des Remotes ab. In diesem einfachen Beispiel kommt für beides der Name mfe1
(Micro Frontend 1) zum Einsatz.
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
module.exports = {
output: {
publicPath: "http://localhost:5000/",
uniqueName: "shell"
},
optimization: {
// Only needed to bypass a temporary bug in Angular CLI 11 Beta
runtimeChunk: false
},
plugins: [
new ModuleFederationPlugin({
remotes: {
'mfe1': "mfe1@http://localhost:3000/remoteEntry.js"
},
shared: ["@angular/core", "@angular/common", "@angular/router"]
})
],
};
Zur Vereinfachung enthält das Listing auch die URL des Remote Entry. Wie oben erwähnt kann dieser auch auf andere Wege angegeben werden, zum Beispiel über ein Script-Tag oder dynamisch über die Webpack Runtime API.
Micro Frontend in Shell laden
Der Code zum Laden des Micro Frontend unterscheidet sich nicht von jenem Code, der auch für Lazy Loading zum Einsatz käme. Das gezeigte Beispiel nutzt dazu eine Route-Konfiguration, die beim Aufruf des Pfades flights
das Micro Frontend aktiviert.
export const APP_ROUTES: Routes = [
[…]
{
path: 'flights',
loadChildren: () => import('mfe1/Module').then(m => m.FlightsModule)
},
];
Aus Sicht dieses Quellcodes passiert hier nichts Besonderes. Die eigentliche Arbeit ĂĽbernimmt Webpack im Unterbau. Da es feststellt, dass mfe1
auf eine externe Anwendung verweist, führt es die nötigen Schritte durch, um dessen veröffentlichtes Modul zur Laufzeit zu laden.
Lediglich TypeScript ist ein wenig zu besänftigen. Da es die hier importierte Datei mfe1/Module nicht kennt, liefert es ansonsten einen Kompilierfehler. Dieses Problem lässt sich jedoch mit einer Typdeklaration lösen. Dazu ist lediglich in einer beliebigen auf .dts endenden Datei der folgende Eintrag zu hinterlegen: declare module 'mfe1/Module';
Dynamische Importe fĂĽr geteilte Bibliotheken
Da Module Federation die zu nutzenden Versionen der geteilten Bibliotheken aushandelt, mĂĽssen diese ĂĽber dynamische Importe geladen werden. Sie sind asynchron und erlauben das Ermitteln und Laden der zu nutzenden Bibliotheksversion.
Damit sich Entwicklerinnen und Entwickler nicht ständig mit dieser Einschränkung konfrontiert sehen, empfiehlt es sich, stattdessen die gesamte Anwendung über einen dynamischen Import zu laden. Der Einsprungspunkt der Anwendung – bei Angular ist das für gewöhnlich die Datei main.ts
– besteht somit nur mehr aus einem einzigen dynamischen Import: import('./bootstrap');
. Dieser lädt ein weiteres TypeScript-Modul, das sich um das Bootstrapping der Anwendung kümmert.
Fazit
Die Umsetzung von Micro Frontends war bis jetzt mit zahlreichen Tricks und Workarounds verbunden. Webpack Module Federation liefert dafür endlich eine einfache und geradlinige Lösung. Zur Verbesserung der Performance lassen sich Bibliotheken teilen und umfangreiche Strategien zum Umgang mit zueinander nicht kompatiblen Versionen konfigurieren.
Interessant ist auch, dass das Laden von fremden Anwendungsteilen durch Webpack unter der Motorhaube erfolgt. Im Quellcode des Hosts aber auch der Remotes findet man davon keine Spuren. Das vereinfacht den Einsatz von Module Federation ebenso wie den resultierenden Quellcode, der ohne zusätzliche Micro-Frontend-Frameworks auskommt.
Dieser Ansatz überträgt jedoch auch mehr Verantwortung auf Entwicklerinnen und Entwickler. So müssen sie zum Beispiel sicherstellen, dass die erst zur Laufzeit geladenen Bestandteile, welche beim Kompilieren noch nicht bekannt waren, auch wie gewünscht zusammenspielen.
Außerdem gibt es Fälle, in denen keine der angebotenen Strategien zum Auflösen von Versionskonflikten eine gute Lösung darstellt. Beispielsweise ist es wahrscheinlich, das Komponenten, die mit gänzlich unterschiedlichen Angular-Versionen kompiliert wurden, zur Laufzeit nicht zusammenspielen. Solche Fälle gilt es mit Konventionen zu vermeiden oder zumindest mit Integrations-Tests möglichst frühzeitig zu erkennen.
Manfred Steyer
ist Trainer und Berater mit Fokus auf Angular, Google Developer Expert und Trusted Collaborator im Angular-Team. Er schreibt für O’Reilly, das deutsche Java-Magazin und Heise Developer. Unter https://angulararchitects.io bieten er und sein Team Angular-Schulungen und Beratung im Bereich von Enterprise-Lösungen an.
(mdo)