3 Redux - mertenhanisch/ng-architecture GitHub Wiki
Untertitel: Wie ich es verstanden habe
Redux ist ein Konzept für Application-State-Management. Die Grundbegriffe sind State, Store, Action, Dispatcher, Reducer, Selector und Effect. Eine prominente Implementation ist ngrx. Ich selbst habe zwar viel über ngrx gelesen, hab ein paar kleine Beispiele damit durchprobiert, konnte mich aber nie so richtig dafür erwärmen, weil mir die Lernkurve ein wenig zu steil war. Als Konsequenz daraus ist eine eigene, etwas pragmatischere Implementation des Redux-Konzept entstanden, die einiges nicht ganz so "sauber" umsetzt, dafür aber (hoffentlich) leichter zu erlernen und zu benutzen ist.
Als State wird der aktuelle Zustand der Anwendung bezeichnet. Im State sind die Informationen gespeichert, die die Anwendung braucht, um genau das auf dem Bildschirm anzuzeigen, was sie gerade anzeigen will. Dazu gehören nicht nur Inhalte von Formularfeldern, sondern auch Zustände wie "es wird gerade was geladen/gespeichert" und alles mögliche weitere, was man letztlich im Template einer Component oder in den Daten eines Services gebrauchen kann. Die allerwichtigste Grundeigenschaft des State ist seine Immutability, seine Unveränderlichkeit. Der State ist zwingend readonly (mit ein paar kleinen, pragmatischen Ausnahmen, die später noch erklärt werden). In der Theorie sollte man jederzeit den State serialisieren und irgendwo speichern können, um ihn später wieder laden und aktivieren zu können, um genau an der Stelle in der Anwendung weiter zu arbeiten, wo man aufgehört hat - oder vielleicht sogar auf einem anderen Gerät. Möchte man etwas am State ändern, bedeutet das in erster Linie, dass man nicht den State selbst ändert, sondern einen neuen State aus dem aktuellen erzeugt, wo die gewünschten Änderungen drin enthalten sind. Das gilt nicht nur für primitive Eigenschaften, sondern auch für tiefere Objekte oder Arrays usw.. Nicht nur das Array selbst ist immutable, sondern auch seine Elemente. Einfach alles... Änderungen am State lassen sich am einfachsten mit dem Spread-Operator umsetzen.
interface NestedState {
readonly someProperty: string;
}
interface SomeState {
readonly loading: boolean;
readonly loadError: string;
readonly items: readonly string[];
readonly nested: NestedState;
}
const initialState: SomeState = {
loading: false,
loadError: "",
items: [],
nested: {
someProperty: "nested",
},
};
const nextState1: SomeState = {
...initialState,
loading: true,
items: [...initialState.items, "new item"],
nested: {
...initialState.nested,
someProperty: "changed",
},
};
const itemIndexToDelete = 1;
const nextState2: SomeState = {
...nextState1,
items: [
...nextState1.items.slice(0, itemIndexToDelete,
...nextState1.items.slice(itemIndexToDelete + 1)
],
};
Der Hintergedanke für diese Art, den State zu ändern liegt in der Art und Weise, wie die OnPush-Change-Detection in Angular funktioniert. Dort wird der Input einer Component nur dann als eine Änderungen erkannt, wenn sich die Referenz des verknüpften Objektes verändert. Wäre in diesem Beispiel die Property "nested" als Input an eine Child-Component gebunden, würde das nicht als Änderung dort erkannt werden, wenn die "someProperty" des Objektes direkt durch eine Zuweisung verändert werden würde. Durch das Erstellen eines neuen Objekts aus den Eigenschaften des alten mit Hilfe des Spread-Operators bekommt man eine flache Kopie des ursprünglichen Objekts, wo alle Properties erst mal ihren Inhalt im Sinne der Referenz behalten, während nur die Eigenschaft eine neue Referenz bekommt, die man ändern möchte. Dadurch kann Angular genau erkennen, was im ganze State-Objekt (oder Baum) sich geändert hat. Man tut also gut daran, alle Properties im State als "readonly" zu kennzeichnen, damit Typescript einem mitteilen kann, falls man aus Versehen irgendwo eine Zuweisung vornimmt. Und wichtig ist auch das "readonly" auf der rechten Seite des Doppelpunktes, wenn man dort ein Array definiert.
Der State muss ja irgendwo gespeichert werden. Dafür gibt es den zugehörigen Store. Je nach Implementation kann der Store nicht nur den aktuellen State speichern, sondern auch eine Historie der State-Änderungen. Das eröffnet gute Debug-Möglichkeiten mit den Redux-Dev-Tools im Browser, die einem eine Zeitachse der verschiedenen States und automatisiert auch deren Unterschiede zeigen kann. Dadurch lässt sich leichter erkennen, was und warum in seiner Anwendung passiert oder besser nicht passieren sollte. Anmerkung: Eine Integration der pragmatischen Redux-Implementation in die Redux-Dev-Tools habe ich nicht vorgenommen. Bisher habe ich sie noch nicht vermisst. Es gibt einen Schalter im Store, der einfach (ganz pragmatisch eben) jede State-Änderung in die Console loggen kann. Die Änderungen muss man sich dann selbst heraussuchen, aber bisher hat das immer gereicht. Wenn man einen Store erstellt, braucht dieser immer einen "initial state", mit dem alles beginnt. Passend zum Beispiel oben:
const initialState: SomeState = {
loading: false,
loadError: "",
items: [],
nested: {
someProperty: "nested",
},
};
const store = new Store<SomeState>(initialState);
Eine Action beschreibt eine State-Änderung - sie besteht mindestens aus einem Action-Type (ist in der pragmatischen Umsetzung nicht enthalten, dafür werden einfach die Namen der Funktionen benutzt, die eine Action erstellen) und optional aus einer Payload, die für die Änderung wichtig ist. Eine Action ist also erst mal ein einfaches Datenobjekt.
Der Dispatcher ist der Teil eines Stores, der eine Action entgegennimmt. Er verarbeitet sie mit Hilfe des letzten States und eines Reducers zum nächsten State.
Der Reducer ist (im Normalfall) eine pure Funktion, d.h. eine Funktion ohne Seiteneffekte. Als Argumente bekommt sie einen State und eine Action, der Rückgabetype entspricht dem State-Type. In klassischen Redux-Implementationen ist der Reducer ein langes Switch-Statement, wo anhand des Action-Types (und der ggf. vorhandenen Payload) entschieden wird, was wie im State geändert wird. Der dann neu erzeugte State wird als Ergebnis zurückgegeben. Sollte der Reducer der Meinung sein, nicht für diese Action zuständig zu sein (um dann sowas wie Reducer-Chaining umsetzen zu können), gibt es einfach genau den State zurück, den er bekommen hat. Wenn genau die Objektreferenz des State als Rückgabe in den Store wandert, die an den Reducer gegangen ist, passiert einfach nichts im Store und bei den Komponenten, die auf Änderungen des States lauschen.
Bei der pragmatischen Implementation ist die Action mit dem Reducer zusammengefasst. Es wird als Action nicht einfach nur ein Datenobjekt an den Store dispatched, sondern die Reducer-Funktion gleich dazu.
type Action<TState> = (state: TState) => TState;
const someAction = (payload: SomePayload): Action<SomeState> => {
return (state): SomeState => {
return {
...state,
someProperty: payload.someProperty,
};
};
};
store.dispatch(someAction(somePayload));
An die funktionale Schreibweise muss man sich erst mal ein wenig gewöhnen, aber später fällt einem das gar nicht mehr auf...
Der Selector filtert genau die Änderungen am State heraus, in die der aufrufende Teil der Anwendung interessiert ist. Das kann der komplette State sein (Stichwort Logger) oder aber auch nur eine ganz bestimmte Property des States. Das Ergebnis des Selectors ist ein Observable, welches bei jeder passenden Action, die von irgendwo an den Dispatcher geschickt und von einem Reducer verarbeitet wurde, einen neuen Wert ausspuckt und damit den Observer neues Futter zum Darstellen, Verarbeiten usw. gibt. Beim Selector stehen einem dann die Fülle aller rxjs-Operatoren bereit, um all das damit zu machen, was einem gerade einfällt.
Ein Effect ist eine spezielle Art von Selector. Er bekommt nicht nur die ihn interessierenden Änderungen am State mit, sondern zusätzlich auch einen Dispatcher, mit dem er neue Actions an den Store schicken kann. Das passiert meist in asynchroner Art. Ein übliches Schema kann das Laden von Informationen aus dem Backend sein.
interface SomeRequest {
...
}
interface SomeResponse {
...
}
interface SomeService {
getResponse: (request: SomeRequest) => SomeResponse;
}
interface SomeState {
readonly loading: boolean;
readonly request: SomeRequest | null;
readonly response: SomeResponse | null;
readonly errorMessage: string | null;
}
const loadResponse = (request: SomeRequest): Action<SomeState> => {
return (state): SomeState => {
return {
...state,
loading: true,
request: request,
response: null,
errorMessage: null,
};
};
};
const responseLoaded = (response: SomeResponse): Action<SomeState> => {
return (state): SomeState => {
return {
...state,
loading: false,
response: response,
};
};
};
const loadFailed = (errorMessage: string): Action<SomeState> => {
return (state): SomeState => {
return {
...state,
loading: false,
errorMessage: errorMessage,
};
};
const setError = (errorMessage: string): Action<SomeState> => {
return (state): SomeState => {
return {
...state,
errorMessage: errorMessage,
};
};
};
const responseLoader = (store: Store<SomeState>, service: SomeService): Subscription => {
return store.stateChanges
.pipe(
filter((s) => s.loading),
map((s) => s.request),
switchMap((request) => someService.getResponse(request)
.pipe(catchError((error: any) => {
store.dispatch(loadFailed(error));
return EMPTY;
}))))
.subscribe({
next: (response) => {
store.dispatch(responseLoaded(response));
},
error: (error: any) => {
console.error("responseLoader, unexpected error:", error);
store.dispatch(setError(error));
},
});
};
const someService = ...;
const store = new Store<SomeState>(initialState);
const logger = store.stateChanges
.pipe(map(console.log))
.subscribe({});
const subscription = responseLoader(store, someService);
const request = ...;
store.dispatch(loadReponse(request));
Statt direkt die loadResponse-Action zu dispatchen, kann diese z.B. aus den Query-Parametern der aktuellen Route generiert werden. Da die "ActivatedRoute.queryParamMap` selbst ein Observable ist, lässt sich das wunderbar miteinander verknüpfen. Man beachte das catchError an dem Service-Aufruf, der dafür sorgt, dass das äußere Observable durch einen möglichen Fehler beim Abrufen der Daten (vom Backend) im inneren Observable nicht aus dem Tritt gebracht wird. Man kann dort die Gelegenheit nutzen und diesen Fehler so im State unterbringen, dass dem Benutzer eine passende Fehlermeldung angezeigt wird.
Ja. Vernünftiges State-Management macht sich erst dann wirklich bezahlt, wenn man größere, komplexere Components hat, die eine Menge State zu verwalten haben. Das kommt in kleinen Beispielen nicht immer so gut rüber. Ein Hauptvorteil von dieser Art State-Management ist, dass durch eine Action mehrere Properties im State ihren Wert ändern können und diese als eine Änderung im Observer der State-Änderungen ankommt. Dadurch lassen sich komplexere Szenarien in dynamischen Templates leichter koordinieren. Das zeigt sich, wenn man die ersten größeren Dinge mit diesem Konzept umsetzt.
Bei ngrx wird der Store über Dependency Injection in eine Component injiziert. Es gibt in der ganzen Anwendung eigentlich auch nur einen einzigen Store und einen großen, globalen App-State. Dadurch ist es relativ unübersichtlich und aufwendig, diesen vernünftig zu definieren und aufzusetzen, Stichwort "Feature-Store". Darauf möchte ich jetzt nicht eingehen (weil ich das auch nie wirklich durchgezogen bekommen habe). Action und Reducer sind voneinander getrennt, was es in meinen Augen etwas unübersichtlich macht, wenn man wissen will, was eine Action eigentlich so genau auslösen kann. Der "pragmatische Store" kann jederzeit von jeder Component per "new" mit der Art von State erzeugt werden, wie er benötigt wird. An der Action-Funktion sieht man nicht nur die mögliche Payload, sondern auch gleich, wie diese mit dem State verarbeitet wird. Letztlich gibt es damit viele Stores über die ganze App verteilt, wodurch man zwar keinen Gesamtstate irgendwo sehen bzw. speichern und wieder laden kann, aber im Grunde sollte eine Component alle wichtigen Informationen ihres State sowieso aus der Route ableiten können. Alternativ kann man auch immer noch einen Service erstellen, der selbst einen Store enthält und damit eine Lebenszeit bieten kann, die über einzelne Components hinaus geht, z.B. Informationen über den gerade angemeldeten Benutzer usw.. Der Fantasie sind da keine Grenzen gesetzt...