Micro-Frontends mit Angular Elements

Zusammenfassung

Beim Einsatz von Angular Elements funktioniert die Change Detection nur innerhalb der mit Angular Elements gebündelten Web Components, nicht aber über ihre Grenzen hinaus. Dieser Artikel unseres Lead Software Engineers Johannes Katzer stellt Lösungen zum Verzicht auf Zone.js vor.

Dieser Artikel erschien am 01.03.2022 im Fachmagazin entwickler.de.

Micro-Frontend-Architektur mit Angular Elements

Die Micro-Frontend-Architektur hat in den letzten Jahren an Bedeutung gewonnen. Sie ermöglicht es, die Vorteile der Microservice-Architektur auch im Frontend zu nutzen. Zu nennen sind dabei insbesondere verteilte Teams, Technologieunabhängigkeit, unabhängige Bereitstellung und unabhängige Aktualisierbarkeit.

Es gibt verschiedene Möglichkeiten Micro-Frontends im Web zu realisieren. Eine dieser Möglichkeiten sind „Web Components“ (auch „Custom Elements“ genannt). Angular bietet dafür das Paket Angular Elements (siehe https://angular.io/api/elements und https://angular.io/guide/elements) an. Mit dessen Hilfe können in einem korrekt konfigurierten Angular Workspace mit wenig Boilerplate Code Angular-Komponenten als Web Components gebündelt werden.

Beim Entwickeln von Micro-Frontends mit Angular Elements stößt man auf eine ganz spezielle Herausforderung, welche mit der Angular-Änderungserkennung (engl. Change Detection) zusammenhängt. In Micro-Frontends mit Angular Elements kann es zu Problemen kommen, wenn die Web Components Werte austauschen, welche von asynchronen Events abhängig sind. Die Änderungserkennung in Angular ist eng mit der Angular Zone verbunden: Zone.js sorgt dafür, dass bestimmte (asynchrone) Events die Änderungserkennung im gesamten Angular-Komponentenbaum anstoßen. Beim Einsatz mit Angular Elements funktioniert die Änderungserkennung aber nur innerhalb der mit Angular Elements gebündelten Web Components, nicht aber über ihre Grenzen hinaus, d.h. ein asynchrones Event, welches in der Zone einer Web Component passiert bleibt vor den Zonen der anderen Web Components des Micro Frontends verborgen.

Ein Ausweg daraus ist, dass alle Web Components des Micro-Frontends sich eine Zone teilen, was allerdings zu einer Kopplung der Komponenten führt, welche dem Grundgedanken des Micro Frontends widerspricht. In diesem Beitrag werden Lösungen vorgestellt, welche darauf basieren ganz auf Zone.js zu verzichten. Dazu stellt dieser Beitrag zunächst das Problem in einer Beispielanwendung (https://github.com/SoftwareKater/zoneless-angular-microfrontend) in der Micro Frontend-Architektur nach. Anschließend werden zwei Möglichkeiten vorgestellt, wie in zonenlosen Angular-Apps die Änderungsdetektion funktionieren kann.

Aufsetzen der Beispielanwendung mit Angular Elements

Die Beispielanwendung basiert auf den folgenden Technologien und Paketen:

  • Angular 12
  • Angular Elements
  • ngx-build-plus

Sie besteht aus zwei Komponenten. Die „Shell“ lädt und orchestriert Web Components. Außerdem ist sie für den Import und das Ausliefern von Zone.js verantwortlich. Der „Counter“ ist eine Web Component. Er zeigt einen numerischen Zustand an und stellt einen Button dar, über den dieser Zustand manipuliert werden kann. In einem echten Micro-Frontend gäbe es noch viele weitere Komponenten, die von der Shell orchestriert werden.

Zunächst wird ein neuer Angular Workspace erzeugt, darin zwei Apps generiert und schließlich zwei zentrale Pakete hinzugefügt:

  1. Erstelle einen neuen Angular Workspace ng new angular-microfrontend --create-application false
  2. Erzeuge die Apps „Shell“ und „Counter“ im neuen Workspace
    1. cd angular-microfrontend
    2. ng generate application shell
    3. ng generate application counter
  3. Füge das Paket Angular Elements hinzu ng add @angular/elements
  4. Füge außerdem das ngx-build-plus Paket – eine Erweiterung der Angular CLI – der Counter App hinzu ng add ngx-build-plus --project counter

Die Counter App

Nachdem das Grundgerüst nun steht, können die Apps mit Leben gefüllt werden. Listing 1 zeigt die Counter App.

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template:
<h2>{{ title }}</h2>
<div>
  <strong>{{ state.counter }}</strong>
  <button (click)="onClick()">+1</button>
</div>
})
export class AppComponent {
  title = 'counter';

