Während sich im Backend eine fachliche Trennung von Monolithen in unterschiedliche Module (Modulithen) oder gar Microservices etabliert hat, ist die Frontend-Welt noch immer stark monolithisch geprägt. In diesem Artikel will ich dir zwei Ansätze zeigen, um mehr Flexibilität in deine Frontend-Architektur zu bringen: Module Federation und Web Components.

Monolithen in der Frontend-Welt

In der Angular- und React-Welt ist es üblich, Frontends als Single-Page-Applications aufzubauen. Dabei wird ein inhaltlich leeres HTML-Dokument mithilfe von JavaScript, das im Browser läuft, mit Inhalt befüllt (Client-seitiges Rendering). Außerdem werden alle Komponenten der Webseite in ein großes Bundle transpiliert. Wir haben es also mit einem Monolithen zu tun.

Das HTML-Dokument referenziert einen Einstiegspunkt, von dem aus das Framework und die benötigten Komponenten importiert werden. Wie bei monolithischen Backends hat dies einerseits den Vorteil einer Single Source-of-Truth, und somit auch den Vorteil eines zentralen Release-Prozesses. Andererseits gibt es den Nachteil, dass die ganze Anwendung an einer zentralen Stelle definiert wird. Bei größeren Frontends wären die verschiedenen Teams also stark aneinander stark gekoppelt.

Zwar ist es möglich, Komponenten in Bibliotheken auszulagern, die von der Anwendung importiert werden. Auch können die einzelnen Komponenten durch ein Refactoring besser voneinander getrennt und entkoppelt werden; der Build wird dadurch dennoch nicht dezentralisiert. Dies ist für kleine Frontends in Ordnung. Bei größeren, von mehreren Teams entwickelten Frontends führt dies – wie im Backend – jedoch schnell zu Konflikten.

Der grobe Aufbau eines monolithen Frontends. Ein HTML-Dokumentiert importiert einen Entry-Point, welcher die Anwendung mit Inhalt befüllt. Dafür werden vom Entry-Point aus weitere JS-Module aufgerufen.
Der grobe Aufbau eines monolithischen Frontends: Ein HTML-Dokument importiert einen Entry-Point, der die Anwendung mit Inhalt befüllt. Dafür werden vom Entry-Point aus weitere JavaScript-Module aufgerufen.

Microfrontends? Was genau darf man sich darunter vorstellen?

In der Backend-Welt gibt es das Konzept der Microservices. Das heißt, Funktionalitäten werden in kleine Backend-Services aufgeteilt, die sich auf eine bestimmte Funktionalität beschränken. Diese Backend-Services können untereinander zum Beispiel mittels REST oder gRPC kommunizieren, können aber unabhängig voneinander entwickelt und deployt werden.

Der Microservices-Ansatz ermöglicht dem Team eine höhere Autonomität. Wird der Ansatz richtig umgesetzt, können Teams selbständig arbeiten und deployen, ohne sich gegenseitig in die Quere zu kommen. Es stellt sich die Frage, wie dies in der Frontend-Welt umgesetzt werden kann.

Eine Idee dafür nennt sich Microfrontends. Allerdings kann die Realisierung dieser Idee sehr unterschiedlich aussehen. Der Browser als feste Ablaufumgebung setzt hier klare Grenzen, die sich an Web-Standards wie JavaScript, HTTP oder Web-APIs orientieren müssen.

Wir schauen uns im Folgenden zwei Optionen näher an:

  • Module Federation
  • Web Components

Basis für die Umsetzung ist die Modulauflösung in JavaScript.

Exkurs: Modulauflösung mit JavaScript

Historisch gibt es in JavaScript verschiedene Arten von Modulsystemen. Mit ES6 wurde ein neues Modulsystem eingeführt, das in den meisten modernen Browsern unterstützt wird: ECMAScript Modules (ESM). Daher ist ESM seit einigen Jahren das bevorzugte Modulsystem im JavaScript-Ökosystem.

Mit ESM können Module weitere Module über zwei Arten einbinden; über einen top-level Import, sowie über den Aufruf der import(...) Funktion:

