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.

In Pocket speichern vorlesen Druckansicht 6 Kommentare lesen
Die Micro-Frontend-Revolution: Webpack 5 Module Federation

(Bild: QuinceMedia, gemeinfrei)

Lesezeit: 11 Min.
Von
  • Manfred Steyer
Inhaltsverzeichnis

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.

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.

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

Webpack-Konfiguration für Host und Remote (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 des Micro Frontends ist in die Shell zu laden (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.

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

Module Federation erlaubt das Teilen von Bibliotheken zwischen einzelnen Anwendungen (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.

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.

Dieses einfache Beispiel lädt ein Micro Frontend in eine Shell (Abb. 4).

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.

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.

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.

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';

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.

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)