  public state = { counter: 0 };

  public onClick(event: any): void {
    this.state.counter += 1;
    console.log('New counter value: ', this.state.counter);
  }
}

Listing 1: Die AppComponent der Counter App

Mit ng serve counter kann überprüft werden, dass die Komponente als eigenständige Angular-App funktioniert. Um die AppComponent der Counter App als Custom Element zu definieren, nutzt man die Funktion createCustomElement() aus dem Angular-Elements-Paket (siehe Listing 2). Anschließend registriert man das neue Custom Element bei der globalen CustomElementRegistry via customElements.define(). Außerdem muss die bootstrap Definition aus der NgModule Deklaration gelöscht werden, da diese in Konkurrenz zur ngDoBootstrap() Methode stehende Anweisung ansonsten zu einem Fehler führen würde (The selector "app-root" did not match any elements).

import { DoBootstrap, Injector, NgModule } from '@angular/core';
import { createCustomElement } from '@angular/elements';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]  // Lösche diese Zeile
})
export class AppModule implements DoBootstrap {
  constructor(private injector: Injector) { }

  public ngDoBootstrap(): void {
    if (!customElements.get('counter-app')) {
      const counterDisplay = createCustomElement(AppComponent, { injector: this.injector });
      customElements.define('counter-app', counterDisplay);
    }
  }
}

Listing 2: Das AppModule der Counter App

Mit ng build counter --single-bundle --output-hashing none wird ein einzelnes Bündel main.js im Verzeichnis dist erzeugt. Die Option --output-hashing none sorgt dafür, dass an den Dateinamen des Bündels kein Hash des Inhalts angehängt wird. Nachträgliche Änderungen an der Web Component beeinflussen so nicht den Namen des Bündels. Das hat zwei Implikationen: Die Codezeile, welche für das einbinden der Web Component in die Shell verantwortlich ist, muss nicht nach jedem Bauen angepasst werden. Außerdem muss im Browser in den Entwicklereinstellungen der Cache ausgeschaltet werden, ansonsten wird auch nach einer Änderung an dem Counter das alte main.js Bündel aus dem Cache geladen.

Die Shell App

Da die Shell nur eine Web Component laden und darstellen muss, ist sie denkbar einfach aufgebaut (siehe Listing 3).

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template:
<h1>{{ title }}</h1>
<counter-app></counter-app>
})
export class AppComponent {}

Listing 3: Die AppComponent der Shell

Die Counter Web Component ausliefern und einbinden

Im Template der Shell findet sich der <counter-app>-Tag. Das ist natürlich kein Standard HTML-Tag. Hierbei handelt es sich um das Custom Element, welches im vorangegangenen Schritt erstellt wurde. Startet man jetzt die Shell mit ng serve shell, so bekommt man eine Reihe von Fehlermeldungen, die sich alle auf eine unzureichende Konfiguration des Projekts zurückführen lassen. Im Folgenden werden diese Fehler untersucht und gelöst.

Zunächst erzeugt das Konsolenfenster die Fehlermeldung Error: projects/shell/src/app/app.component.html:2:1 - error NG8001: 'counter' is not a known element. Diese lässt sich darauf zurückführen, dass die Shell die Counter Web Component nicht finden kann. Schließlich wird der Counter aktuell weder ausgeliefert, noch ist er bei der Shell registriert. Um den Counter auszuliefern, wird ein statischer HTTP-Server im Ordner dist gestartet. Dazu kann man z.B. das npm-Paket http-server verwenden, siehe Listing 4.