import { something } from '../some/module.js';
//      ^- sofort verfügbar
const { somethingElse } = await import('../another/module.js');
//      ^- erst nachdem await fertig ist verfügbar

Der Aufruf ist eine Promise. Es wird gewartet bis das Modul sowie alle Top-Level-Abhängigkeiten des Moduls geladen wurden. Top-Level-Imports werden sofort mit dem Einbinden des eigenen Moduls mitgeladen und sind dadurch ohne Promise verwendbar. Imports über import(...) werden erst dann ausgeführt, wenn die Funktion tatsächlich ausgeführt wird. Da es sein kann, dass das importierte Modul noch geladen werden muss, wird hier eine Promise zurückgegeben.

Ansatz 1: Framework-spezifische UI-Komponenten

Wie oben erwähnt, habe ich für diesen Blogartikel zwei Strategien genauer unter die Lupe genommen. Erstens: das Einbinden Framework-spezifischer UI-Komponenten in eine Host-Anwendung. Zweitens: das Einbinden Framework-unabhängiger Web Components in eine Host-Anwendung.

Für den Framework-spezifischen Ansatz habe ich mir zwei Frameworks angeschaut: Angular und React.

Da beim Framework-spezifischen Ansatz Host und die eingebundenen Remote-Frontends mit demselben Framework implementiert werden müssen, wurden zwei separate Anwendungen, eine mit React und eine mit Angular, implementiert. Die Anforderung an die Applikationen war es, eine Komponente, bzw. ein Modul von einem anderen Server zu laden und erfolgreich in die Applikation einzubinden, und Daten in der Anwendung zwischen den lokal und remote zur Verfügung stehenden Komponenten zu übertragen.

Screenshot der React-Implementation. In Host-Anwendung (React) wird eine Komponente (React) zwei mal eingebunden. Diese Komponente wurde von einem anderen Server mithilfe von Module Federation geladen.
Screenshot der React-Implementation. In Host-Anwendung (React) wird eine Komponente (React) zwei mal eingebunden. Diese Komponente wurde von einem anderen Server mithilfe von Module Federation geladen.

Module Federation

Für den Framework-spezifischen Ansatz wurde Module Federation mithilfe von @angular-architects/module-federation (Angular) und @originjs/vite-plugin-federation (Vite/React) realisiert.

Da alle modernen Browser ESM direkt unterstützen, ist Module Federation auch ohne diese Plugins möglich. Dafür werden die Remote Frontends im Library-Modus gebaut und als ES-Modul direkt konsumiert. Die Plugins automatisieren diesen Schritt für uns und bauen zudem Polyfills für ältere Browser ein.

