Warum sich ein Blick auf ein zentrales State-Management lohnt.

In Angular leben die Daten in einer komponentenzentrierten Welt. Die Daten werden in der Komponente gespeichert, und die Komponenten sind isoliert voneinander, sodass diese Dreh- und Angelpunkt der Daten sind. Möchte man dann auf die Daten zugreifen, kommt man nur mit Hilfe der Komponente heran. Gut, oder? Jede Komponente kümmert sich nur um die Daten, die es selbst betrifft. Das hört sich doch eigentlich nach sauberer Programmierung an.

Wenn nicht folgendes wäre … Was ist, wenn die Komponenten gar nicht so isoliert voneinander leben wie gedacht?  Was ist, wenn die Komponenten sich wie ein Team verhalten: Jeder hat zwar seinen Verantwortungsbereich, aber es müssen Informationen unter den Teammitgliedern ausgetauscht werden.

Eine klassische Antwort könnte so lauten: „@Input(), @Output()“ und dazu noch diese Visualisierung:

Eine Komponente Book-List bekommt ein neues Buch hinzugefügt und gibt die Buchliste als Event weiter. Dann wird die Liste von der Toolbar-Komponente empfangen, und in der Toolbar die Anzahl der Bücher dargestellt.

Sieht nicht schlecht aus, oder? Problem gelöst! Das ist auch der Fall, wenn der Komponentenbaum so aussieht, wie in unserem Beispiel. Was ist aber, wenn er weitaus komplizierter wird? Oder um es mit den Worten vom Schachweltmeister Mikhail Tal zu sagen:

„You must take your opponent into a deep dark forest where 2+2=5“

In manchen Fällen verlangt die Anwendung von uns, einen Komponentenbaum zu konstruieren, der weitaus verschachtelter ist als der, den wir im Beispiel gesehen haben (gefühlt: ein „deep dark forest“). Dann kann die scheinbar kleine Aufgabe, ein Event von A nach B zu schicken, nur mit viel Konzentration und einigen Bugs später gelöst werden. Hinzu kommt, dass es nicht nur bei diesem einen Kommunikationsweg bleibt, also von A nach B, sondern bald wollen auch Komponente C, D und E mitreden. Und spätestens ab diesem Zeitpunkt brauchen wir ein anderes Konzept. Wir brauchen eine bessere Lösung, als durch den „deep dark forest“ durchzulaufen.

Redux – Konzept eines zentralen State Management

Nachdem wir also gesehen haben, dass es auf lange Sicht nicht sinnvoll ist, die Daten in einer einzelnen Komponente zu speichern, sehen wir uns das Architekturmodell Redux an. Wir werden dazu ein eigenes Modell erstellen und dieses sukzessiv mit Ideen anreichern. Beginnen wir mit dem ersten Minimalbeispiel, das den Code enthält, falls wir uns dazu entscheiden sollten, die Daten über die Elternkomponente an die andere Kindeskomponente weiterzuleiten.

Für diejenigen, die gerne den Code selbst ausprobieren wollen. Hier der Link zum entsprechenden GitHub-Repository. Der Code zu den einzelnen Ideen kann zum Nachvollziehen ausgecheckt werden.

Idee Nummer Null: ohne Redux – the good old way

Stellen wir uns vor, wir haben eine Buchliste und eine Toolbar, die anzeigt, wie viele Bücher die Liste beinhaltet. Zusätzlich gibt es noch einen Button „Buch hinzufügen“. Drückt man auf ihn, wird die Buchliste um ein neues Buch erweitert. Der Code für book-list.ts sieht so aus.

@Component({
  selector: 'rb-book-list',
  template: `
    <div *ngFor="let book of books">{{ book }}</div>
    <button (click)="addBook()">Buch hinzufügen</button>
  `,
})
export class BookListComponent implements OnInit {
  @Output() newBookEvent = new EventEmitter<string[]>();
  books: string[] = [];

  constructor() {}

  ngOnInit(): void {}

  addBook(): void {
    this.books.push('new book');
    this.newBookEvent.emit(this.books);
  }
}

Nachdem der Button „Buch hinzufügen“ gedrückt wurde, wird das Event weitergeleitet an die Elternkomponente book-site.ts:

@Component({
  selector: 'rb-book-site',
  template: `
    <rb-toolbar [books]="books"></rb-toolbar>
    <rb-book-list (newBookEvent)="passBook($event)"></rb-book-list>
  `,
})
export class BookSiteComponent implements OnInit {
  books: string[] = [];
  constructor() {}

