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:
- Erstelle einen neuen Angular Workspace
ng new angular-microfrontend --create-application false
- Erzeuge die Apps „Shell“ und „Counter“ im neuen Workspace
cd angular-microfrontend
ng generate application shell
ng generate application counter
- Füge das Paket Angular Elements hinzu
ng add @angular/elements
- 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
- Github Repository zum Artikel: https://github.com/SoftwareKater/zoneless-angular-microfrontend
- Angular Elements without Zone.js: https://www.angulararchitects.io/aktuelles/angular-elements-part-iii/
- Angular and Web Components: https://indepth.dev/posts/1116/angular-web-components-a-complete-guide
- New possibilities with Angular’s push pipe – Part 1: https://indepth.dev/posts/1313/angulars-push-pipe-part-1
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.
- Johannes Katzer
- Lead Software Engineer
- jka@maximago.de
- +49 231 58 69 67-0