Module Federation verwendet standardmäßig die import() Funktion, um das Remote-Kompilat zu laden. Denn import() kann neben relativen Pfaden (../module.js) auch URLs (http://localhost:3001/module.js) aufrufen. Diese URLs können auch auf Server mit einer anderen Root URL zeigen, solange der Remote Server CORS-Header setzt.

Wurde das Frontend nicht im Library-Modus gebaut, können die Remote Module über import() nicht direkt aufgerufen werden. Das liegt daran, dass Bundler wie Webpack und Rollup (Vite) die JavaScript-Module umverpacken und für Browser optimieren. Statt import("../util/myModule.js") findet sich bei Webpack im Bundle ein minifizierter Aufruf von __webpack_require__(module_id). Die ID ist intern und kann daher nicht „öffentlich“ konsumiert werden.

Die remoteEntry.js ist hier die Lösung. Sie stellt eine Art Nachschlagewerk bereit. Externe Module importieren die remoteEntry.js und rufen das interne Modul über den Namen auf.

Hier ein Ausschnitt der nicht minifizierten remoteEntry.js aus dem Angular-Projekt:

var moduleMap = {
  './RemoteRoute': () => {
    return __webpack_require__
      .e(44)
      .then(() => () => __webpack_require__(2044));
  },
};
/// ... der Rest der remoteEntry.js
Die `remoteEntry.js` zeigt einem Konsumenten, wo es im remote Bundle die "öffentlichen" JS-Module finden kann.
Die remoteEntry.js zeigt einem Konsumenten, wo er im Remote Bundle die „öffentlichen“ JS-Module finden kann.

React

Das React-Frontend verwendet als Build-Tool Vite, ein relativ neues Entwicklungswerkzeug, das viel Wert auf Entwicklererfahrung setzt und zum Beispiel mittels ESM und Überspringen eines Bundle-Steps während der Entwicklung erheblich schnellere Bauzeiten ermöglicht als es beispielweise bei Webpack der Fall ist.

Für Vite gibt es das vite-plugin-federation-Modul, welches als Plugin in die Vite-Konfiguration eingebunden werden kann. Über das Plugin lassen sich von diesem Kompilat bereitgestellte, sowie von externen Kompilaten konsumierte JavaScript-Module deklarieren:

// vite.config.ts
import federation from '@originjs/vite-plugin-federation';
return defineConfig({
  ...,
  plugins: [
    ...,
    federation({
      name: 'my-component-name',
      // Der Remote Entrypoint. Ein externes Modul kann diese Datei aufrufen
      // und über einen Lookup eine der unter exposes definierten Komponenten
      // laden.
      filename: 'remoteEntry.js',
      // Exposes, wenn dieses Modul Komponenten bereitstellt
      exposes: {
        './SomeComponent': './src/components/SomeComponent',
        './AnotherComponent': './src/components/AnotherComponent',
      },
      // Remotes, wenn dieses Modul externe Komponenten konsumiert,
      // mit einem Link zum Remote-Entrypoint.
      remotes: {
        someRemoteEntry: 'http://localhost:5001/assets/remoteEntry.js',
      },
      // Hier definierte Dependencies werden im Browser effektiv
      // nur einmal geladen. Es muss also nicht pro Remote Frontend
      // eine eigene React-Version geladen werden.
      shared: ['react', 'react-dom'],
    }),
  ],
});

Bei einem Build sorgt das Plugin dann dafür, dass die remoteEntry.js-Datei erzeugt wird. Die Komponenten können dann über den im remotes-Objekt definierten Namen eingebunden werden:

const RemoteComponent = React.lazy(() => import('someRemoteEntry/SomeRemoteComponent')); 
return (
  <Suspense fallback={<Loading />}>
    <RemoteComponent /> 
  </Suspense> 
);

Das Plugin schreibt den Import während des Builds so um, dass die remoteEntry.js vom Remote Server importiert, und darüber die gewünschte Komponente gefunden wird.

Hier ist es wichtig, anzumerken, dass ein Top-Level-Import, also

import RemoteComponent from 'someRemoteEntry/SomeRemoteComponent';

nicht möglich ist, da es sich hier um eine synchrone Operation handeln würde. Das heißt, das Remote-Modul muss zuerst von einem anderen Server geladen werden.

Die import(...)-Funktion gibt das Modul als Promise zurück, welche gelöst wird, wenn das Modul, sowie alle Top-Level-Importe dieses Moduls aufgelöst wurden.

Angular

Da Angular-Projekte standardmäßig Webpack als Build-Tool verwenden, habe ich dieses Tool mit dem Plugin @angular-architects/module-federation-plugin für das Aufrufen und Bereitstellen von Remote-Modulen ausprobiert.

Ähnlich wie beim vite-plugin-federation kann ein Modul über die webpack.config.js über remotes und exposes Remote-Module konsumieren, bzw. Module für andere Remote-Module bereitstellen.

module.exports = withModuleFederationPlugin({
    // Wird von diesem Kompilat konsumiert
    remotes: {
      'angular-remote': 'http://localhost:3001/remoteEntry.js',
    },
    name: 'module-name',
    // Liste der JS-Module, welche von diesem Kompilat bereitgestellt werden
    exposes: {
      './ExposedRoute': './packages/angular-remote/src/app/remote-page.module.ts',
    },
    shared: {
      ...shareAll({
        singleton: true,
        strictVersion: true,
        requiredVersion: 'auto',
      }),
    },
  });

Auch ein Einbinden eines Remote-Modules ist unter Angular sehr ähnlich zum React+Vite- Beispiel:

const routes: Routes = [
  {
    path: '',
    component: IndexRoute,
    pathMatch: 'full',
  },
  {
    path: 'remoteRoute',
    // Das Remote Modul wird über eine Promise geladen. Dabei handelt
    // es sich um ein Angular-Modul, welches ein
    // RouterModule.forChildren(...) definiert.
    loadChildren: () =>
      loadRemoteModule({
        type: 'module',
        remoteEntry: 'http://localhost:3001/remoteEntry.js',
        exposedModule: './RemoteRoute',
      }).then((m) => m.RemotePageModule),
  },
];
@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {}

Auch wenn für Angular unüblich, können hier Remote-Komponenten über das asynchrone Austauschen einer ViewContainerRef ohne einen Router eingebunden werden (vgl. ViewContainerRef Dokumentation).

Ansatz 2: Framework-unabhängiger Ansatz mit Web Components

Für diesen Ansatz wurde eine Host-Anwendung mit Angular implementiert. In diese Anwendung wurden mehrere Web Components eingebunden, implementiert mit Vue, React und „Vanilla TS“. Diese Web Components werden dabei von einem anderen Server bereitgestellt.

Ein Screenshot der entwickelten Anwendung. In einen Angular-Host wurden mehrere Microfrontend-Komponenten über Web Components eingebunden.
Ein Screenshot der entwickelten Anwendung zeigt: In einen Angular-Host wurden mehrere Microfrontend-Komponenten über Web Components eingebunden.

Web Components

Web Components sind eine in allen gängigen Browsern unterstützte API, mit der neue HTML-Elemente definiert werden können. Diese Element können, ähnlich wie bei Vue und Angular, über Props Daten annehmen und über Events zurückgeben.

Eine Web Component wird erstellt, indem wir die Klasse HTMLElement erweitern.

Hier ein Beispiel einer sehr primitiven Web Component. Diese nimmt einen Namen an und rendert den Namen in einen Gruß:

class ExampleGreeter extends HTMLElement {
  #name: string;
  #root!: ShadowRoot;
  #htmlElement: HTMLDivElement;
  get name() {
    return this.#name;
  }
  // Setter wird aufgerufen wenn das name-Prop modifiziert wird.
  // Der Setter ruft die Render-Funktion auf
  set name(name: string) {
    this.#name = name;
    this.render();
  }
  render() {
    if (!this.#htmlElement) {
      return;
    }
    this.#htmlElement.innerHTML = `Hello, &lt;b&gt;${this.#name}&lt;/b&gt;!`;
  }
  // Code wird aufgerufen wenn das Element aufgebaut wird
  connectedCallback() {
    this.#root = this.attachShadow({ mode: 'open' });
    this.#htmlElement = document.createElement('div');
    this.render();
  }
  // Cleanup Code. Wird aufgerufen wenn das Element entfernt wird
  disconnectedCallback() {}
  // Damit eine Web Component im HTML verwendet werden darf muss sie
  // zuerst angemeldet werden.
  // Danach kann die Web Component wie gefolgt verwendet werden:
  // &lt;example-greeter name="Lukas"&gt;&lt;/example-greeter&gt;
  static register() {
    window.customElements.define('example-greeter', ExampleGreeter);
  }
}

Eine wichtige Anmerkung zu Web Components ist, dass diese isoliert zum Rest der Webseite gestylt werden. Der Inhalt der Web Component läuft unter einer Shadow DOM. Die Shadow DOM stellt ein eigenes Dokument bereit. Styles können auf dieses separate Dokument über einen in der Web Component erstellten <link rel="stylesheet" ...>-Knoten oder durch Inline Styles angewandt werden. Diese Styles werden nur auf das eigene Dokument angewandt, die Styles werden also nicht an darunterliegende Web Components oder den Eltern-Knoten übergeben.

Definition der Remote Frontends

Die Remote Frontends wurden so definiert, dass im Kompilat eine /webcomponent.js generiert wird. Die Datei definiert ein ES-Modul, welches als default-Export eine Funktion bereitstellt, über welche die Web Component im Host angemeldet werden kann.

Diese Struktur ist für alle Remote Frontends identisch, sodass ein Remote Frontend aus dem Host über

const module = await import(`http://localhost:${PORT}/webcomponent.js`);
module.default('component-name-to-use');

geladen und im Host registriert wird.

Danach kann im Host eine Komponente mit dem angegebenen Namen (hier component-name-to-use) erstellt werden.

Für die Remote Frontends wurde Vite als Build-Tool verwendet. Die Standard-Konfiguration bei Vite baut eine Single Page Application. Diese Konfiguration ist so aufgebaut, dass das Resultat auf einem Server gehostet werden kann und eine Webseite bereitstellt.

Es gibt aber auch den Library Mode. Hier wird eine Code-Datei als Einstiegspunkt referenziert.

Statt einer ganzen Webseite wird im Libary Mode dieser Einstiegspunkt, sowie alle Abhängigkeiten, in ein Bundle gepackt. Dieses Bundle lässt sich dann als ES-Modul im Host einbinden.

// Die vite Config, welche für die Remote Frontends verwendet wird.
// Hier als Beispiel die vite-webcomponent.config.ts des Vue-Microfrontend
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
  plugins: [vue()],
  define: {
    'process.env': {},
  },
  base: 'http://localhost:5002/',
  build: {
    lib: {
      entry: resolve(__dirname, 'src/webcomponent.ts'),
      formats: ['es'],
      fileName: 'webcomponent',
    },
    target: 'modules',
    sourcemap: true,
  },
});

