Warum Micro Frontends nicht alle Probleme lösen
Microservices im Backend sind ein beliebtes Konzept, um in Enterprise-Projekten die unabhängige Zusammenarbeit vieler Teams zu ermöglichen. Micro Frontends scheinen hier die passende Ergänzung zu sein. Sie erlauben eine Integration einzelner Komponenten zur Laufzeit. Aber ist dieser Ansatz im Frontend wirklich die geeignete Methode? Architekturelle Trennung erhöht die Komplexität und erzeugt Aufwand in Kommunikation und Definition stabiler Schnittstellen. Im Frontend laufen zudem alle Komponenten in ein und demselben Browser-Fenster.
Welche Möglichkeiten bietet das beliebte Enterprise-JavaScript-Framework Angular, den Code passend zu strukturieren? Wie ausgereift sind Konzepte wie Module Federation und welche Probleme treten bei der Verwendung von Web-Komponenten mit Angular Elements auf?
Der Wunsch nach Micro Frontends
Viele Projekte werden mit einer Microservice-Architektur konzipiert, um die Vorteile von Cloud-Native-Anwendungen auszunutzen. Hierbei steht die Trennung und Isolation einzelner Komponenten nach Feature-Domänen im Vordergrund.
Gerade in Großprojekten gibt es oft sehr viele Entwicklerteams, die unabhängig voneinander arbeiten wollen. Nur für ein einzelnes Feature verantwortlich zu sein reduziert die Komplexität – auch in Bezug auf die Fachlichkeit. Die Code-Basis wird übersichtlicher, das Feature ist isoliert leichter zu testen.
Micro Frontends versprechen in der Theorie identische Vorteile wie Microservices im Backend. In Enterprise-Unternehmen arbeiten oft eine Vielzahl von Entwicklern global verteilt an einer Anwendung. Micro Frontends erlauben den Teams, unabhängig voneinander entwickeln zu können. Sie sind frei in der Wahl, eigene Versionen verschiedener JavaScript-Frameworks zu verwenden, einen eigenen Technologie-Stack mit Zusatzbibliotheken zu wählen, unabhängig voneinander ein Artefakt zu bauen und dieses jederzeit eigenständig neu zu deployen.
Neue Versionen entstehen ohne Wartezeiten und Absprachen. Zudem besteht häufig der Wunsch, in verschiedenen Code-Repositories entwickeln zu können. Zur Laufzeit soll eine Shell als Rahmenanwendung dann von einer Remote-URL stets das aktuellste Bundle des Micro Frontends laden.
Idealerweise können zudem auch externe Anwendungen integriert und wiederverwendet werden. Auch der Build ist im Gegensatz zu einer größeren monolithischen Single-Page-App deutlich schneller. Schnellere Compilation, schnellere Testdurchläufe, kleinere separate Bundles – so kann eine neue Version in Minuten fertig ausgerollt in der Cloud laufen.
Single Page Application Frameworks
Um zu verstehen, welche Probleme bei der Umsetzung einer Micro-Frontend-Architektur üblicherweise auftreten, lohnt es sich, die Funktionsweise von Single Page Application Frameworks einmal genauer anzuschauen. Die meisten Probleme einer Micro-Frontend-Architektur würden ohne die Verwendung von Single Page Application Frameworks gar nicht erst auftreten.
Heutzutage ist das Schreiben einer Webseite "zu Fuß" mit HTML, CSS und "Vanilla" JavaScript unüblich geworden. Die meisten Anwendungen sind schlicht zu komplex.
Single Page Application (SPA) Frameworks wie Angular, React oder Vue erleichtern den Entwicklungsaufwand erheblich. Durch die Generierung eines Quellcode-Gerüsts inklusive vorkonfigurierter Unit-Tests, Build-Mechanismen und eines lokalen Webservers bieten SPAs jedem einen schnellen produktiven Einstieg in die Web-Entwicklung. Sie helfen zudem bei der Strukturierung der Architektur mit wiederverwendbaren Komponenten und der Orchestrierung in Modulen. Module erlauben Techniken zum Lazy-Loading (Nachladen zur Laufzeit) einzelner JS Bundles.
SPA Frameworks sind vor allem deshalb beliebt, da sie helfen, eine performante Webseite mit Seitenwechseln ohne Neuladen zu erreichen.
Einer der zentralen Aspekte einer Single-Page-Anwendung ist der sogenannte "soft reload" beim Navigieren zwischen verschiedenen Seiten in der Anwendung. Trotz Änderung des URL-Pfades gibt es kein Flackern mit einer zwischenzeitlich leeren HTML-Seite. Der Wechsel der URL ist performant, neue Inhalte erscheinen nahtlos. Dahinter steckt ein Trick: Klassische Webseiten ändern die globale singuläre Browser Property window.location.href direkt. Dies erzeugt einen "hard reload", der Browser lädt den Quellcode neu und baut die Seite ausgehend von einem leeren weißen Bildschirm von vorne neu auf.
Single Page Application Frameworks wie Angular oder React hingegen benutzen für den Wechsel zwischen verschiedenen Unterseiten einen indirekten Mechanismus. Sie agieren wie eine Art Zeitmaschine. Statt der window.location ändern sie die window.history. So wird dem Browser mitgeteilt, man sei bereits auf der URL, auf die man navigieren möchte. Die derzeitige URL wird in der Historie als die zuletzt besuchte gespeichert. Hierbei wird die native Browser-Schnittstelle window.history.pushState() aufgerufen. So ist es SPA Frameworks möglich, dass Browser-Steuerelemente wie der Backbutton weiterhin funktionieren. Ist im Browser eine einzige Instanz des SPA Frameworks und somit ein einziger Router.forRoot vorhanden, so kann er diese Manipulationen beobachten und im eigenen Zustand speichern.
SPA Frameworks basieren auf der Annahme, dass auf einer Webseite nur eine einzige Instanz dieses Frameworks im Browser lebt. Greifen diese doch auf globale Objekte im Browser-Fenster zu. Genau dieser Ansatz steht im Widerspruch zu einer Micro-Frontend-Architektur, bei der mehrere Anwendungen gleichzeitig im gleichen Browser laufen.
Kompositionsmöglichkeiten für eine Micro-Architektur
Es gibt verschiedene technologische Ansätze, eine Micro-Frontend-Architektur für eine vorhandene Single-Page-Anwendung zu ermöglichen. Je näher wir am Konzept einer zentralen singulären Applikation bleiben, desto stabiler wird unsere Lösung.
Angular hat den Anwendungszweck, eine nahtlose Single-Page-Applikation zu bauen.
Die Anforderungen, der Grad der gewünschten Isolation und die Wiederverwendbarkeit bedingen die technische Lösung. Können die Micro Frontends im gleichen Repository entwickelt werden? Sind Absprachen möglich, so dass alle Komponenten und die Rahmenanwendung in der gleichen Angular-Version gebaut werden. Sind Versions-Updates gleichzeitig koordinierbar? Ist es möglich, Updates der Module und der Gesamtanwendung in einem gemeinsamen Release durchzuführen? Sollen die einzelnen Micro Frontends in verschiedenen Anwendungen wiederverwendet werden?
Angular hat den Anwendungszweck, eine nahtlose Single-Page-Applikation zu bauen. Je weiter wir einzelne Bestandteile isolieren, desto experimenteller wird die Architektur. Vollen Angular-Support hat eine Trennung der Anwendung in Angular Libraries. Hier kann der Compiler am Ende aus allen einzelnen Libraries und der Shell-Anwendung wieder eine nahtlose Single-Page-Applikation bauen. Inkompatibilitäten werden direkt beim Bauen erkannt.
Die Verwendung von Module Federation in einem Mono-Repository mit einer zentralen geteilten Angular-Version ist ebenfalls eine prinzipiell unterstützte Möglichkeit. Architekturen mit Poly-Repositories, verschiedenen Versionen und Instanzen von Angular sowie die Isolation in Webkomponenten sind experimenteller Natur und kommen mit verborgenen Risiken daher. Schauen wir uns die einzelnen Methoden und deren Möglichkeiten und Grenzen genauer an.
Micro-Architektur mit Angular-Library-Modulen
Mit Frameworks wie Angular strukturiert man Anwendungen üblicherweise bereits in einzelne Module. Diese Module bilden einzelne Javascript- Bundles, die zum Beispiel beim Seitenwechsel nachgeladen werden können. Ein Modul kann mehrere Komponenten, Services und auch Untermodule beinhalten. Mit einem einfachen Befehl in der Kommandozeile lassen sich mit Hilfe vorhandener Skripte, sogenannter "Schematics", Library-Projekte generieren, die statt einer (root)-App ein Library-Modul enthalten. Diese Library-Module können im gleichen Code-Repository bleiben. Ein solches Mono-Repository birgt jedoch das Risiko einer Schichtenverletzung, des architekturell verbotenen Zugriffs auf Quellcode anderer Libraries. Mit Hilfe von Mono-Repository-Tooling wie Linter- und Dependency-Graphen von NX lässt sich dieses Risiko aber frühzeitig abfangen [1].
Angular-Library-Module kompilieren zu eigenen Artefakten, die wiederum per NPM (Node Package Manager) als Abhängigkeit in einer anderen Applikation installiert werden können. Nach der Installation in der Root-App erhält man nach dem Bauen wieder eine vollständige Single-Page-Applikation. Dies erlaubt – als Alternative zum Monorepo-Ansatz – die Entwicklung in verschiedenen Code-Repositories. Teams, die für ein Library-Modul zuständig sind, müssen so nicht die Gesamtanwendung im Blick haben. Die Trennung in Library-Module verursacht jedoch auch Nachteile. Library-Module können nicht eigenständig gebaut und deployt werden. Der Build und die Paketierung in eine versionierte NPM Dependency ist unabhängig. Um jedoch eine vollständig lauffähige Anwendung zu erhalten, muss eine Library in einer App installiert werden. Für die Verwendung einer neuen Version der Library muss die Host-App auch neu gebaut werden. Dies erfordert Absprachen zwischen den Library-Teams und dem Shell-App-Team. Zudem braucht es ständig neue Versionen der Gesamtanwendung, wenn neue Versionen der einzelnen Libraries gebraucht werden. Ein Release und verbundene Tests werden so schnell aufwändig.
Außerdem bricht der Build der Gesamtanwendung, wenn in den einzelnen Libraries inkompatible Versionen von Angular-Paketen oder anderen Abhängigkeiten benötigt werden. Die Shell-Anwendung muss weiterhin potentiell alle Pakete bereitstellen, die von Library-Modulen verwendet werden wollen.
Der Update-Prozess einer Anwendung, basierend auf einem versionierten Framework wie Angular, wird schnell komplex. Für ein Versions-Update von Angular müssen hier nicht nur die einzelnen Code-Module mit der gleichen Angular-Version kompiliert werden, sondern ebenfalls die zusätzlichen Abhängigkeiten kompatibel gehalten werden. Beispiele hierfür sind RxJS, UI-Bibliotheken wie Angular Material oder State-Management-Bibliotheken wie NgRx. Ein Update verursacht schnell hohe Aufwände, zudem müssen immer erst die inneren Abhängigkeiten, wie alle Library-Module, mit der neuen gewünschten Version gebaut sein, bevor die Rahmenanwendung die neue Angular-Version verwenden kann.
Von Library-Modulen zu Module Federation
Module Federation bietet seit Angular Version 11 und Webpack Version 5 die Option, einzelne Library-Module nicht schon beim Bauen installiert haben zu müssen, sondern von einer Remote-URL dynamisch zur Laufzeit nachzuladen. Die Technologie verspricht somit die Realisierung "echter" Micro Frontends, weil nun die Integration einzelner Komponenten in die Shell-Anwendung zur Laufzeit im Browser stattfinden kann.
Module Federation erlaubt die Orchestrierung des Builds von JavaScript-Bundles in einer Weise, dass einzelne Teile des JavaScript-Quellcodes modular geteilt werden können. So kann vermieden werden, dass doppelte JavaScript-Inhalte mehrfach in verschiedenen Bundles im Browser geladen werden. Zudem baut man nicht mehr zwingend alles in einem Bundle, sondern kann den Build in kleinere Artefakte auftrennen, was wesentliche Erleichterungen beim benötigten Arbeitsspeicher des Compilier-Vorgangs mit sich bringt. So können riesige Anwendungen effizienter gebaut werden. Gerade die einzelnen Angular-Pakete sind selbst komprimiert noch viele Kilobyte groß, was zu einer längeren Ladezeit einer Webseite führen kann. Vor allem, wenn größere Inhalte mehrfach geladen werden müssen.
Die Webpack-Konfiguration für Module Federation erlaubt die Definition zu teilender Versionen beliebiger NPM-Abhängigkeiten. Auch die einzelnen Angular-Pakete können geteilt werden. Die Möglichkeit, "auto" als benötigte Version auszuwählen, soll die flexible Option bieten, verschiedene kompatible Minor- oder Patch-Level-Versionen zu verwenden.
Listing 1: Theoretisch mögliche webpack.config.ts
shared: {
"@angular/core": {
requiredVersion: "auto",
},
"@angular/common": {
requiredVersion: "auto",
},
"@angular/router": {
requiredVersion: "auto",
},
Für die Orchestrierung einer stabilen modularisierten Single-Page-Anwendung ist jedoch einiges an zusätzlichen Aufwänden notwendig. Die Webpack-Konfiguration muss in der Shell-Anwendung sowie in allen Micro Frontends passend gewählt werden. Ebenso ist die Schnittstelle konsistent zu konfigurieren, die der Shell-Anwendung sagt, woher welches Modul zur Laufzeit geladen werden muss. Üblicherweise nutzt man die Möglichkeit des Angular-Routers, um hier das Laden des remote-Moduls zu initiieren. Der Vergleich mit der Konfiguration zum Lazy Load eines klassischen Library-Moduls zeigt, dass wenig Code zu ändern ist, um ein Modul remote auszulagern. Die relative Pfadangabe des Imports wird durch die remote URL ersetzt.
Listing 2: Build-time lazy load eines Library-Moduls
{
path: 'dashboard',
component: DashboardComponent,
loadChildren: () =>
import('./shop/shop.module').then((m) => m.EntryModule)
},
Listing 3: Run-time lazy load eines Federated-Moduls
{
path: 'dashboard',
component: DashboardComponent,
loadChildren: () =>
loadRemoteModule({
remoteEntry: 'http://my.shop.com/shop.js',
remoteName: 'shop',
exposedModule: './Module'
}).then((m) => m.EntryModule)
},
Ein Risiko der Komposition zur Laufzeit besteht darin, dass der Compiler nicht überprüfen kann, ob das benötigte EntryModule vorhanden ist und den passenden Namen hat. Daher ist besondere Sorgfalt beim Programmieren der App Shell notwendig, um Exceptions aufzufangen, wenn unter der Remote-Adresse das gewünschte JavaScript-Bundle nicht zu finden ist. Ebenso kann das EntryModule den falschen Namen haben.
Die Möglichkeit, verschiedene Angular-Versionen mittels einer auto-Selektion in der Webpack-Konfiguration auszuwählen, scheitert in der Praxis bei einer mittelgroßen Anwendung an der Abwärts-Kompatibilität verwendeter Angular-Features. Angular ist nicht dafür vorgesehen, in mehreren Instanzen gleichzeitig im Browser zu laufen. Bereits kleinere Versionsunterschiede bergen das Risiko eines Fehlers zur Laufzeit. So könnte beispielsweise Angular Forms nicht mehr korrekt funktionieren und ein Eingabeformular wird nicht mehr korrekt validiert. Garantierte Stabilität mit Module Federation und Angular ist nur dann gegeben, wenn Angular als Singleton im Browser vorhanden ist. Idealerweise kompiliert man die Anwendung und alle Remote-Module mit der auf patch-Level identischen Version.
Listing 4: webpack.config.ts
shared: {
"@angular/core": {
singleton: true,
strictVersion: true,
},
"@angular/common": {
singleton: true,
strictVersion: true,
},
"@angular/router": {
singleton: true,
strictVersion: true,
},
Diese Versionsgleichheit kann durch Absprachen zwischen Entwicklerteams entstehen. Idealerweise befinden sich jedoch alle Module und die Shell-Anwendung im gleichen Monorepo. So gibt eine zentrale package.json die konkrete Version vor.
Wieso hat die Webpack-Konfiguration dann überhaupt die Option einer "auto"-Versionsangabe? Erneut liegt die Ursache des Problems nicht an der Technologie selbst, sondern an der Verwendung mit einer Single-Page-Applikation. Das Webpack-Feature Module Federation funktioniert zuverlässig beim Teilen kleinerer zustandsloser JavaScript-Bundles. Ein Single Page Application Framework mit einem komplexen internen Zustand bringt die Technologie an ihre Grenzen. Es sei denn, es gibt nur ein Singleton und damit nur einen einzigen globalen Zustand.
Für eine Shell-Anwendung und mehrere Module, die dynamisch einzeln neu ausgerollt werden können, lässt sich eine einheitliche Version mit Absprachen oder einem Monorepo realisieren. Sobald aber einzelne Module in anderen Shell-Anwendungen wiederverwendet werden sollen, wird die Orchestrierung problematisch. Ebenso, falls die einzelnen Module doch in eigenen Repositories entwickelt werden sollen und die Koordination der einheitlichen Version bei Updates nicht mehr gelingt. Hierzu brauchen wir eine andere technische Lösung.
Web-Komponenten und Angular Elements
Angular hat ein Unterpaket "Angular Elements", welches man mit den üblichen Paketmanagement-Tools wie NPM installieren kann.
Listing 5: package.json
"@angular/elements": "^16.2.12"
Angular Elements erlaubt leichtgewichtig, mit der Änderung weniger Zeilen Codes, bestehende Angular-Apps oder -Komponenten in Custom Elements zu verwandeln. Custom Elements ist eine Webkomponenten-API und erlaubt, HTML-Elemente mit einem selbstgewählten Tag-Namen zu erzeugen. Die Isolation einer Angular-Anwendung in ein Custom-Element erlaubt uns nun durch eigene Namespaces die Kombination mehrerer Angular Instanzen im gleichen Browserfenster. Nicht nur mehrere Instanzen, sondern auch mehrere verschiedene Versionen parallel werden nun ermöglicht.
Optional kann auch mit Hilfe des Shadow DOMs ein eigener Namespace für das Styling mit CSS geschaffen werden. So kann das Design einer Web-Komponente die äußere Anwendung nicht beeinflussen und umgekehrt.
Jede vorhandene Angular-Anwendung kann in ein Custom-Element überführt werden. Neben der Installation vom Paket Angular Elements sind hierzu wenige Zeilen im Code zu ändern. Nicht nur die äußere AppComponent einer Angular-Anwendung, sondern jegliche inneren Komponenten können in eigenständige Custom Elements überführt werden. Zudem können gleichzeitig aus ein und derselben Angular-Anwendung mehrere Custom Elements aus verschiedenen Komponenten bereitgestellt werden.
Die Definition der Custom Elements wird mit einer Hilfsfunktion createCustomElement aus dem Angular-Elements-Paket im AppModul ermöglicht. Der Komponente werden alle benötigten Abhängigkeiten wie Services oder geschachtelte Komponenten injiziert. Ebenso alle notwendige Logik zum Bootstrappen ist enthalten.
Listing 6: app.module.ts
import { createCustomElement } from '@angular/elements';
export class AppModule {
constructor(private injector: Injector) {}
}
ngDoBootstrap(): void {
const myCustomElement = createCustomElement(WidgetComponent,
{injector: this.injector});
customElements.define('my-widget', myCustomElement);
}
}
Nach dem Kompilieren einer Anwendung wird der benötigte JavaScript-Code, der das Custom Element zum Leben erweckt, in einem dist/-Ordner bereitgestellt. All die einzelnen JavaScript-Dateien lassen sich zu einem einzigen Bundle zusammenfügen. So ist zur Verwendung eines Custom Element HTML Tags nur das Laden einer einzigen remote JavaScript-Datei notwendig.
Listing 7: Minimalbeispiel für die Verwendung eines Custom Elements auf einer beliebigen HTML-Seite
<head>
<script src="widget.js"></script>
</head>
<body>
<my-widget></my-widget>
</body>
Die Möglichkeit der Kapselung einzelner Angular-Komponenten in Web-Komponenten erlaubt beispielsweise die Wiederverwendung einzelner Widgets in anderen Angular-Anwendungen. Die Einbettung eines solchen Widgets ist ebenfalls in einem Content-Management-System, einer herkömmlichen HTML-Seite ohne Framework, oder aber in einer Anwendung mit React oder Vue möglich. Custom Elements erlauben zudem das Übermitteln von Input-Parametern. Diese können verwendet werden, um von außen Konfiguration oder Initial-Zustand an das Micro Frontend zu übergeben. Ein weiterer Vorteil von Custom Elements, im Vergleich zu iFrames, sind die leichtgewichtigeren Möglichkeiten, mit dem Micro Frontend zu kommunizieren. Native Browser-Technologien erlauben das Senden von Custom Events oder die Verwendung der Broadcast Channel API.
Web-Komponenten mit Router
Web-Komponenten erlauben uns die Komposition mehrerer Micro Frontends verschiedener SPA Frameworks im gleichen Browserfenster. Ebenso erlaubt die Isolation in einem Custom Element die Verwendung mehrerer Versionen des gleichen Frameworks. Mit Angular Elements lassen sich Angular-Anwendungen leicht in Micro Frontends verwandeln. Im Browser laufen so mehrere Angular-Instanzen parallel. Dies ist eine stabile Möglichkeit, insofern es sich bei den Micro Frontends um "Widget"-Ansichten mit einer einzigen Seite handelt.
Sobald eine Web-Komponente jedoch Routing verwendet, also unter Änderung der URL die Ansicht ändert, treten hier schnell Probleme auf. Kombiniert man nun eine Shell und mehrere Micro Frontends mit jeweils einem eigenen Router in einem Browser, kommt es zu Problemen und Exceptions zur Laufzeit. Möglicherweise passen der angezeigte Inhalt und die URL nicht zusammen. Oder der Browser verfängt sich in einer Endlosschleife beim Wechsel der URL. Eventuell verliert der Backbutton seine Funktionalität und man bleibt auf der aktuellen URL gefangen und kann nicht mehr zurück navigieren. Wie ist das möglich?
SPA Frameworks wie Angular verwenden den Trick der Browser History Manipulation, um die URL und den angezeigten Inhalt auszutauschen. Dies ist ein stabiles, ausgereiftes Vorgehen, wenn im Browser nur eine einzige Instanz eines einzigen SPA Frameworks aktiv ist. Kombiniert man nun mehrere Versionen (und damit mehrere Instanzen) von Angular in einem Browser-Fenster, so hat man mehrere Instanzen eines Routers. Jeder Router hat die Möglichkeit, die URL indirekt zu manipulieren. Die Änderung der Browser-Historie geschieht jedoch ohne ein natives – per JavaScript außerhalb der Angular-Instanz detektierbares – Event. So haben die verschiedenen Router-Instanzen technisch keine Möglichkeit, eine Änderung der URL mitzubekommen. Sie können nicht auf ein Event horchen, da es kein globales Browser-Event gibt. Intern innerhalb der gleichen Anwendung wirft der Angular-Router nach jeder URL-Manipulation ein NavigationEnd-Event. Dieses wird aber nicht aus einer Web-Komponente heraus- oder hineingereicht. So kommt es schnell zu Divergenzen in der Zustandsinformation der einzelnen Router-Instanzen.
Aufgrund dieser Schwierigkeiten, Micro Frontends aus SPAs zu verwalten, gibt es etliche Frameworks, die die Orchestrierung übernehmen. Beispiele hierfür sind single-spa oder Luigi. Der Preis für die Verwendung sind jedoch Einschränkungen im Layout, aufwändige zentrale Konfigurationen und oft die Konvention, ein Micro Frontend nur auf seiner Startseite aufrufen zu können. Eine naheliegende Lösung ist beispielsweise die Konvention, ein Micro Frontend beim Aufruf eines konfigurierten Top-Level-URL-Pfades zu laden. Dieser Pfad kann in Angular als APP_BASE_HREF gesetzt werden. Das Micro Frontend betrachtet somit die URL erst nach dem jeweiligen Basispfad.
Ein Navigieren aus der Shell auf /mfe1 könnte somit das Micro Frontend 1 laden. Eine Navigation zu /mfe2 lädt das Micro Frontend 2. Mit einer solchen Konvention und der Einschränkung, dass nur ein Micro Frontend gleichzeitig aktiv ist, kann ein Micro Frontend von seiner Startseite aus zu weiteren Unterseiten navigieren. Beispielsweise von der Startseite /mfe1 zu /mfe1/page1 oder /mfe1/page2.
Dieser Ansatz stößt leider an seine Grenzen, wenn es nun notwendig ist, "Deeplinking" zu ermöglichen. Beispiel für Deeplinking ist die Navigation von außen, von der Shell-Anwendung direkt auf eine spezielle Unterseite des Micro Frontends, egal ob es bereits geladen ist oder nicht. Möglicherweise hat man ein Notification Widget im Header, welches zu /mfe1/page2 navigieren möchte.
Was passiert nun, wenn die Navigation über einen routerLink in der Shell-Anwendung ausgelöst wird? Ist die URL zuvor /mfe1/page1, so ist das Micro Frontend 1 bereits angezeigt. Die dargestellte Komponente entspricht der Router-Konfiguration für den Unterpfad /page1. Ein routerLink außerhalb des Micro Frontends ändert indirekt die URL, der Browser ist nun auf /mfe1/page2. Da diese indirekte Manipulation aber kein nach außen detektierbares Event verursacht, glaubt das Micro Frontend 1, wir befinden uns noch immer auf page1. Der interne Zustand des Micro Frontends wäre router.url = /mfe1/page1. Die Shell, welche den URL-Wechsel verursacht hat, kennt den wahren Zustand router.url = /mfe1/page2. Diese Zustandsdivergenz gilt es zu reparieren.
Wie erreicht man nun die Anforderung des Deeplinkings zwischen Micro Frontends mit Router? Es braucht eine Sonderlösung. Eine leichtgewichtige Idee ist es, das Kernproblem zu klären, dass die Router unabhängig voneinander agieren und die URL stumm, ohne ein detektierbares Event, manipulieren. Innerhalb der Shell-Anwendung oder eines Micro Frontends lassen sich diese Änderungen mittels des NavigationEnd-Router-Events abfangen. Wenn man nun bei jedem NavigationEnd-Event ein natives Browser-Event erzeugt, ein Custom-Event, so kann man dies nach außen kommunizieren. Mittels einer Orchestrierung über ein DOM-Element, welches als EventBus, als Erzeuger und Beobachter des Custom-Events fungiert, lässt sich eine stabile Implementierung erreichen. Diese ist jedoch mit einigem Aufwand verbunden und birgt das Risiko, dass ein einzelnes Micro Frontend, welches nicht dieser Konvention folgt, nach wie vor im Stillen die URL ändern kann, ohne ein Event nach außen Preis zu geben.
Es ist aber möglich, eine stabile Event-Abfolge zu erreichen, die es erlaubt, Deeplinking aus Micro Frontends und aus der Shell heraus zu stabilisieren. Das Endergebnis erlaubt die Verwendung von BrowserBack und BrowserForward wie in einer stabilen Single-Page-Applikation. Sowohl der Code der Shell-Anwendung als auch der Micro Frontends sollte gut getestet sein. Idealerweise lagert man den Quellcode für das Senden und Empfangen der Events in eine externe wiederverwendbare Library aus. Zudem können Ende-zu-Ende-Tests, welche das Deeplinking zwischen den Anwendungen überprüfen, sicherstellen, dass eine solche Sonderlösung stabil verwendet werden kann.
Listing 8: CustomEvent wird nach NavigationEnd ausgelöst, um global im Browser URL-Manipulationen detektieren zu können
this.router.events.pipe(
filter((e: Event | RouterEvent): e is RouterEvent => e instanceof RouterEvent)
).pipe(
filter((e: RouterEvent | NavigationEnd): e is NavigationEnd => e instanceof NavigationEnd)).subscribe((navEnd: NavigationEnd) => {
this.notifyOtherRouters(navEnd.urlAfterRedirects);
});
notifyOtherRouters(url: string) {
const eventBusDOMElement = document.getElementsByTagName('router-event-bus')[0];
const event = new CustomEvent('url-changed', {
detail: {
url: this.basePath + url,
},
});
eventBusDOMElement.dispatchEvent(event);
}
Bei der Implementierung über CustomEvents benötigt es ein DOM-Element, welches als EventBus fungiert. Alternativ bietet die Broadcast Channel API eine native "Vanilla"-JavaScript-Schnittstelle im Browser, um eine Kommunikation zwischen Shell und Micro Frontends zu ermöglichen.
Nicht immer ist ein Micro Frontend die geeignete Wahl
Eine "echte" stabile Micro-Frontend-Architektur mit der Komposition verschiedener Komponenten beliebiger Framework-Versionen zur Laufzeit im Browser ist nur mit der Kapselung in Web-Komponenten erreichbar. Die Aufwände zur Stabilisierung einer solchen Architektur im Falle eines notwendigen Deeplinkings sind erheblich. Ist dies keine Anforderung, so bieten Web-Komponenten aber durchaus eine stabile Möglichkeit.
Der Wunsch nach einer Micro-Frontend-Architektur sollte stets gut durchdacht sein. Aufwand und Nutzen des langfristig gewünschten Isolationsgrades müssen abgeglichen werden. Ebenso ist ein hybrider Ansatz denkbar.
Micro Frontends müssen zudem kompatibel zu ihrer Shell, ihrer Rahmen-Applikation, sein. Wenn Schnittstellen für Kommunikation, Kontexttransfer oder Deeplinking notwendig werden, gibt es leider kein universelles Konzept. Die API muss wohlüberlegt sein, idealerweise zudem kompatibel mit allen denkbaren zu unterstützenden Frameworks. Dies kann durch Verwendung von "Vanilla"-JavaScript/-TypeScript garantiert werden. Native Browser-Schnittstellen wie Custom Events oder die Broadcast Channel API können hier nützlich sein.