  ngOnInit(): void {}

  passBook(books: string[]): void {
    this.books = books;
  }
}

Mit Hilfe von Property-Binding gibt dann die Elternkomponente wiederum das Buch weiter an die toolbar.ts:

@Component({
  selector: 'rb-toolbar',
  template: `
    {{ books.length }}
    <hr />
  `,
})
export class ToolbarComponent implements OnInit {
  @Input() books: string[] = [];

  constructor() {}

  ngOnInit(): void {}
}

Soweit so gut. Allerdings merken wir an dieser Stelle schon, dass es sehr sperrig wird. Denn jede Elternkomponente muss die Daten, die es bekommt, weiterleiten. Hier immer den Überblick zu bewahren, kann sehr knifflig sein. Kommen wir deswegen zu …

Idee Nummer Eins: Zentralisierung

Das erste, was wir machen möchten, ist die Hoheit von der Komponente auf einen zentralen Ort zu übertragen, nämlich den „Store“. Dazu erstellen wir einen Store, den wir als Service state.service.ts implementieren:

export class StateService {
  state: BookState = {
    books: []
  };

  addBook() {
    this.state.books.push("new book");
  }
}

export interface BookState {
  books: string[]
}

In Zukunft wird also der Store dafür zuständig sein, das Buch hinzuzufügen. In der book-list.ts selbst steht dann nur:

@Component({/* wie vorher … */})
export class BookListComponent implements OnInit {
  books: string[] = [];

  constructor(private service: StateService) {}

  ngOnInit(): void {}

  addBook(): void {
    this.service.addBook();
    this.books = this.service.state.books;
  }
}

In der Toolbar greifen wir auch mit Hilfe des Services auf die aktuelle Buchliste zu (Ja, Injizierungen sollten idealerweise private sein und nicht public, wir vernachlässigen das an dieser Stelle der Einfachheit halber).

@Component({
  selector: 'rb-toolbar',
  template: `
    {{ service.state.books.length }}
    <hr />
  `,
})
export class ToolbarComponent implements OnInit {
  books: string[] = [];

  constructor(public service: StateService) {}

  ngOnInit(): void {}
}

Die Elternkomponente book-site.ts enthält danach keinen Code mehr, der die Logik betrifft, sondern im Template steht nur noch:

<rb-toolbar></rb-toolbar>
<rb-book-list></rb-book-list>

Was haben wir bis jetzt erreicht?

Dadurch, dass wir das Objekt „books“ an einen zentralen Ort ausgelagert haben, ist es für den Datenaustausch nicht mehr nötig zu wissen, wie der Komponentenbaum aussieht. Alle Komponenten können jetzt über den zentralen Service miteinander kommunizieren.

Was wünschen wir uns noch?

Aktuell funktioniert der Code nur deswegen, weil eine Änderung der Daten die ChangeDetection von Angular triggert. Wir möchten jedoch die Routinen auch selbst anstoßen können und somit noch etwas mehr Unabhängigkeit gewinnen. Oder in anderen Worten: Wir wollen Reaktivität.

Idee Nummer zwei: Reaktivität für noch mehr Kontrolle

Dafür werden wir ein BehaviorSubject im Service state.service.ts einführen. Im Vergleich zu einem normalen Subject kann ein BehaviorSubject auch den aktuellen Zustand speichern. Danach werden wir die Variable state auf private setzen, damit in Zukunft nur über das BehaviorSubject auf den State zugegriffen werden kann. Damit sieht die Datei state.service.ts so aus:

export class StateService {
  private state: BookState = {
    books: [],
  };

  state$ = new BehaviorSubject<BookState>(this.state);

  addBook() {
    this.state.books.push('new book');
    this.state$.next(this.state);
  }
}

In der Datei book-list.ts wird dann die Zeile

books: string[] = []; 

ersetzt durch

books$ = this.service.state$.pipe(map((state) => state.books));

und die Zeile

this.books = this.service.state.books;

aus der Funktion addBook() gelöscht. Zum Schluss wird die Async-Pipe im zugehörigen Template verwendet, und die Datei book-list.ts ist vorerst wieder fertig. Analog verändern wir auch die toolbar.ts. Das Ergebnis:

@Component({
  selector: 'rb-toolbar',
  template: `
    {{ booksLength$ | async }}
    <hr />
  `,
})
export class ToolbarComponent implements OnInit {
  booksLength$ = this.service.state$.pipe(map((state) => state.books.length));