Die resultierende webcomponent.js beinhaltet den ganzen Code, der für diese Web Component benötigt wird. Das beinhaltet die Laufzeit (bpsw. Vue), sämtliche Komponenten, die in der Anwendung definiert und eingebunden wurden, sowie die Logik zum Verpacken und Registrieren der Anwendung als Web Component.

Das Anbinden der Frontend-Frameworks an die Web Component hängt vom jeweiligen Framework ab. Vue 3 hat beispielsweise die defineCustomElement-Funktion, die eine Vue Singe File Component in eine Web Component umwandelt.

Die genauen Implementationen können dem Github Repository aus den webcomponent.ts(x)-Dateien entnommen werden.

Einbindung und Kommunikation zwischen Host und Microfrontend-Komponenten

In der Host-Anwendung wurde eine Angular-Komponente definiert, die das Laden und Einbinden einer remote Web Component ermöglicht. Kurz gesagt lädt die Komponente über import(...) das Remote-Modul, ruft dort den Default Export auf und registriert somit die Web Component. Daraufhin wird eine Instanz der Web Component erstellt und der Zustand zwischen der Host-Anwendung und der Web Component verknüpft.

Die Web Components sind so aufgebaut, dass die Host-Anwendung einen Zustand (hier Testweise count) übergeben und über Updates auf diesen Zustand informiert werden kann. Für die Information ist die Anmeldung an ein von der Web Component bereitgestelltes CustomEvent notwendig.