npm i -g http-server
cd dist
http-server

Listing 4: Installieren und Starten des HTTP-Servers

In der index.html der Shell muss nun auf die Ressourcen aus dem dist-Verzeichnis verwiesen werden, indem die folgende Zeile im <head>-Tag hinzugefügt wird. Dabei ist die genaue URL und insbesondere der Port von der Konfiguration des HTTP-Servers abhängig. Listing 5 zeigt die Einbindung unter der Voraussetzung, dass der Webserver über den Port 8080 erreichbar ist.

<head>
  <!-- ...  -->
  <script src="http://localhost:8080/counter/main.js"></script>
</head>

Listing 5: Referenzieren des Counter-Bündels in der Shell

Da es sich bei dem <counter-app>-Tag um ein Custom Element handelt, ist es notwendig das CUSTOM_ELEMENTS_SCHEMA im AppModule der Shell zu definieren (Listing 6).

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule
  ],
  schemas: [CUSTOM_ELEMENTS_SCHEMA],  // füge diese Zeile hinzu
})

Listing 6: Moduldeklaration der Shell App

Ein erneutes Starten der Shell erzeugt in der Konsole des Browsers (nicht in der Konsole, in der das Kommando ausgeführt wurde) die Fehlermeldung In this configuration Angular requires Zone.js. Zone.js wird über die polyfills.js ausgeliefert. Da in der Shell aber nur auf das main.js Bündel verwiesen wird, liegen die Polyfills und damit insbesondere auch das Zone.js-Bündel für die Counter App nicht vor. Es gibt nun grundsätzlich zwei Möglichkeiten das Micro-Frontend zu reparieren.

Angular-Apps mit und ohne Zone.js

Eine mögliche Lösung ist Zone.js in der main.ts Datei zu importieren, wie Listing 7 zeigt. Die Datei liegt in den node_modules und kann mittels import zone.js/dist/zone importiert werden. Den Anweisungen aus dem vorherigen Abschnitt folgend, kann das Micro-Frontend fehlerfrei gestartet werden.

// ...
import 'zone.js/dist/zone';

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));

Listing 7: Counter App main.ts mit Zone.js

Diese Lösung besticht durch ihre Einfachheit: In jeder Sub-Komponente, die von der Shell geladen werden soll, muss man einfach nur das Zone.js Paket importieren. Sie kommt aber mit einigen Problemen, die erst in komplexeren Micro-Frontends auftreten. Da diese Probleme nicht mit dem einfachen Beispiel einer Counter App gezeigt werden kann, soll hier zumindest ihr Grund geschildert werden. Das Zone.js Paket patcht bestimmte Browser-Funktionalitäten, um die Hooks zur Angular Änderungserkennung einzufügen. Durch dieses Monkey Patching stoßen asynchrone Operationen, Nutzerinteraktionen und bestimmte Events nach ihrer Abhandlung die Änderungserkennung an. Dabei ist es relevant welches Zone.js Paket das Patchen vornimmt: Liefern mehrere Web Components das Zone.js Paket aus, so überschreiben sie sich gegenseitig. In Micro-Frontends mit vielen Web Components führt das zu Konflikten, die man häufig daran erkennt, dass sich nur Teile des Micro-Frontends korrekt aktualisieren.

Im Folgenden soll eine andere Lösung verfolgt werden, bei der man zonenlose Angular-Apps programmiert. Also Apps, die ganz auf Zone.js verzichten. Dazu übergibt man in main.ts die noop-Zone (siehe Listing 8).

platformBrowserDynamic().bootstrapModule(AppModule, { ngZone: 'noop' })
  .catch(err => console.error(err));

Listing 8: Counter App main.ts mit noop Zone

Keine Änderungserkennung ohne Zone.js

Nachdem alle Hürden beim Aufsetzen des Projekts beseitigt sind und die Anwendung lauffähig ist, stellt man fest, dass sie nicht funktioniert. Mit einem Klick auf den Button passiert nichts, obwohl sich der Zustand und damit die Anzeige verändern sollte. Ohne Zone.js haben Angular-Apps keine automatische Änderungserkennung. Dieser Umstand fällt übrigens erst in der Integration des Micro-Frontends auf. Mit einem Komponententest lässt sich dieses Verhalten nicht abfangen – der in Listing 9 gezeigte Test wird erfolgreich durchlaufen.

