
Motivation
Webapplikationen müssen häufig Informationen aus verschiedenen Quellen anzeigen. Über die reine Darstellung hinaus unterstützen diese Anwendungen typischerweise das Aktualisieren, Erweitern und Löschen von Daten. Um solche Funktionalität zu ermöglichen, müssen Daten auf der Client-Seite vorgehalten werden – zusammengefasst als State der Anwendung.
Eine zentrale Herausforderung besteht darin, dass Anfragen an verschiedene Quellen gesendet werden und die Antworten zu unvorhersehbaren Zeitpunkten eintreffen. Komponenten müssen sich mit neuen Daten aktualisieren und neu rendern, was einen gut strukturierten Datenfluss erfordert. Zusätzlich erscheinen dieselben Informationen oft an mehreren Stellen der Anwendung, was durchdachte Verteilungsstrategien ohne Duplikation oder Kontrollverlust erfordert.
State Management Frameworks werden bei solchen Herausforderungen unverzichtbar. Dieser Artikel erklärt ein solches Framework am Beispiel von Akita. Akita entstand aus dem Angular-Ökosystem und baut auf Ideen von Frameworks wie NgRx auf. Obwohl die Code-Beispiele aus Angular-Anwendungen stammen, ist Akita selbst framework-agnostisch und funktioniert gleichermaßen mit React und Vue.js. Im Vergleich zu anderen State Management Frameworks erfordert die Akita-Implementierung deutlich weniger Boilerplate-Code und bietet ein einfacheres Onboarding.
Funktionsweise
Das State Management Framework von Akita dreht sich um Stores, die client-relevante Daten verwalten und als “Frontend-Repositories” fungieren. Typischerweise verwaltet ein Store eine einzelne Entität, wobei Anwendungen mehrere Stores umfassen. Jeder Store definiert Queries, die den Datenzugriff innerhalb von Komponenten ermöglichen und so Informationen für Benutzer sichtbar machen. Dieser Datenfluss funktioniert über asynchrone Streams aus dem RxJS Framework , wodurch Store-Aktualisierungen sofort an Komponenten mit sofortigem Re-Rendering weitergeleitet werden.
Ein Service füllt den Store initial mit Daten und ist die einzige Instanz, die den Store-State verändern darf. Dieser Service behandelt auch asynchrone Aufrufe an externe Systeme und leitet Informationen an den Store weiter. Der Service stellt Komponenten Methoden zur Verfügung, die es Benutzern ermöglichen, Store-Daten zu modifizieren.
Diese Komponenten und Prozesse bilden die Grundlage des Akita-Patterns, wobei der unidirektionale Datenfluss entscheidend für die Transparenz der Anwendung ist. Das Entwicklungsteam von Akita definiert vier High-Level-Prinzipien, denen Entwickler folgen sollten:
High-Level Prinzipien
- Ein Store repräsentiert ein einzelnes Objekt mit dem aktuellen State und fungiert als “Single Source of Truth”
- Der Store-State ändert sich nur über eine einzige Methode:
setState() - Komponenten greifen auf Stores über vordefinierte Queries zu, niemals direkt
- Store-Updates und asynchrone Logik sollten in Services gekapselt werden
Bestandteile von Akita
Um die Funktionalität von Akita zu demonstrieren, wurde eine beispielhafte “Player-Manager”-Anwendung erstellt – eine einfache CRUD-Anwendung (Create Read Update Delete) zur Verwaltung von Spielerlisten. Benutzer können Spieler hinzufügen, löschen und deren Bewertungen anpassen. Der vollständige Code ist auf Github verfügbar.
Model
export interface Player {
id: ID;
name: string;
rating: number;
}
Das zentrale Modell der Anwendung ist unkompliziert. Die Attribute name und rating beschreiben die verwalteten
Spieler; das id-Attribut dient zur Identifikation. Um die Vorteile von Akita voll auszuschöpfen, müssen Modelle
eindeutige IDs besitzen. Das ID-Interface wird von Akita selbst bereitgestellt.
Store
@StoreConfig({
name: 'players'
})
export class PlayersStore extends EntityStore<PlayersState, Player> {
constructor() {
super(initialState);
}
}
Um Spielerinformationen zu verwalten, implementieren wir einen Store, der Akitas bereitgestellte EntityStore-Klasse erweitert. Vereinfacht gesagt funktioniert EntityStore wie eine Datenbanktabelle. Im Vergleich zu Akitas Standard-Store-Klasse bietet EntityStore eingebaute CRUD-Methoden, die die Entitätsverwaltung vereinfachen und Boilerplate-Code eliminieren. Beim Erben von EntityStore müssen sowohl das verwaltete State-Modell (PlayersState) als auch das verwaltete Entitäts-Modell (Player) definiert werden. Der angegebene State muss Akitas EntityState-Interface erweitern.
export interface PlayersState extends EntityState<Player> {}
Um den PlayerStore zu verwenden, ruft man einfach den Konstruktor von EntityStore mit dem initialen State auf. Unter der
Annahme, dass der PlayerStore beim Anwendungsstart keine Objekte enthält, initialisieren wir die initialState-Variable
als leeres Objekt:
const initialState: PlayersState = {};
Der @StoreConfig-Dekorator ermöglicht die Konfiguration von Store-Eigenschaften wie Namen, was wichtig wird, wenn
Anwendungen mit zusätzlichen Stores wachsen. Keine weitere PlayerStore-Implementierung ist erforderlich; er kann nun in
einen Service integriert werden. Durch die EntityStore-Erweiterung bietet PlayersStore nun Methoden wie add, update,
upsert und remove.
Service
export class PlayersService {
constructor(private store: PlayersStore,
private http: PlayersHttpService) {
}
retrieveAll(): void {
this.http
.retrieveAll()
.pipe(take(1))
.subscribe(players => this.store.add(players));
}
add(players: Player[]): void {
this.http
.add(players)
.pipe(take(1))
.subscribe(addedPlayers => this.store.add(addedPlayers));
}
remove(id: string): void {
this.http
.remove(id)
.pipe(take(1))
.subscribe(() => this.store.remove(id));
}
updateRating(id: string, rating: number): void {
this.http
.update(id, rating)
.pipe(take(1))
.subscribe(updatedRating => this.store.update(id, {rating: updatedRating}));
}
}
Der Service bietet eine Schnittstelle zum Store und ermöglicht es UI-Komponenten, State-Änderungen auszulösen. Obwohl UI-Komponenten den Store direkt aufrufen könnten, würde dies Akitas High-Level-Prinzipien verletzen. Zusätzlich kapseln Services bequem die asynchrone Verarbeitung. PlayersService greift auf PlayersHttpService zu, der die API-Kommunikation übernimmt. Zu Demonstrationszwecken ist PlayersHttpService ein Dummy, der Dummy-Daten zurückgibt. Die Methoden von PlayersHttpService geben Observables zurück, die Zugriff auf API-Aufruf-Daten bieten. Wer mit Observables nicht vertraut ist, sollte RxJS-Tutorials konsultieren (z.B. Tutorial RxJS Framework ). Kurz gefasst: Observables geben Daten asynchron über Streams zurück.
Die Methode retrieveAll() betrachtet: Sie ruft die retrieveAll()-Methode von PlayersHttpService auf, die ein
Player[]-Observable zurückgibt. subscribe() registriert sich beim Observable und definiert, dass store.add() mit
dem empfangenen players-Objekt ausgeführt wird. Einfach ausgedrückt: Wir machen einen REST-Call, der alle Spieler von
der API anfordert. Bei Empfang der Antwort speichern wir die zurückgegebenen Spieler in unserem Store.
Die anderen Methoden bieten zusätzliche CRUD-Service-Funktionalität. Die hier aufgerufenen Store-Methoden erforderten keine eigene Entwicklung, da sie von EntityStore bereitgestellt werden. Akita spart erheblichen Boilerplate-Code im Vergleich zu alternativen State Management Frameworks, was die Entwicklung angenehm macht.
Der pipe(take(1))-Aufruf stammt von RxJS und besagt, dass wir uns nach dem Empfang des ersten Objekts abmelden.
Query
export class PlayersQuery extends QueryEntity<PlayersState> {
constructor(protected store: PlayersStore) {
super(store);
}
}
Als letzte Akita-Implementierungskomponente benötigen wir einen Service, der Store-Daten bereitstellt. Wenig
Implementierung ist erforderlich, da QueryEntity, unsere Vererbungsquelle, viele nützliche Methoden enthält. Eine
vorimplementierte Methode ist getAll, die alle gespeicherten Spieler als Array zurückgibt. Zusätzlich existiert
selectAll, die ein Observable-Spieler-Array zurückgibt. Die Arbeit mit Observables bietet Vorteile: Man wird
benachrichtigt, wenn sich die abgefragten Quelldaten ändern. Durch die Verwendung von PlayersQuerys select-Methoden,
die Observables zurückgeben, zeigt man kontinuierlich aktuelle Store-Daten an, ohne PlayersQuery-Methoden erneut
aufrufen zu müssen.
Einbindung in die Komponenten
@Component({
selector: 'manage-players',
templateUrl: './manage-players.component.html',
styleUrls: ['./manage-players.component.scss']
})
export class ManagePlayersComponent {
playerForm: FormGroup;
allPlayers$: Observable<Player[]>;
constructor(private playersQuery: PlayersQuery,
private playersService: PlayersService,
private formBuilder: FormBuilder) {
this.initFormGroup();
this.allPlayers$ = this.playersQuery.selectAll();
}
addPlayer(): void {
const name = this.playerForm.value.name;
if (name) {
this.playersService.add([createPlayer(name, 0)]);
this.playerForm.reset();
}
}
updatePlayerRating(playerId: string, rating: number): void {
this.playersService.updateRating(playerId, rating);
}
removePlayer(playerId: string): void {
this.playersService.remove(playerId);
}
...
}
Mit allen Akita-spezifischen Komponenten implementiert, wird das State Management in UI-Komponenten integriert. Zuerst
werden PlayersQuery und PlayersService über den Konstruktor injiziert. Die selectAll()-Methode von PlayersQuery wird
aufgerufen, um ein Observable aller Player-Objekte im Store zu erhalten. Dieses Observable wird in der Variable
allPlayers$ gespeichert, auf die das HTML-Template der Komponente zugreift. Nach der Initialisierung werden
Spielerinformationen in einer Liste angezeigt. Über den Konstruktor hinaus bietet die Komponente die Methoden
addPlayer, updatePlayerRating und removePlayer, die durch Benutzerklicks ausgelöst werden. Wenn removePlayer
ausgeführt wird, ruft die UI-Komponente die remove-Methode von PlayersService auf, die das referenzierte Player-Objekt
aus dem PlayersStore entfernt. Folglich aktualisiert sich das allPlayers$-Observable und erhält eine neue Spielerliste
ohne den gelöschten Spieler. Das Komponenten-Template rendert dann mit der neuen Spielerliste. Die Methoden addPlayer
und updatePlayerRating ermöglichen das Hinzufügen neuer Spieler oder die Änderung bestehender Spielerinformationen und
vervollständigen die CRUD-Anwendung. Den vollständigen Code inklusive aller Templates finden Sie im
Github-Projekt
.
Devtools
Frontend-Entwickler wissen, dass Debugging herausfordernd sein kann. Akita enthält jedoch das Redux DevTools Browser-Plugin, das die Fehlererkennung erheblich vereinfacht. Redux DevTools zeigt jederzeit den aktuellen Store-Inhalt an und pflegt eine vollständige Historie aller Store-Datenänderungen. Dies ermöglicht die präzise Nachverfolgung, wann und wie sich Store-Daten geändert haben, was die Akita-Entwicklung sehr transparent macht. Das Plugin ist verfügbar für Chrome und Firefox .
Ausblick
Akita bietet viele Aspekte, die über den Rahmen dieses Artikels hinausgehen. Reale Anwendungen implementieren wahrscheinlich mehrere Stores mit gegenseitigen Beziehungen, was die Berücksichtigung von Datennormalisierung erfordert – erreichbar mit Akita. Mit wachsenden Anwendungen steigt die Komplexität der Query-Klassen, was möglicherweise separate Tutorials rechtfertigt. Wer nach Akita-Informationen zu diesen und verwandten Themen sucht, sollte die offizielle Akita-Dokumentation konsultieren, die in Struktur und Vollständigkeit überzeugt. Weitere Einstiegspunkte und Tutorials finden sich in den unten aufgeführten Quellen.