Ein Kommunikations-Beispiel zwischen den Web Components und der Host-Anwendung. Die Host-Anwendung übergibt Daten "nach unten" über Props. Aktualisiert eine der Web Components den Zustand, erfährt die Host-Anwendung darüber über das `CustomEvent`. Kommt ein Event an, aktualisiert die Host-Anwendung den eigenen Zustand. Die Änderung wird in die Web Components propagiert, wodurch der neue Zustand in allen Web Components landet.
Ein Kommunikations-Beispiel zwischen den Web Components und der Host-Anwendung zeigt: Die Host-Anwendung übergibt Daten „nach unten“ über Props.
Aktualisiert eine der Web Components den Zustand, erfährt die Host-Anwendung darüber über das CustomEvent. Kommt ein Event an, aktualisiert die Host-Anwendung den eigenen Zustand. Die Änderung wird in die Web Components propagiert, wodurch der neue Zustand in allen Web Components landet.

Für das Beispiel wurde ein sehr einfacher Zustand, ein einzelner Counter, verwendet. Es ist jedoch genauso möglich, den Zustand komplexer aufzubauen oder sogar eine Message Queue zu implementieren.

Beide Ansätze im Vergleich

Im Grunde können beide Herangehensweisen verwendet werden, um Microfrontends zu realisieren. Der Framework-spezifische genauso wie die Framework-unabhängige. Doch es gibt Unterschiede:

