Warum ist es so herausfordernd, performante und bedienfreundliche Oberflächen zu bauen? Eine Antwort lautet: Im Frontend müssen unterschiedliche Aufgaben gelöst werden. Es geht nicht nur um die Umsetzung von Logik. Bedienelemente müssen sinnvoll miteinander verknüpft werden. Das Design muss ansprechend gestaltet werden. Außerdem müssen die richtigen Daten zur richtigen Zeit visualisiert werden, um dem Nutzer ein komfortables Bedienerlebnis zu bieten.
Das höhere Ziel lautet: “Als Entwickler wollen wir eine Anwendung bieten, mit denen unsere Kunden ihr Business rocken können.”
Das eben angesprochene Bedienerlebnis wird durch das Zusammenführen verschiedener Informationsquellen realisiert, die den aktuellen Anwendungsstatus repräsentieren. Im Frontend können fünf Statusarten unterschieden werden.
Die Komplexität der Fünf
Die Statusarten sind im Einzelnen im Artikel “The 5 Types of React Application State” beschrieben. Wird jede Statusart einzeln betrachtet, ist die Komplexität vergleichsweise gering. Allerdings überschneiden sich die Status. Informationen werden aus Location (Bsp.: Query-Parameter) ausgelesen, um Data zu laden. Nicht jede Session hat das Recht, alles von Data abzurufen. Es entsteht ein Abhängigkeitsgraph. Dieser wächst mit jeder Funktion, die der Anwendung hinzugefügt wird.
Schnell sind Statusinformationen so miteinander gekoppelt, dass der betreffende Code monolithische Charakterzüge hat. Die Modularisierung und damit die Austauschbarkeit sinkt. Eine Änderung kann Fehler in anderen Teilen der Anwendung haben, die gar nicht abzusehen waren.
In Angularäußert sich dieser Zustand ziemlich deutlich. Eine entartete Service-Composition hält Einzug
@Component({/*... */})
export class ListComponent {
constructor(
private route: ActivatedRoute,
private userService: UserService,
private shoppingService: ShoppingService,
private invoiceService: InvoiceService,
) {}
}
Services werden miteinander gekoppelt, sodass sie eigenständig nicht mehr funktionieren. Dass eine Änderung in Service A ein Fehlverhalten in Service B oder C hervorruft, ist eine große Gefahr. Bugs, die auf diese Art entstehen, führen nicht selten zu langen Debugging-Sessions, die Zeit und Nerven kosten.
Außerdem zeigt das Code-Beispiel sehr gut, dass in einer Komponente verschiedene Status verarbeitet werden. Das heißt, dass in jeder Komponente State-Management betrieben wird. Die folgende Abbildung macht deutlich, wie State in einer komponentenbasierten Architektur verteilt ist.
Je größer der Component-Tree wird, desto herausfordernder ist es, die Zuständigkeiten richtig zu organisieren.
Das zentrale State-Management bietet für diese Problemstellung die Lösung. Zunächst werden die Statusarten in einem Punkt organisiert. Das ist vergleichbar mit einer In-Memory-Datenbank.
Des Weiteren werden alle Schreib- und Leseoperationen in der Anwendung in einer API homogenisiert. Wie genau das funktioniert, wird im nächsten Abschnitt am Beispiel der Redux-Architektur erklärt.
Was ist Redux?
Redux beschreibt den Datenfluss in einer Frontend-Anwendung. Dabei werden Lese- und Schreiboperationen voneinander getrennt (vgl. Command-Query-Segregation Pattern). Redux hat seine Wurzeln in Facebooks Flux-Architektur. Im Kern geht es darum, die Ereignisse in einer Anwendung besser zu organisieren. Ein Ereignis wird durch eine Interaktion mit der Benutzeroberfläche ausgelöst.
In Redux werden Ereignisse in eine Action verpackt und zu einem Store gesendet. Der Store ist eine Art In-Memory-Datenbank für Ihre Anwendung, in der der gesamte Anwendungszustand (vgl. Die Komplexität der Fünf) verwaltet wird. Dort werden die Informationen der Action ausgewertet und verarbeitet. Die folgende Grafik veranschaulicht den Datenfluss der Redux-Architektur.
Das Schaubild kann mit wenigen Punkten erklärt werden:
- Die Component versendet eine Action.
- Die Action wird durch s.g. Reducer-Funktionen verarbeitet.
- Der Store wird durch das Ergebnis der Reducer aktualisiert (mutiert).
- Die Component kann Daten des Stores abonnieren und visualisieren.
In den folgenden Abschnitten zoomen wir in die einzelnen Bestandteile von Redux hinein, um zu verstehen, was sie im Einzelnen bedeuten.
Action
Die Action ist das Transportmedium, das eine Interaktion mit der Benutzeroberfläche repräsentiert. Darüber hinaus hat die Action noch eine weitere Aufgabe, die später erläutert wird. Eine Action wird durch ein serialisierbares JavaScript-Objekt repräsentiert.
const action = {
type: '[Counter] Add',
payload: 3
}
Jede Action muss über eine Property type verfügen. Optional kann sie eine Property payload besitzen. Es ist auch möglich, weitere Metadaten in das Objekt zu schreiben. Die payload kann jedweden Typ haben. Sie kann ein Primitive oder ein Referenztyp sein.
Immer wenn ein Benutzer auf die Oberfläche klickt, Eingaben tätigt oder Elemente per Drag & Drop verschiebt, wird die jeweilige Interaktion in eine Action verpackt und an den Store gesendet.
Die Action repräsentiert den Vertrag, über den in Redux kommuniziert wird.
Eine Action wird mithilfe des Store-Services versendet, der in einer Komponente genutzt werden kann.
@Component({ /* ... */ })
export class CounterComponent {
constructor(private store: Store<State>) {}
add(count: number) {
this.store.dispatch({
type: '[Counter] Add',
payload: count
})
}
}
Nicht jede Component sollte mit dem Store kommunizieren. Hier gilt es die Zuständigkeiten klar abzugrenzen. Ein bewährtes Mittel ist die Trennung von Komponenten in Presentational- & Container-Components.
Store
Im Store werden Actions verarbeitet. Mithilfe von type und payload wird abgeleitet, welche Statusinformationen im Store aktualisiert werden müssen. Die Statusinformationen werden durch ein großes JavaScript-Objekt repräsentiert.
{
counter: {
count: 0
},
log: {
infos: []
}
}
Der Store soll zu jedem Zeitpunkt einen validen Zustand haben. Aus diesem Grund wird ein Store immer mit Initialdaten erstellt, um zu gewährleisten, dass bereits zum Start der Anwendung ein valider Anwendungszustand herrscht.
Damit kann eine Component jederzeit Daten aus dem Store abrufen.
this.store.subscribe(state => {
console.log('Counter', state.counter.count);
console.log('Log Messages', state.log.infos);
});
Wie bei der Action muss das State-Objekt serialisierbar sein. Darüber hinaus gelten drei Prinzipien für den Redux Store.
Read-Only
Informationen können im Store nicht einfach überschrieben werden. Dies geht nur über das Versenden einer Action. Das sichert den Store gegen versehentliche Mutationen ab. Das führt ebenfalls dazu, dass die Change-Detection in der gesamten Anwendung auf OnPush gestellt werden kann. Dadurch sinkt die Anzahl der Change-Detection-Cycles und führt zu einer besseren Performance in der Anwendung.
Single Source of Truth
Der Store soll in der Lage sein, jedwede Anfrage einer Component zu beantworten. Dazu müssen alle erforderlichen Informationen im Store hinterlegt werden.
Wenn Sie Redux konsequent einsetzen, werden Komponenten nur noch mit dem Store-Service kommunizieren, um mit dem Anwendungsstatus zu arbeiten.
Pure Functions
Eine Statusänderung soll nur über Pure-Functions erfolgen. Diese Funktionen sind seiteneffektfrei. Das bedeutet, dass sie bei der gleichen Eingabeimmer mit dem gleichen Resultat antworten, da sie nicht von externen Quellen abhängig sind, die potenziell fehlschlagen oder unvorhersehbare Antworten liefern (Bsp.: HTTP-Client).
In der Abbildung Unidirektionaler Redux-Datenfluss wurde der Begriff Reducer eingeführt. Die Reducer sind die angesprochenen Pure-Functions in Redux.
Aus den Prinzipien von Redux ergeben sich zwei Fragen 1. Wie fügen sich Pure-Functions in die Redux-Architektur ein? 2. Wie werden Seiteneffekte in Redux behandelt? Beide Fragen werden in den nächsten zwei Abschnitten beantwortet
Reducer
Eine Reducer-Funktion hat die Aufgabe, versendete Actions zu verarbeiten. In einer Redux-Anwendung gibt es beliebig viele Reducer-Funktionen. Wird eine Action versendet,werden alle Reducer ausgeführt. Der jeweilige Reducer schaut zunächst auf die type-Property der Action. Wenn es für den type eine zugehörige Mutation gibt, wird sie ausgeführt und der Status im Store aktualisiert.
Die Abbildung Reducer verarbeiten eine Actionzeigt, wie eine Action A versendet wird und mehrere Reducer passiert. Der Reducer A hat nichts mit der Action zu tun. Darum passiert in diesem Reducer nichts. In Reducer B gibt’s dann ein “Match” und die Action resultiert in einer Zustandsveränderung.
Eine Redux-Anwendung verfügt über zahlreiche Reducer, weil jede dieser Funktionen für einen bestimmten Teil zuständig ist. Gibt es in Ihrer Anwendung einen Login, gibt es wahrscheinlich einen AuthenticationReducer. Verwalten Sie zudem Produkte oder Bestellungen, werden diese Bereiche durch einen ProductsReducer beziehungsweise einem OrdersReducer abgedeckt.
Gemäß dem ersten Prinzip von Redux ist der State Read-Only. Daher wird bei der Aktualisierung des Zustands der bestehende Zustand in eine neue Objektreferenz verpackt und die Mutation wird auf der neu entstandenen Kopie appliziert. Ab diesem Zeitpunkt repräsentiert die State-Kopie den neuen Anwendungszustand.
function counterReducer(state: State, action: Action): State {
switch (action.type) {
case '[Counter] Add':
return {
...state,
count: state.count + action.payload
};
default: return state;
}
}
Das SnippetReducer-Funktion zeigt ein einfaches Beispiel einer Redux-Reducer-Funktion. Sie nimmt stets den aktuellen Anwendungszustand (hier state) und die Action entgegen und gibt einen neuen State zurück. Falls der type der Action nicht passt, wird der State unverändert zurückgegeben.
Darüber hinaus hat eine Reducer-Funktion noch eine weitere Aufgabe, die im vorangegangenen Snippet noch nicht verzeichnet ist. In jedem Reducer werden die jeweiligen Initialdaten festgelegt, damit der Store beim Start der Anwendung direkt einen validen Zustand hat.
const initialState: State = { count: 0 };
function counterReducer(
state = initialState,
action: Action
): State {
// switch-case action.type ...
}
Jeder Reducer wird im Store registriert, damit eine versendete Action verarbeitet werden kann. In Angular erfolgt dies über die Konfiguration eines Moduls. Das nachstehende Snippet skizziert, wie die Registrierung aussieht.
StoreModule.forRoot({
counter: counterReducer,
log: logReducer
})
Die implementierten Reducer kommen im Store zusammen. Da jede Reducer-Funktion einen initialen State hat, entsteht aus der Summe aller Reducer der sogenannte AppState.
Die Reducer-Funktionen verarbeiten Actions, die im Store versendet werden. Diese drei Bausteine machen den synchronen, unidirektionalen Flow von Redux aus. Es fehlt nur noch eine letzte Komponente: das Behandeln von Seiteneffekten oder auch asynchronen Operationen.
Async-Flow – Effects
Der Redux-Zyklus soll seiteneffektfrei sein. Das heißt, dass diese Seiteneffekte isoliert werden müssen, damit Redux nicht an seiner naturgemäßen Stabilität einbüßt. Der Trick ist, dass diese “unsicheren” Operationen mithilfe mehrerer Actions modelliert werden.
Es wird eine Action genutzt, um eine Operation zu initiieren. Häufig handelt es sich dabei um API-Aufrufe. Die Operation kann erfolgreich sein oder fehlschlagen. Für beide Szenarien können Actions bereitgestellt werden, die je nach Ausgang zum Store versendet werden, wo sie ein Reducer verarbeitet.
Das bedeutet, dass Actions nicht zwangsweise direkt zu einem Reducer gesendet werden. Sie können ebenso durch einen Service abgefangen werden. Das Resultat der initiierten Operation wird wieder in eine Action verpackt und zum Store gesendet.
Um einen Effect zu implementieren, muss es eine Möglichkeit geben, auf den Strom der Actions zu lauschen. Dann muss die initiierende Action herausgefiltert werden. Nachdem der Seiteneffekt fertig ausgeführt ist, wird je nach Ergebnis eine Action zum Store versendet.
Das folgende Snippet dient lediglich der Veranschaulichung. Es ist für den Gebrauch in produktivem Code ungeeignet. Die richtige Nutzung von Seiteneffekten wird im Abschnitt @ngrx/effects gezeigt.
storeActions.pipe(
filter(action => action.type === 'Initiate'),
switchMap(action => apiCall.execute(action.payload)),
map(result => store.dispatch({
type: 'Success', payload: result
})),
catchError(err => store.dispatch({
type: 'ERROR', payload: err.message
}))
)
Das folgende Snippet dient lediglich der Veranschaulichung. Es ist für den Gebrauch in produktivem Code ungeeignet. Die richtige Nutzung von Seiteneffekten wird im Abschnitt @ngrx/effects gezeigt.
Die NgRx Plattform
Die in diesem Abschnitt gezeigten Code-Beispiele gehören zu einer Beispielanwendung. Auf https://stackblitz.com/edit/ngrx-9-playground können Sie sich das NgRx Projekt ansehen.
NgRx ist ein Framework für Angular, das die Redux-Architektur implementiert. NgRx versteht sich jedoch nicht nur als Redux-Implementierung, sondern stellt reaktive Erweiterungen für Angular zur Verfügung.
Das Core-Team von NgRx stellt die Bibliothek als Plattform auf. Das bedeutet, dass sie die Grundlage schaffen, die anderen Teams gestattet, eigene Erweiterungen und Abstraktionen zu entwickeln. Schon längst wächst das Ökosystem um Frameworks, die NgRx nutzen. Dazu zählen NgRx Auto Entity und NgRx Ducks.
Die folgenden Abschnitte fokussieren sich jedoch auf die built-in Mechanismen von NgRx. Es wird gezeigt, wie die vorab besprochene Redux-Architektur durch NgRx umgesetzt wird.
Installation
NgRx ist in mehrere Pakete aufgeteilt. Über npm können alle Module installiert werden.
npm install @ngrx/{store,effects,entity,store-devtools,schematics} |
Modul | Funktion |
@ngrx/store | Stellt Actions, Reducer, Selectors und Store bereit. Mit diesen Werkzeugen kann der synchrone Flow von Redux umgesetzt werden. |
@ngrx/effects | Bietet eine API für das Handling von Seiteneffekten. |
@ngrx/entity | Vereinfacht das Schreiben von Mutationen in Reducern und stellt fertige Selektoren bereit. |
@ngrx/store-devtools | Erlaubt komfortables Debugging mithilfe einer Browser-Extension. |
@ngrx/schematics | Erweiterung für Angular CLI, um Code erzeugen zu lassen. |
@ngrx/router-store | Synchronisiert Location-Status mit dem Store. Nicht Teil dieses Artikels (siehe https://ngrx.io/guide/router-store für mehr Informationen). |
@ngrx/data | Automatisiert Erzeugung von Effekten und Reducern für CRUD-Szenarien. Nicht Teil dieses Artikels (siehe https://ngrx.io/guide/data für mehr Informationen). |
Wie bei jeder höheren Architektur ist es notwendig, die erforderliche Infrastruktur bereitzustellen. Bei dieser Aufgabe unterstützen die Schematics von NgRx. Die Kommandos erzeugen den Code, den es braucht, um NgRx in der Angular-App zu nutzen. Darüber hinaus bietet es Code-Templates an, die die Implementierung neuer Features beschleunigen.
@ngrx/store
Um den NgRx-Store zu initialisieren, wird folgendes Kommando der @ngrx/schematics ausgeführt.
ng generate store State
Initialisierung des NgRx Stores
Neben der Erzeugung einiger Verzeichnisse werden in der app.module.ts einige NgRx-Module eingetragen. Das dient dazu, die Dienste in Angular zu registrieren und den Store zu konfigurieren.
StoreModule.forRoot(reducers, {
metaReducers,
runtimeChecks: {
strictStateImmutability: true,
strictActionImmutability: true,
}
})
Setup des NgRx Stores im App Module
Im StoreModule werden reducers registriert. Darüber hinaus können in einem Konfigurationsobjekt metaReducers genutzt werden. Diese kann man sich wie Plugins für den Store vorstellen. Es sind ebenfalls Reducer-Functions. Ein gängiger Anwendungsfall ist ein Logger, der jede Action protokolliert.
Im Root-StoreModule werden runtimeChecks konfiguriert. Die Optionen strictState- und strictActionImmutability sorgen dafür, dass das erste Prinzip von Redux eingehalten wird. Sollte dagegen verstoßen werden, kommt es zu einem Fehler , der auf der Konsole zu sehen ist.
Es fällt auf, dass das StoreModule nicht direkt genutzt wird, sondern dass die statische Methode forRoot aufgerufen wird, um den Store zu initialisieren.
NgRx folgt dem Muster des Angular-Routers (vgl. @angular/router – ForRoot-Pattern). Es gibt ein Root-Modul, das die Services lädt, die für den Betrieb des Stores gebraucht werden. Zusätzlich können sog. Features hinzugefügt werden, die in den jeweiligen Feature-Modules registriert werden können.
NgRx unterstützt Lazy-Loading. Wenn ein Feature-Modul erst nachträglich geladen wird, wird das Feature zur Laufzeit dem Store hinzugefügt.
Jedes Feature erhält einen eineindeutigen Namen (hier: featureA, featureB). Unter diesem Namen werden die Daten im Store verwaltet.
{
featureA: { ... },
featureB: { ... }
}
State-Object – Verwendung von Features
Was gehört in den Root-Store und was in die Feature-Stores?
Der Root-Store stellt Daten bereit, die von allen Features geteilt werden. Dazu gehören Location-State oder auch der Session-State. Diese Informationen werden auch von anderen Modulen benötigt. Daher ist es gut, wenn diese Informationen gleich zu Beginn zur Verfügung stehen. In einem Feature-Store wird der State des jeweiligen Features verwaltet.
Beim Hinzufügen eines neuen Features unterstützen die Schematics ebenfalls.
ng generate module counter
ng generate @ngrx/schematics:feature counter/store/counter \
--module counter/counter.module.ts
Initialisierung eines NgRx Features
Falls noch nicht vorhanden wird ein Modul angelegt. Anschließend wird das Feature diesem Modul zugeordnet. Es werden alle benötigten Dateien generiert. Außerdem werden Templates für Unit- und Integrationstests zur Verfügung gestellt.
counter/
┣ store/
┃ ┣ counter.actions.spec.ts
┃ ┣ counter.actions.ts
┃ ┣ counter.effects.spec.ts
┃ ┣ counter.effects.ts
┃ ┣ counter.reducer.spec.ts
┃ ┣ counter.reducer.ts
┃ ┣ counter.selectors.spec.ts
┃ ┗ counter.selectors.ts
┣ counter.component.ts
┗ counter.module.ts
NgRx-Building-Blocks in einem Feature Module
In der Datei counter.module.ts werden die Reducer ebenfalls unter dem Feature-Key (hier: counter) registriert.
import { StoreModule } from '@ngrx/store';
import * as fromCounter from './store/counter.reducer';
// … @NgModule Definition
StoreModule.forFeature(
fromCounter.counterFeatureKey,
fromCounter.reducer
)
counter.module.ts
Es gibt eine eigene Datei für Actions, die nun exemplarisch um eine Inkrement- und Decrement-Action erweitert wird.
Anstatt das Action-Objekt manuell zu erzeugen, bietet NgRx Hilfsmethoden an, die die Deklaration und Typisierung vereinfachen.
import { createAction, props } from '@ngrx/store';
export const increment = createAction(
'[Counter] Increment',
props<{ payload: number }>()
);
export const decrement = createAction(
'[Counter] Decrement',
props<{ payload: number }>()
);
counter.actions.ts
Der Helfer createAction gibt eine Funktion zurück, mit der die jeweilige Action erzeugt werden kann. Darüber hinaus wird die Funktion props<T> eingesetzt, um den Typen der payload festzulegen.
Die Actions können mithilfe des Store-Services von NgRx versendet werden. Der Store selbst ist ein Observable. Das heißt, dass Daten als Stream in der Komponente gebunden und visualisiert werden können.
Im folgenden Snippet wird der Type any temporär verwendet, um die Erläuterungen etwas abzukürzen. Diese Situation wird durch den Einsatz von NgRx–Selectors noch behoben.
import { Component } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { decrement, increment } from './store/counter.actions';
@Component({
selector: 'app-counter',
template: `
<strong>{{ count | async }}</strong>
<button (click)="increment(1)">Increment</button>
<button (click)="decrement(1)">Decrement</button>
`
})
export class CounterComponent {
count: Observable<number>;
constructor(private store: Store) {
this.count = this.store.pipe(
select((state: any) => state.counter.count)
);
}
increment(payload: number) {
this.store.dispatch(increment({ payload }));
}
decrement(payload: number) {
this.store.dispatch(decrement({ payload }));
}
}
counter.component.ts
Im Konstruktor der CounterComponent wird der aktuelle Zählerstand abonniert. Sofern er sich ändert, wird die Ansicht aktualisiert. Die Änderung von count wird über die Actions increment und decrement hervorgerufen, die in den jeweiligen Methoden durch einen Button-Klick versendet werden.
Damit sich count tatsächlich erhöht oder senkt, müssen die Actions in einer Reducer-Funktion verarbeitet werden.
Im Theorieteil wurde ein Reducer mit einem switch-case-Statement implementiert. NgRx bietet eine alternative API an, die besser typisiert ist und deren Ziel es ist, die Lesbarkeit des Codes zu erhöhen.
import { createReducer, on } from '@ngrx/store';
import { increment, decrement } from './counter.actions';
export const initialState: State = {
count: 0
};
export const reducer = createReducer(
initialState,
on(increment, (state, { payload }) => ({
...state,
count: state.count + payload
})),
on(decrement, (state, { payload }) => ({
...state,
count: state.count - payload
}))
);
counter.reducer.ts
Mithilfe von createReducer wird eine Reducer-Funktion erstellt. Der erste Parameter repräsentiert den initialen Zustand beim Start der Anwendung. In diesem Beispiel wird er dazu genutzt, um den count auf 0 zu setzen. Danach können beliebig viele Actions behandelt werden. Dafür wird on verwendet. Die Methode nimmt die zu behandelnde Action entgegen. Danach wird die Methode implementiert, die den State verändert (hier: Addition und Subtraktion).
Der erzeugte Reducer wird in der counter.module.ts durch StoreModule.forFeature( … ) im Store registriert.
Im Reducer wird der Spread-Operator (…state) genutzt, um eine Kopie des Anwendungszustands zu erzeugen. Die Mutation wird dann auf der Kopie angewandt und ist ab diesem Zeitpunkt der neue State. Hier wird dem ersten Prinzip von Redux Rechnung getragen. Der State ist Read-Only.
Mit den bisher gezeigten Code-Beispielen funktioniert das Counter-Feature bereits. Um Daten vom Store abzurufen, stellt NgRx ein weiteres Werkzeug zur Verfügung: Selectors.
Ein Selector ist eine Projektionsfunktion, um bestimmte Daten aus dem Store zu lesen. Der Einsatz von Selectors hat zwei Vorteile:
- Die Component nutzt die Projektion des Selectors und hat keine Kenntnis mehr, wie der Store intern strukturiert ist. Das erlaubt Optimierungen am Store, ohne dass die Component angepasst werden muss.
- Selectors können miteinander kombiniert werden und erlauben das Aggregieren von Daten aus unterschiedlichen Teilen des Stores.
Um einen Selector zu erzeugen, wird zunächst ein Feature-Selector gebraucht. Dieser ist wie ein Lesezeichen, das zu einem bestimmten Teil des Stores springt, um von diesem Punkt aus Daten zu selektieren. Dafür stellt NgRx die Methode createFeatureSelector<T> zur Verfügung. Diese Methode erwartet den Feature-Key. Der Feature-Selector muss an dieser Stelle manuell typisiert werden, damit die Auto-Vervollständigung weiterhin funktioniert und Laufzeitfehler vermieden werden.
Der von der Component genutzte Selector wird mit createSelector erzeugt. Diese Funktion erwartet den Feature-Selector und erlaubt anschließend das Definieren einer Projektionsfunktion, die genau die Daten liest, die von der Component benötigt werden.
import { createFeatureSelector, createSelector } from '@ngrx/store';
import * as fromCounter from './counter.reducer';
export const counterState = createFeatureSelector<fromCounter.State> (
fromCounter.counterFeatureKey
);
export const count = createSelector(
counterState,
state => state.count
);
counter.selectors.ts
Durch die Bereitstellung des Selectors count, kann der Code in der counter.component.ts vereinfacht werden.
constructor(private store: Store) {
// Vorher
this.count = this.store.pipe(
select((state: any) => state.counter.count)
);
// Nachher
this.count = this.store.pipe(select(count));
}
counter.component.ts
Das Code-Beispiel zeigt, dass der select-Operator von @ngrx/store sowohl Inline-Projektionen als auch Selector-Funktionen akzeptiert.
Die in diesem Abschnitt gezeigten Instrumente von NgRx bilden den synchronen Flow von Redux ab.
Der folgende Abschnitt zeigt, wie Seiteneffekte behandelt werden.
@ngrx/effects
Für die Behandlung von Seiteneffekten stellt NgRx ein eigenes Modul bereit: @ngrx/effects. Damit Effects in der Angular-Anwendung funktionieren, muss das EffectsModule.forRoot([]) einmalig in der App registriert werden. Wie auch beim StoreModule werden dadurch alle erforderlichen Services bereitgestellt.
EffectsModule.forRoot([]) |
Anschließend kann jedes Feature seine eigenen Effects bereitstellen. Diese sind nichts anderes als Services, die auf den Strom von Actions lauschen und bei bestimmten Actions (den Initiating Actions) asynchrone Operationen ausführen. Jeder Effect-Service muss im EffectsModule registriert werden.
EffectsModule.forFeature([CounterEffects]) |
Die @ngrx/effects bieten einen injizierbaren Service an, der den Action-Stream bereitstellt. In Verbindung mit der Funktion createEffect wird ein Effect bereitgestellt. Der Stream von Actions wird mithilfe des Operators ofType gefiltert. So wird die Pipe des Streams nur dann weiterverarbeitet, wenn die gewünschte Action versendet wurde (hier: randomAdd).
Im folgenden Beispiel wird eine Initiating Action mit der Bezeichnung randomAdd verarbeitet. Es wird eine zufällig generierte Nummer erzeugt. Die Funktion randomizedNumber kann allerdings auch einen Fehler hervorrufen. Das folgende Snippet zeigt, dass bei erfolgreicher Generierung der Nummer die Action add erzeugt wird. Tritt ein Fehler auf, wird eine Action logInfo zurückgegeben, die den Fehler protokolliert.
Im Effect selbst ist jedoch kein this.store.dispatch() zu sehen. Das liegt daran, dass die Werte, die durch den Effect-Stream zurückgegeben werden, durch NgRx selbst zum Store verschickt werden. Das bedeutet weniger Aufwand beim Programmieren für den Entwickler.
@Injectable()
export class CounterEffects {
randomAdd = createEffect(() =>
this.actions.pipe(
ofType(randomAdd),
concatMap(() =>
randomizedNumber().pipe(
map(value => add({ payload: { value } })),
catchError(message => of(logInfo(message)))
)
)
)
);
constructor(private actions: Actions) {}
counter.effects.ts
In manchen Fällen ist es nicht gewünscht, dass ein Effect eine Action automatisch versendet. Die Funktion createEffect akzeptiert einen zweiten Parameter, der zur Konfiguration dient. Damit kann das Versenden deaktiviert werden.
randomAdd = createEffect(() =>
this.actions.pipe(...),
{ dispatch: false }
);
Effect – Das Versenden einer Action deaktivieren
Der gezeigte Code ist Teil einer Beispielanwendung, die Sie auf https://stackblitz.com/edit/ngrx-9-playground finden.
Der Umgang mit Effects ist die größte Herausforderung in NgRx. Services können lose miteinander gekoppelt werden und je nach Ergebnis der Operation können die passenden Actions versendet werden, um das Verhalten der Anwendung steuern.
@ngrx/entity
In diesem Artikel wurde bereits viel von der Verwaltung des Anwendungsstatus gesprochen. Vielleicht ist diese Ausdrucksweise etwas zu hoch gegriffen. Im Grunde geht es um das Hinzufügen, Bearbeiten, Löschen und Lesen von Datensätzen. Häufig müssen diese Operationen an Listen durchgeführt werden. Der dafür benötigte Code ist oft sehr ähnlich.
Darum hat das NgRx-Core Team mit @ngrx/entity eine Bibliothek geschaffen, die wiederkehrende Arbeiten in Reducer-Funktionen und Selectors vereinheitlichen.
Listen können mit @ngrx/entity effektiv verwaltet werden. Dafür wird das Interface EntityState<T> zur Verfügung gestellt. Dessen Einsatz homogenisiert den Aufbau des States. Anstelle die Elemente in einem Array abzuspeichern, wird ein Objekt mit einer Key-Value-Zuweisung genutzt. Der Key ist die Id des jeweiligen Datensatzes. Das bedeutet, dass die Elemente (Entities genannt) über ein Feld mit einem eindeutigen Identifier verfügen müssen.
Nun können alle mutierenden Operationen auf die gleiche Art und Weise ausgeführt werden. Der EntityAdapter<T> liefert alle herkömmlichen Funktionen (Create, Update und Delete), um den State zu aktualisieren.
import { EntityState } from '@ngrx/entity';
export interface Message {
id: string;
text: string;
}
export interface LoggerState extends EntityState<Message> {}
State mithilfe von EntityState<T> definieren
Ein EntityAdapter wird mit der Funktion createEntityAdapter<T> erstellt und typisiert. Drei wesentliche Funktionen stehen dann zur Verfügung:
- Mutationsfunktionen können im Reducer genutzt werden.
- InitialState kann für den Reducer generiert werden.
- Selectors werden zur Verfügung gestellt.
import { createEntityAdapter, ... } from '@ngrx/store';
export const adapter = createEntityAdapter<Message>();
export const loggerReducer = createReducer(
adapter.getInitialState(),
on(logInfo, (state, { payload }) => adapter.addOne(payload, state))
Einsatz des EntityAdapters
Das Codebeispiel zeigt, dass der EntityAdapter unter anderem eine Funktion addOne bereitstellt, mit dem ein Element einer Liste hinzugefügt wird. Neben der Operation sorgt der EntityAdapter auch dafür, dass eine Kopie des States angelegt wird. Der Entwickler muss damit nicht mehr an den Spread-Operator denken.
Der EntityAdapter hat neben addOne noch weitere Funktionen zu bieten, die das Entwickeln von Reducern einfacher machen.
EntityAdapter – Die Funktionen im Überblick
(siehe: https://ngrx.io/guide/entity/adapter#adapter-collection-methods)
Da durch das EntityState-Interface geregelt ist, wie die Listenelemente gespeichert werden, ist die Selektion auch immer gleich. Der EntityAdapter liefert fertige Selectors aus, die einzig und allein mit dem FeatureSelector verknüpft werden müssen, damit die Informationen vom richtigen Ort aus dem Store selektiert werden können.
import { adapter } from './logger.reducer';
export const loggerFeatureKey = 'logger';
const featureLogger = createFeatureSelector<LoggerState>(loggerFeatureKey);
export const {
selectAll,
selectTotal,
selectEntities,
selectIds
} = adapter.getSelectors(featureLogger);
EntityAdapter – Vorbereitete Selektoren bereit zum Einsatz
Mit @ngrx/entity werden kleine Helfer zur Verfügung gestellt, die nicht nur die Produktivität steigern, sondern auch den Code lesbarer machen.
Bei all den neuen Techniken, die bisher diskutiert wurden, bringt Ihnen der Einsatz von Redux auch an der Tooling-Front Vorteile. Im folgenden Abschnitt lernen Sie die StoreDevtools von NgRx kennen.
@ngrx/store-devtools
Für Anwendungen, die Redux einsetzen, gibt es Erweiterungen für Firefox und Chrome, die Actions und Statusänderungen visualisieren.
So haben Sie stets einen guten Überblick über das Geschehen in Ihrer App. Es ist leicht zu prüfen, ob die Reducer arbeiten, wie erwartet und ob die richtigen Actions versendet wurden.
Nach der Installation taucht in den Developer Tools ein neuer Reiter mit dem Titel Redux auf. Mit einem Klick wird eine dunkle Oberfläche sichtbar, die auf der linken Seite alle versendeten Actions als Log präsentiert.
Wird eine Action ausgewählt, wird auf der rechten Seite ein Diff angezeigt. Es zeigt die Änderung an, die durch die Action im Store hervorgerufen wurde.
Der untere Bereich der DevTools sieht etwas wie ein Musikplayer aus. Dieses Control erlaubt Time-Travel-Debugging. Sie können versendete Actions und die damit einhergegangenen Änderungen zurückspulen. Die zurückgenommen Actions werden in den DevTools ausgegraut.
Sobald Ihr NgRx-Projekt und die Anzahl der Actions wächst, können Sie in Ruhe alle Prozesse analysieren und nachvollziehen.
Neben der Diff-Anzeige ist es auch jederzeit möglich, den gesamten State anzeigen zu lassen. Dazu kann in der oberen, rechten Ecke die Ansicht gewechselt werden.
Die Redux-DevTools bieten auch noch andere Funktionen, wie das Importieren oder Exportieren der Actions. Alle Funktionen sind nur einen Klick entfernt und warten darauf, ausprobiert zu werden.
Die Redux-DevTools wurden unabhängig von NgRx entwickelt. NgRx nutzt die API der DevTools, um diese zu nutzen. Dafür muss StoreDevtoolsModule.instrument() einmalig in einem Modul konfiguriert werden.
Wenn Sie die @ngrx/schematics einsetzen, werden die DevTools automatisch bereitgestellt. Die Standardkonfiguration sieht vor, dass die DevTools nur im Development-Mode von Angular zur Verfügung gestellt werden.
!environment.production
? StoreDevtoolsModule.instrument()
: []
Redux DevTools – Registrierung in app.module.ts
Fazit
Redux im Allgemeinen und NgRx im Speziellen haben zu Beginn eine steile Lernkurve. Der Code ist fragmentierter als zuvor, und es ist nur verständlich, dass es kurzzeitig frustrierend sein könnte.
Mit zentralem StateManagement ist das Entwickler-Team im Leistungssport angekommen und die jeweiligen Disziplinen müssen anfangs trainiert werden. Sobald die Muscle-Memory da ist, können Änderungen und Neuerungen schnell umgesetzt werden. Das liegt nicht zuletzt daran, dass Redux das CQS-Pattern (Command-Query-Segregation) forciert.
Das NgRx-Team hat viel Arbeit in die Typisierung gesteckt, sodass Entwickler schnell darauf aufmerksam werden, wenn Bestandteile falsch miteinander verdrahtet sind. Außerdem helfen Tools wie die @ngrx/schematics oder die @ngrx/store-devtools dabei, Code schnell zu generieren und zur Laufzeit zu analysieren.
Die Tatsache, dass Seiteneffekte isoliert werden, wird zu Anfang häufig etwas unterschätzt. Sie werden schnell feststellen, welch ein Segen es ist, dass Services austauschbarer werden, beziehungsweise schnell neu miteinander verdrahtet werden können, wenn es die Anforderungen vorgeben. Die @ngrx/effects wirken hier wie ein zustandsloser Composition-Layer.
Wenn Sie vor der Entscheidung stehen, ob Sie NgRx einsetzten wollen, bietet sich ein Review des Codes an. Wenn die folgenden Fragen großteils mit “Ja” beantwortet wird, sollte überlegt werden den Schritt zum zentralen State-Management zu machen:
- Ist das Service-Composition-Anti-Pattern bereits stark verbereitet?
- Sind Methoden in Komponenten und Services sehr komplex? (Messbar mit der zyklomatischen Komplexität)
- Sind Services untereinander gekoppelt?
- Existieren mehrere Services die bereits Statusinformationen verwalten?
- Ist die Aggregation verschiedener Datenquellen sehr komplex?
- Wird es zunehmend schwieriger Bestandteile in der Anwendung schnell auszutauschen?
NgRx macht Ihr Projekt definitiv komplexer, verschafft Ihnen und Ihrem Team jedoch eine hohe Flexibilität und stellt einen robusten Architekturunterbau für Ihre Anwendung zur Verfügung.
Gratis E-Book Angular zum Download
Mehr Experten-Tipps für die Arbeit mit Angular finden Sie in unserem E-Book. Jetzt kostenlos downloaden.
- Zentrales State-Management für Angular - 10. September 2020