it('updates the state', () => {
    expect(app.state.counter).toBe(0);
    component.onClick();
    expect(app.state.counter).toBe(1);
});

Listing 9: Unit Test der onClick() Methode

Wie die Logmitteilung in der Browserkonsole beweist, wird der Klick auf den Button durchaus in der Komponente registriert. Das Problem ist, dass die Anzeige daraufhin nicht neu gezeichnet wird, obwohl es eine Änderung im Modell gab.

Lösung 1: Manuelle Änderungserkennung

Diese Lösung arbeitet mit der Klasse ChangeDetectorRef und ihrer Methode detectChanges(). Damit können Komponenten für die Angular Änderungserkennung markiert werden, damit diese tatsächlich aktualisiert werden. Listing 10 zeigt die Implementierung.

import { ChangeDetectorRef, Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template:
<h2>{{ title }}</h2>
<div>
  <strong>{{ state.counter }}</strong>
  <button (click)="onClick()">+1</button>
</div>
})
export class AppComponent {
  title = 'counter';

  public state = { counter: 0 };

  constructor(private readonly cdr: ChangeDetectorRef) { }

  public onClick(event: any): void {
    this.state.counter += 1;
    console.log('New counter value: ', this.state.counter);
    this.cdr.detectChanges();
  }
}

Listing 10: Die AppComponent der Counter-Komponente mit manueller Änderungserkennung

Dieses Vorgehen ist zwar für ein einzelnes Element einfach umzusetzen, aber für eine ganze Anwendung in Summe aufwändig. Die ChangeDetectorRef und die detectChanges() Aufrufe sind über die gesamte Anwendung verstreut. So muss etwa jedes @Input() Attribut der Komponente, welches im Template verwendet wird, zu einem Setter umgeschrieben werden, an dessen Ende die Änderungserkennung aufgerufen wird (siehe Listing 11).

@Component({
  selector: 'my-component',
  template:
<div>{{ myInputValue }}</div>
})
export class MyComponent {
  private myInputValue: string;

  @Input()
  public set myInput(value: string) {
    myInputValue = value;
    this.cdr.detectChanges();
  }

  public get myInput() {
    return myInputValue;
  }
}

Listing 11: Manuelle Änderungserkennung bei @Input() Attributen

Schließlich ist das Debugging und Auffinden der Stellen, an denen der detectChanges() Aufruf fehlt fehleranfällig und zeitintensiv.

Der Programmcode zu dieser Lösung befindet sich im Branch origin/manual-change-detection.

Lösung 2: Push Pipes

Die alternative Lösung mit Push Pipes instanziert alle relevanten Komponentenmitglieder als BehaviorSubject (siehe Listing 13 und 14). Mit einem Aufruf der next() Methode kann das BehaviorSubject aktualisiert werden. Im Template der Komponente sorgt die push-Pipe für das automatische Aktualisieren der Nutzeroberfläche. Außerdem erleichtert sie – wie die async-Pipe – die Arbeit mit Observables wie zum Beispiel dem BehaviorSubject, weil kein explizites abonnieren und abmelden (über die Methoden subscribe() und unsubscribe()) nötig ist.

Für diese Lösung kommt die ngrxPush-Pipe aus dem Paket @ngrx/component zum Einsatz. Das Modul in welchem sie enthalten ist, muss im AppModule der Counter App importiert werden (Listing 12). Daraufhin kann im Template die Pipe ngrxPush verwendet werden (Listing 13).

import { ReactiveComponentModule } from '@ngrx/component'

@NgModule({
  // ...
  imports: [
    BrowserModule,
    ReactiveComponentModule
  ],
})
export class AppModule implements DoBootstrap {
// ...

Listing 12: Das AppModule der Counter App mit dem Import des ReactiveComponentModule

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template:
<h2>{{ title }}</h2>
<div>
  <strong>{{ (state$ | ngrxPush)?.counter }}</strong>
  <button (click)="onClick()">+1</button>
</div>
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
  title = 'counter';