Der Framework-spezifische Ansatz hat den Vorteil, dass Frameworks wie React, Vue oder Angular nur einmal eingebunden werden. Somit bleibt das generierte Bundle vergleichsweise klein. Auch bleibt man dadurch im Daten-Konzept des jeweiligen Frameworks (bspw. :props und @events mit Vue) und kann Komplexität sparen, die durch das Verpacken der Anwendung in eine Web Component entstünde.

Der Framework-unabhängige Ansatz mit Web Components hat den Vorteil, dass man sich nicht auf ein bestimmtes Framework beschränkt. Für die Kommunikation zwischen Host und Microfrontends wird eine API verwendet, die im Living Standard für HTML definiert ist.

Man darf davon ausgehen, dass Web Components als Standard stabil sind. Falls verwendete Frameworks in der Zukunft nicht mehr unterstützt werden, wäre der Austausch  vergleichsweise einfach.

Ein Nachteil dieses Ansatzes ist, dass jedes Microfrontend das verwendete Framework separat einbindet. Dies hat jedoch auch den Vorteil, dass es nicht zu Versionskonflikten kommen kann.

Bei der Implementation des Framework-spezifischen Ansatzes bekam ich kryptische Fehler beim Anbinden eines Microfrontends. Nach längerem Debugging entdeckte ich die Ursache: Der Fehler kam aufgrund unterschiedlicher Versionen zwischen Host und Microfrontend zustande.

Eine alternative Lösung ist es, in den Web Components und im Host die Frameworks als externe Abhängigkeiten einzubinden.

Das verhindert einerseits, dass die verwendeten Funktionen des react-Moduls in die webcomponent.js kopiert werden und sorgt gleichzeitig dafür, dass stattdessen das react-Modul auch weiterhin als Top-Level-Import im Kompilat eingebunden wird. Gekoppelt mit einer Import-Map können so mehrere react-Imports auf denselben Import abgebildet werden, wodurch das zu ladende JS-Bundle kleiner wird.

Beide Ansätze teilen die grundlegenden Probleme mit Microfrontends:

  • Builds sind schwerer reproduzierbar, weil es keine Single Source-of-Truth gibt. Es müssten also alle Komponenten in einer bestimmten Version deployt werden, um einen Build reproduzierbar zu testen.
  • Weil es zum Übersetzungzeitpunkt keine Informationen über die Microfrontends gibt, gibt es keine Typsicherheit auf der Schnittstelle

Welche Alternativen gibt es?

Zwei alternative Ansätze sollten meines Erachtens nicht unerwähnt bleiben. Mit ihnen lassen sich einige der genannten Probleme lösen:

1. Komponenten-Bibliothek

Statt die einzelnen Microfrontends dezentral zu deployen und von einem zentralen Host zur Laufzeit die einzelnen Microfrontends einzubinden, könnten die Microfrontends weiterhin dezentral gebaut, aber zentral zur „Kompilierzeit“ eingebunden werden – oder anders gesagt: Die Microfrontends stellen jeweils Komponenten über ein NPM-Modul bereit, das vom Host konsumiert wird.

Dadurch gewinnen wir Typ-Sicherheit und haben Dank der Single Source-of-Truth einen reproduzierbaren und somit deutlich testbareren Build. Die „Microfrontends“ können weiterhin isoliert voneinander entwickelt und getestet werden. Die einzige Konfliktstelle zwischen den verschiedenen Implementationsteams stellt die Host-Anwendung dar. Hier müssen die einzelnen Module in der package.json eingebunden und bei Bedarf aktualisiert werden.

Der Nachteil: Die einzelnen Microfrontends verlieren an Unabhängigkeit: Sie sind nicht mehr unabhängig deploybar, die Deploymentzeitpunkte sind zeitlich aneinander gekoppelt.

2. Multipage-Application (MPA) mit nginx

Ein Beispiel-Aufbau mit NGINX und Microfrontends im MPA Ansatz
Ein Beispiel-Aufbau mit NGINX und Microfrontends im MPA Ansatz

Sind die einzelnen Microfrontends vollständig voneinander isoliert, also nicht verschachtelt, und bilden nur Routen ab, könnte die Verwendung einer Multi Page Application (MPA) mit nginx in Erwägung gezogen werden.

Die ganzen Frontends werden separat als vollständige Frontends deployt. Die Router der jeweiligen Microfrontends sind so konfiguriert, dass die Wurzel nicht / ist, sondern ein dem Frontend zugeordneter Pfad wie /account oder /checkout. Somit ist beispielsweise ein Microfrontend zuständig für die /account/**-Routen, ein anderes für die /checkout/**-Routen.

Die Frontends werden auf verschiedenen Server unabhängig deployed. Es wird als „Host“ ein Nginx-Deployment erstellt, das die einzelnen Pfade auf die dazugehörigen Frontend-Deployments abbildet. Bleibt der Nutzer auf demselben Microfrontend, so wird der Frontend-interne Router verwendet. Wechselt man zwischen den Microfrontend, dann wird die ganze Seite neu geladen. Dies führt zu etwas längeren Ladezeiten, da das HTML-Dokument, sowie die ganzen Styles und Skripte für das neue Microfrontend geladen und ausgeführt werden müssen. Dieses Problem kann jedoch durch das Setzen eines <link rel="preload" .../> in der aufrufenden Seite eingedämmt werden.

Ein weiteres Problem ist geteilter Zustand. Möchte man Daten zwischen den Frontends austauschen, kann nicht der Frontend-interne Store verwendet werden.

Ein Lösungsansatz ist es, den geteilten Zustand in die sessionStorage zu schreiben. Hierfür muss der Zustand als String abbildbar sein, bspw. als JSON String. Während der localStorage einen permanenten Key-Value-Store für eine Webseite darstellt, ist der sessionStorage nur für die aktuelle Session aktiv. Wenn eine Webseite auf mehreren Tabs geöffnet wird, wird für jeden Tab eine neue Session erstellt. Wird ein Tab, bzw. der Browser, geschlossen, dann erlischt die Session (vgl. MDN Docs).

Dank der nginx Proxy haben alle Frontends für den Browser denselben Origin. Dadurch verfällt die sessionStorage nicht bei einem Seitenwechsel auf ein anderes Microfrontend.

Mein persönliches Fazit

Microfrontends bieten die Möglichkeit, riesige und hochkomplexe Frontends sinnvoll aufzuteilen und voneinander unabhängig zu machen und somit auch unabhängig daran zu entwickeln.

Du solltest dir jedoch gut überlegen, ob die Einbußen in der Entwicklererfahrung, Testbarkeit, Typsicherheit und Reproduzierbarkeit von Builds sich für die dadurch gewonnene Unabhängigkeit der einzelnen Microfrontends lohnen.

Für riesige Anwendungen kann dies tatsächlich der Fall sein. Doch für die meisten Anwendungen sind Investitionen in bessere Architektur, einen besseren Schnitt der Komponenten und klare Coding-Konventionen eine effizientere Art, um Konflikte zwischen Entwicklungsteams an einem Frontend zu reduzieren.

Wenn ich zwischen einer der beiden gezeigten Strategien für Microfrontends wählen müsste, würde ich die framework-unabhängige Herangehensweise empfehlen.

Aufgrund von drei Gründen:

  • Höhere Flexibilität (weil unabhängig von Framework und Version)
  • Einfachere Austauschbarkeit durch die Verwendung von Web Components
  • Unterstützung und Unabhängigkeit: Wie erwähnt sind Web Components Teil der HTML-Spezifikation, werden von allen gängigen Browsern unterstützt und bieten somit eine Framework-unabhängige Möglichkeit für die Kommunikation zwischen Microfrontends.

Weiterführende Links

Profilbild Waldemar Lehner
Alle Beiträge von Waldemar Lehner

Associate Developer mit Fokus auf Web-Technologien

Schreibe einen Kommentar