  constructor(private service: StateService) {}

  ngOnInit(): void {}
}

Mit dem reaktiven Ansatz gewinnen wir wieder etwas: Jetzt teilen sich die Komponenten nicht nur die Daten in einem zentralen State, sondern sie können reaktiv reagieren, wenn Änderungen geschehen. Außerdem sind wir nicht nur auf die integrierte ChangeDetection angewiesen, sondern können selbst neue Routinen definieren.

Aber: Was wünschen wir uns noch?

Bis jetzt werden Änderungen noch direkt in der Variable state vorgenommen. Stellen wir uns vor, wir wollen jedes Mal, wenn state im StateService verändert wird, darauf reagieren. Doch woher können wir wissen, dass die Variable state sich tatsächlich geändert hat? Eine Strategie wäre, eine Kopie vom alten state zu speichern und ihn mit dem neuen state zu vergleichen. Simpel, oder? Naja, wenn der state nur aus einer Property besteht, dann auf jeden Fall. Wenn er aber mehr als nur eine hat und eine Property mehrere Properties hat und diese evtl. noch weitere (Nested Objects), dann sieht die Sache schon etwas anders aus. Wir müssten diese Struktur mit der alten abgleichen, und das kann schon etwas länger gehen, weil wir jede Property auf Veränderung abtasten müssten. Das können wir zwar tun, aber so wirklich effizient kann man das nicht nennen. Gäbe es doch nur einen Weg, mit dem man mit einem einzigen Vergleich feststellen könnte, ob sich etwas verändert hat …

Idee Nummer drei: Immutables

Wie können Immutables das vorher angesprochene Problem lösen? Ein Immutable kann, wie der Name schon sagt, nicht verändert werden. Wenn wir also für die Variable state ein Property verändern, können wir das nur, wenn wir das ganze Objekt austauschen. Dadurch ändert sich auch die Referenz, weil wir ein neues Objekt haben. Das heißt nachzuprüfen, ob sich der state geändert hat, reduziert sich auf den Vergleich der neuen Referenz mit der alten Referenz. Hat diese sich geändert, hat sich auch das Objekt geändert. Und damit ist das Problem gelöst!

In JavaScript kann man dazu die Bibliotheken „Immutable.js“ oder auch „Immer“ benutzen. Allerdings reicht es in unserem Fall auch, wenn wir nur so tun, als ob das Objekt unveränderlich ist. Das bedeutet wir achten beim Programmieren darauf, das Objekt nicht zu verändern. Wie können wir das erreichen? Der Spread-Operator ist das Schlagwort hier.

Nochmal die state.service.ts, diesmal mit Pseudo-Immutables:

  addBook() {
    this.state = {
      ...this.state,
      books: [...this.state.books, 'new book'],
    };
    this.state$.next(this.state);
  }

Und das war es schon.

So weit so gut. Wir wollen jetzt einen weiteren Schritt gehen und unsere Anwendung noch mehr voneinander entkoppeln. Aktuell greifen wir in der Komponente direkt auf die Funktion addBook() im StateService zu. Wir möchten jedoch, dass die Komponente eine Nachricht an den StateService sendet, und der StateService darf dann selbst entscheiden, was er mit dieser Nachricht macht. Nennen wir mal diese Nachrichten „Actions“, was uns auch direkt zu Idee Nummer Vier führt.

Idee Nummer Vier: Actions und Auslagerung der Berechnung

Um die Entkoppelung vom StateService und Komponente zu erreichen, bedarf es nur eines simplen Tricks. Statt auf die addBook-Funktion im Service zurückzugreifen, greifen wir auf die dispatch-Funktion zu. Diese macht nichts anderes, als die Nachricht entgegenzunehmen – mehr nicht. Was aus dieser Nachricht gemacht wird, ist dem StateService selbst überlassen. Die book-list.ts sieht dann so aus.

@Component({/* … */})
export class BookListComponent implements OnInit {
  
/* wie vorher  … */

  addBook(): void {
    this.service.dispatch("ADD BOOK");
  }
}

Bevor wir zeigen, wie der Code für den StateService aussieht, wollen wir noch eine strukturelle Änderung vornehmen. Berechnung und Lagerung der Daten sind momentan im selben Block geschrieben, wir wollen diese zwei Verantwortlichkeiten separieren, indem wir die Berechnung in eine Funktion auslagern. Diese Funktion nennen wir dann calculateState. Optimalerweise wäre sie auch in einer anderen .ts untergebracht, aber der Einfachheit halber, wird sie nur außerhalb der Klasse definiert.