  public readonly state$ = new BehaviorSubject({ counter: 0 });

  public onClick(): void {
    const counter = this.state$.value.counter + 1
    this.state$.next({counter});
    console.log('New counter value: ', counter);
  }
}

Listing 13: Die AppComponent der Counter Komponente in der Lösung mit Push-Pipes

Da das zugrundeliegende BehaviorSubject nur aktualisiert aber niemals neu zugewiesen wird, kann es als readonly deklariert werden, was einen kleinen Vorteil bei der Speichernutzung ergibt. Außerdem kann die ChangeDetectionStrategy.OnPush verwendet werden.

Auch bei diesem Lösungsweg müssen alle klassischen @Input() Attribute zu einem Setter umgeschrieben werden, welcher die next() Methode auf dem zugrundeliegenden BehaviorSubject aufruft. Dies zeigt das letzte Listing 14.

@Component({
  selector: 'my-component',
  template:
<div *ngIf="(myInputSubject$ | ngrxPush) as myInput">{{ myInput }}</div>
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MyComponent {
  private readonly myInputSubject$: BehaviorSubject<string>;

  @Input()
  public set myInput(value: string) {
    myInputSubject$.next(value);
  }
}

Listing 14: Input Attribute mit Push-Pipes

Der Programmcode zu dieser Lösung befindet sich im Branch origin/push-pipes.

Zusammenfassung

Die Angular-Änderungserkennung nimmt dem Entwickler im Normalfall einiges an Arbeit und kognitiver Überlast ab. Der Entwickler muss nur die von Angular bereitgestellten Decorators benutzen, um die Anzeige mit dem Modell so zu verknüpfen, dass Änderungen sofort sichtbar werden. Wird die Angular-App allerdings als Web Component gebündelt gibt es Gründe, auf diese Vorteile zu verzichten. Einerseits geht der Verzicht auf Zone.js mit einer Reduktion der Bündelgröße einher, was im Rahmen von Micro-Frontends durchaus erwünscht ist, zumal Angular schon von sich aus sehr große Bündel produziert. Andererseits sind die verschiedenen Angluar-Komponenten des Micro-Frontends ohne Zone.js tatsächlich unabhängig. Sind sie doch ansonsten über die Verwendung der über die Shell ausgelieferten Zone.js gekoppelt: Ein Micro-Frontend, welches aus Standard-Angular-Komponenten besteht, muss das Zone.js Paket bereitstellen. Damit sind die Komponenten nicht unabhängig voneinander und auch nicht unabhängig von der umgebenden Anwendung (Shell) in der sie eingebunden sind.

Grundsätzlich gibt es zwei verschiedene Wege mit dem Problem der Änderungserkennung in zonenlosen Angular-Apps umzugehen: manuell oder mittels Push Pipes. In den meisten Fällen ist die letzte Lösung der Königsweg. Je nachdem wie die umgebende technologische Landschaft aufgebaut ist und wie die Kenntnisse des Entwicklungsteams gelagert sind, mag aber auch der andere Weg zum Ziel führen.

Im Besonderen hat die NgRx-Implementierung des Konzeptes der „Push Pipes“ den Vorteil, dass sie sowohl im einem zonenlosen als auch in einem zonenbehafteten Umfeld eingesetzt werden kann. Unter der Haube wird dann entweder detectChanges oder markForCheck für das Anstoßen der Änderungserkennung verwendet.

Weitere Informationen

Der Autor

Johannes Katzer ist Software Engineer bei der MAXIMAGO GmbH, die Innovations- und Experience-getriebene Softwareentwicklung, u.a. in hochkritischen sicherheitstechnischen Bereichen, anbietet. Er studierte Mathematik und Wirtschaftswissenschaften an der Uni Bochum. Seit mehreren Jahren arbeitet er in Angular-Projekten – auch in sehr großen Projekten mit sicherheitskritischem Hintergrund.

 

Zu den Übersichtsseiten