export class StateService {
 /* … */
  dispatch(message: string) {
    this.state = calculateState(this.state, message);
    this.state$.next(this.state);
  }
}

export function calculateState(state: BookState, message: string): BookState {
  switch (message) {
    case 'ADD BOOK': {
      return { ...state, books: [...state.books, 'new book'] };
    }
    case 'DELETE BOOK': {
      return { ...state, books: state.books.slice(0, state.books.length - 2) };
    }
    default:
      return state;
  }
}

Durch die Entkoppelung können wir in der Funktion calculateState() sogar Actions definieren, die aktuell in keiner Komponente zu finden sind. Andersherum kann auch eine Komponente Actions verwenden, deren konkrete Auswirkung noch nicht bekannt ist. Denn falls die Action nicht bekannt ist, wird an der aktuellen Variablen state nichts verändert.

Idee Nummer 5: Reproduzierbarkeit des States

Wir wollen unserem System noch eine letzte Eigenschaft hinzufügen, nämlich die Reproduzierbarkeit des States anhand der ankommenden Nachrichten. Das führt dazu, dass unser bisheriges System noch robuster wird und einfacher zu testen. Betrachten wir die jetzige Funktion dispatch, so arbeitet sie unserem Ziel entgegen, weil sie gleichzeitig den State berechnet und ändert. Wir werden also zwei Veränderungen vornehmen:

  • Die Funktion dispatch() ändert nicht mehr die Variable state, sondern fügt einem Datenstrom messages$ eine Nachricht message hinzu.
  • Die Variable state reagiert auf den Datenstrom messages$ und ändert state nur noch durch eine Reducer-Funktion, die eine Pure-Function ist.

Das Ergebnis der state.service.ts sieht dann so aus:

export class StateService {
  private state: BookState = {
    books: [],
  };

  messages$ = new Subject<string>();

  state$ = this.messages$.pipe(
    startWith('initial message'),
    scan(calculateState, this.state),
    shareReplay(1)
  );

  dispatch(message: string) {
    this.messages$.next(message);
  }
}

Wir haben den Datenstrom state$ noch mit einem shareReplay(1) ausgestattet, um den letzten Wert des States mit allen Subscribern zu teilen. Weiterhin haben wir den Datenstrom state$ eine initialen Wert mitgegeben, der allerdings keine Veränderung anstößt, sondern lediglich dafür sorgt, dass ein initialer Wert schon vorhanden ist. Das führt dazu, dass die Toolbar beim Start der Anwendung eine Null anzeigt, weil noch keine Bücher vorhanden sind.

Zusammenfassung

Unser finales Modell können wir mit diesem Schaubild visualisieren:

Fazit:

Wir haben in diesem Blog-Post mit den herkömmlichen Angular-Mitteln das Konzept von Redux umgesetzt, und wir verstehen, dass mehrere Ideen nötig waren, um unseren jetzigen Stand zu erreichen. Doch das Ergebnis lohnt sich. Jetzt können wir:

  • Daten zwischenspeichern, die über HTTP abgerufen worden sind.
  • Daten zwischen verschiedenen Komponenten speichern, ohne den Komponentenbaum zu berücksichtigen.
  • Auf verschiedene Ereignisse reagieren, indem wir neue Routinen einführen.
  • Eine Garantie geben, dass die gleichen Nachrichten den gleichen Output erzeugen.

Und das wäre nicht ohne Weiteres ohne dieses Konzept möglich gewesen. Allerdings ist auch klar, dass 5 Ideen umzusetzen nicht trivial ist. Deswegen ist es ratsam, sich zu überlegen, ob denn die Anwendung das auch wirklich fordert. Manchmal können die Komponenten auch ganz gut isoliert voneinander leben 😉.

Ausblick:

Für diejenigen, die das Konzept anwenden möchten, stellt sich vielleicht die Frage: „Das ist zwar alles sehr nett, aber gibt es nicht einen anderen Weg, als es selbst händisch zu programmieren? Gibt es nicht ein Framework für so etwas?“ Und tatsächlich: Ja, gibt es – NgRx. Aber das ist ein Thema für einen anderen Beitrag.

Views All Time
312
Views Today
4
Short URL for this post: https://blog.oio.de/nWMNI
This entry was posted in Web as a Platform and tagged , . Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *