Das Wissensportal für IT-Professionals. Entdecke die Tiefe und Breite unseres IT-Contents in exklusiven Themenchannels und Magazinmarken.

SIGS DATACOM GmbH

Lindlaustraße 2c, 53842 Troisdorf

Tel: +49 (0)2241/2341-100

kundenservice@sigs-datacom.de

Konzepte für Web-Frontends

„Das Frontend ist ja dann trivial“ – diesen Satz hat man schon oft gehört, und zu oft hat er sich nicht bewahrheitet. Zu verstehen, was wann in welchem Zustand an der Oberfläche ist und wer für welche Änderung verantwortlich ist, kann schnell schwierig bis unmöglich werden. Um langfristig wartbare, erweiterbare und robuste Clients zu bauen, bedarf es daher guter Konzepte. Dieser Artikel stellt die hierbei wichtigsten Prinzipien vor: Komponenten, unidirektionaler Datenfluss und das Push-Prinzip.

  • 21.06.2019
  • Lesezeit: 14 Minuten
  • 76 Views

Gerade zunehmend komplexer werdende Webanwendungen haben mit dem eingangs genannten Problem der Benutzeroberfläche zu kämpfen. Die Browser werden immer leistungsstärker, ihre Programmierschnittstelle immer umfangreicher. Um eine gute User-Experience (UX) zu gewährleisten, wird außerdem zunehmend mehr Logik in das Frontend verlagert. Wo es vor zehn Jahren noch ein bisschen HTML und CSS getan haben, sind heute mit Single-Page Applications, Progressive Web Apps und den immer zahlreicheren Möglichkeiten der Browser ganz andere Anforderungen vorhanden.

Dieser Artikel stellt die wichtigsten Prinzipien vor, um langfristig wartbare, erweiterbare und robuste Clients zu bauen: Komponenten, unidirektionaler Datenfluss und das Push-Prinzip. Die Prinzipien werden anhand eines Beispiels veranschaulicht. Sie lassen sich auf Web-Frontends allgemein und zunehmend Frontends mit anderen Technologien übertragen (siehe auch Kasten 1).

Kasten 1: Erläuterung zu konkreten Web-Frameworks

Komponenten – das Fundament der UI

UI-Komponenten (im Folgenden immer mit „Komponenten“ abgekürzt) sind UI-nahe Elemente, die letztlich die Anwendung visuell darstellen. Komponenten können wiederverwendet und ineinander geschachtelt werden. Sie haben außerdem fest definierte Schnittstellen, worüber sie mit anderen Komponenten kommunizieren können. Ein sauberer Schnitt von Komponenten und eine klare fachliche und technische Trennung sind die Grundlage für ein modulares Web-Frontend.

Eine Komponente kann aus insgesamt fünf Teilen bestehen.

  • Die Anzeigelogik bestimmt, welches Element wann und wie angezeigt wird. Das HTML und das CSS beschreiben, wie die Komponente aussieht. Abhängig vom Zustand können zudem Elemente modifiziert angezeigt oder ausgeblendet werden. So kann zum Beispiel dargestellt werden, dass ein Tab abhängig vom Anzeigezustand offen oder geschlossen ist.
  • Kompositionslogik: Die Komponente vereint mehrere Komponenten unter sich und kümmert sich um ihr Zusammenspiel. Eine Komponente, die von einer anderen Komponente verwaltet wird, heißt in deren Kontext Kindkomponente.
  • Interaktion mit anderen Schichten: Eine Komponente kann mit Services interagieren, und so andere Prozesse wie etwa eine Backend-Interaktion anstoßen. Wichtig: Die Komponente stößt einen solchen Prozess lediglich an, hat aber keine Kenntnisse über den genauen Ablauf.
  • Inputs: Komponenten können in andere Komponenten eingebettet werden. Über Inputs bekommen sie Werte hineingegeben, die sie nicht selbst verwalten können oder sollen, und die sie weiterverarbeiten, zum Beispiel zur Anzeige. Komponenten sollten niemals ihre Input-Variablen direkt modifizieren. Änderungen sollten über Outputs nach außen gereicht werden.
  • Outputs: Komponenten können in andere Komponenten eingebettet werden. Über Outputs können sie mitteilen, dass sich etwas geändert hat oder ändern soll. Im Output können Daten mitgereicht werden, die die Vaterkomponente zur weiteren Verarbeitung nutzt.

Genauso wichtig ist, aus welchen Teilen eine Komponente nicht bestehen sollte:

  • Business-Logik: Komponenten sollten UI-nahe Logik enthalten, sei es zur Anzeige oder zur Komposition anderer Komponenten. Business-Logik sollte in die Serviceschicht ausgelagert werden.
  • Direkte Backend-Interaktion: Komponenten sollten Services nutzen, um mit dem Backend zu interagieren. Methodenaufrufe sollten fachlicher Natur sein und nicht mit technischen Artefakten wie einem Http-Aufruf vermischt werden.
  • Property-Drilling: Wenn Daten über viele Komponenten hinweg durchgereicht werden, die lediglich für eine Komponente am Ende der Kette von Belang sind, dann spricht man von Property-Drilling. Ein solches Durchreichen sollte vermieden werden, da man Komponenten so implizit aneinander koppelt, was Refactorings und Wiederverwendbarkeit erschwert. Besser, man lagert solche Datenströme in Services aus.

Komponenten sollten möglichst klein geschnitten werden, ähnlich wie Klassen sollten sie dem Single-Responsibility-Principle folgen [Mar02]. Man geht iterativ vor und beginnt mit einer Komponente. Sobald diese mit Services interagiert, sich um zehn Kindkomponenten kümmert und selbst noch Anzeigelogik enthält, tut sie zu viele Dinge gleichzeitig. Nun ist es Zeit, sie zu zerlegen.

Dabei ist das Prinzip der Container- und Presentational Komponenten sinnvoll [Med]. Es besagt, dass eine Komponente sich entweder darum kümmern sollte, wie die Dinge funktionieren, oder wie die Dinge aussehen. Eine Presentational Komponente besteht also aus den drei Teilen Anzeigelogik, Inputs, Outputs, während eine Container-Komponente vor allem aus Kompositionslogik, Interaktion mit anderen Schichten und nur eventuell aus Inputs und Outputs besteht. Abbildung 1 verdeutlicht die Unterteilung.

Abb. 1: Komponentenschnitt und Zuständigkeiten

Unidirektionaler Datenfluss – sinnvolle Einbahnstraßen

Unidirektionaler Datenfluss (engl. „One-Way-Data-Flow“) beschreibt im Kern eine Trennung der Daten von den Aktionen, die diese Daten verändern. Die UI zeigt Daten nur an, möchte sie etwas ändern, benachrichtigt sie den Verwalter dieser Daten mit dem Änderungswunsch. Dies ist essenziell, um auch bei größeren Anwendungen den Überblick zu behalten, wer wann welche Änderung ausgelöst hat und in welchem Zustand die Anwendung momentan ist.

Dieses Prinzip ist in Ansätzen bereits aus dem vorherigen Abschnitt bekannt, als es hieß, Komponenten dürfen Inputs niemals direkt modifizieren, sondern sollen ihre Änderungswünsche über Outputs propagieren.

Wer AngularJS kennt, dem ist das Two-Way-Binding vertraut. Es ermöglicht, die Daten in beide Richtungen zu beschreiben, das Framework bekommt diese Änderungen mit und rendert die Anzeige automatisch neu.

Für die Interaktion mit HTML-Elementen, wie zum Beispiel Inputs, ist dies eine sehr praktische und mächtige Eigenschaft, wendet man dieses Prinzip aber bei der Kom-
munikation von Komponenten miteinander und der Verwaltung des Anwendungszustands an, dann wird es ganz schnell sehr unübersichtlich. Wer hat nun die Änderung an den Daten vorgenommen? War es die Vaterkomponente? Oder war es eine der Kindkomponenten, oder vielleicht eine von dessen Kindkomponenten?

Um solche Fragen zu vermeiden, sollten Daten immer nur in eine Richtung fließen: Sie werden vom Datenverwalter beschrieben, niemals dem Datennutzer. Das Gleiche gilt bei der Kommunikation von Komponenten mit der Serviceschicht. Stellt ein Service Daten zur Verfügung, so darf die Komponente diese nicht direkt ändern.

Auf den Punkt gebracht: Nur eine einzige Stelle in der gesamten Anwendung darf einen bestimmten Zustand verändern. Diese Stelle nimmt Änderungswünsche entgegen und modifiziert dann gegebenenfalls den Zustand. Alle Nutzer benutzen den Zustand rein lesend. Rein lesend bedeutet nicht, dass aus dem Zustand keine Ableitungen vorgenommen werden dürfen. Es ist nur darauf zu achten, dass der Originalzustand dabei unberührt bleibt. Abbildung 2 veranschaulicht das Prinzip.

Abb. 2: Unidirektionaler Datenfluss

Redux [Red], einer der beliebtesten State-Management-Ansätze im Web-Frontend-Umfeld, treibt dieses Prinzip auf die Spitze: Der Zustand wird an einer zentralen Stelle, im sogenannten Store, verwaltet. Komponenten interagieren mit dem Store in Form einer Fassade, kennen also die Interna nicht. Im Store-Bereich befindet sich die komponentenübergreifende Logik sowie die Datenhaltung und Interaktion mit dem Backend. Der Store besteht aus verschiedenen Teilen mit klaren Zuständigkeitsbereichen:

  • Action: Beschreibt einen Zustandsänderungswunsch. Komponenten oder die Middleware erstellen Actions und schicken sie an den Store.
  • Middleware: In der Middleware geschehen Seiteneffekte und asynchrone Vorgänge, also zum Beispiel Logging oder Backend-Calls. Die Middleware wird entweder direkt aus den Komponenten heraus aufgerufen oder indirekt über eine Action. Die Middleware feuert ihrerseits ebenfalls Actions ab, um den Zustand zu ändern.
  • State (auch: Zustand): Der Zustand der Anwendung. Alles, was Komponenten übergreifend wichtig ist, sowie die Datenhaltung sind hier verortet.
  • Reducer: Ein Zustandsverwalter. Er nimmt als einzige Eingabeparameter den aktuellen State und eine Action entgegen und verarbeitet sie synchron zu einem neuen State. Nur Reducer dürfen den State verändern. Ein Reducer kümmert sich immer nur um einen kleinen Teil des States. Alle Reducer werden zu einem großen Reducer kombiniert, der sich um den gesamten State kümmert.
  • Selektor: Komponenten benötigen nicht den gesamten State, und eventuell auch nicht in der vorliegenden Form. Ein Selektor nimmt den State und bringt ihn in eine Form, die für eine Komponente passend ist. Selektoren sind daher oft komponentenspezifisch, können aber durchaus auch geteilt werden. Selektoren können ineinander geschachtelt werden beziehungsweise aufeinander aufbauen. Eine Komponente registriert sich beim Store mit einem Selektor. Der Store ruft den Callback der Komponente jedes Mal auf, wenn sich der State geändert hat.

Den Code, der die Bestandteile des Stores verknüpft, schreibt man in der Regel nicht selbst, sondern nutzt eine Library (zum Beispiel Redux [Red]). Das Zusammenspiel von Store und Komponenten ist in Abbildung 3 dargestellt. Eine umfangreiche Einführung findet sich in [Gar18].

Diese Trennung fördert eine modulare und erweiterbare Struktur. Die Testbarkeit wird deutlich erhöht, da Reducer und Selektoren zustandslose Eingabe-Ausgabe-Funktionen sind. Die Middleware ist gut isoliert und daher ohne großen Mockaufwand testbar. Durch den Umstand, dass nur Reducer den Zustand ändern dürfen, ist sehr klar, wer welche Änderung durchgeführt hat. Der Informationsfluss ist unidirektional. Der Zustand der Anwendung ist daher deterministisch und nachverfolgbar. Das Logging von States und Actions ergibt einen klaren Aktivitätsstrom, was vor allem das Debuggen deutlich erleichtert.

Für komplexere und größere Anwendungen ist der Einsatz von Redux sehr empfehlenswert. Für kleinere Projekte ist der zusätzliche Lernaufwand aber oft nicht gerechtfertigt. Wenn man sich nicht direkt zu Anfang entscheiden kann, ist das aber nicht schlimm. Wenn man das Prinzip des unidirektionalen Datenflusses von Anfang an beherzigt, sollte ein schrittweiser Wechsel nicht schwerfallen.

Wenn man die beiden Schaubilder 2 und 3 miteinander vergleicht, sieht man, dass sich Redux lediglich in der Organisation des Zustands und der Unterteilung von Zustand und Seiteneffekten wesentlich unterscheidet. Komponenten werden von den Refactorings also kaum betroffen sein.

Abb. 3: Redux-Architektur

Push statt Pull – reaktive, deklarative Anwendungen schreiben

Hat man den unidirektionalen Datenfluss beherzigt, kann man problemlos das letzte wichtige Prinzip in den Komponenten anwenden: das Push-Prinzip. Eine Komponente sollte sich nicht darum kümmern, den neuen Zustand explizit abzufragen. Der Zustand sollte stattdessen „einfach“ übernommen werden, sobald er aktualisiert wurde.

Technisch löst man dies über das Observer-Pattern, sei es „von Hand“ geschrieben oder hinter einer Library wie RxJs (mehr dazu in Kasten 2) versteckt. Die Komponente registriert sich beim Zustandsverwalter und hinterlegt einen Callback, der mit dem aktuellen Zustand aufgerufen wird, sobald es eine Änderung gibt. Die Komponente weiß also nicht mehr selbst, wann sie aktualisiert wird, sondern tut es, sobald es ihr gesagt wird. Der große Vorteil, der sich hieraus ergibt, ist eine wesentlich deklarativere Art, Komponenten zu schreiben. Man befreit die Komponentenlogik von dem „wann“ und kann das „was“ sehr viel klarer in Code ausdrücken.

Wenn man die Trennung von Datenanzeige und Änderungswünschen komplett beherzigt, hat dies zur Folge, dass auf Komponentenebene kein sichtbarer Zusammenhang zwischen diesen beiden Elementen besteht. Den Ladekringel, der während einer Backend-Interaktion gesetzt wird, würde man also nicht explizit vor dem Aufruf des Backend-Calls in der Komponente setzen und nach dessen Ende wieder zurücksetzen, sondern der Zustandsverwalter hätte zusätzlich einen „wird geladen“-Zustand. Die Komponente reagiert nach dem Push-Prinzip auf eine Änderung an diesem Zustand und zeigt den Ladekringel genau dann an, wenn der Zustand entsprechend gesetzt ist.

Diese Form der Indirektion kann zu Beginn etwas überflüssig oder unnötig kompliziert erscheinen, doch der Code wird so auf Dauer modularer und erweiterbarer gestaltet. Der Ladekringel könnte zum Beispiel auch dann ohne Änderungen an der Komponente angezeigt werden, wenn jemand anderes den Ladezustand auslöst.

Kasten 2: Erläuterung RxJs

Die Prinzipien an einem Beispiel veranschaulicht

Das Zusammenspiel der Prinzipien wird abschließend an einem Diagramm (siehe Abbildung 4) veranschaulicht, auf ein konkretes Codebeispiel wird bewusst verzichtet (vgl. Kasten 1). Fachlich geht es um eine Todo-Listen-App. Implementiert wird die Anzeige eines Zählers, der die Anzahl der offenen Todos anzeigt sowie das Hinzufügen eines neuen Todos.

Die Anzeige des Zählers sowie die Eingabe eines neuen Todos werden in Presentational Komponenten ausgelagert. Beide Komponenten werden von einer Container-Komponente verwendet, die die Schnittstelle zur Serviceschicht ist. Diese besteht aus dem TodoService.

Das Push-Prinzip wird bei der Kommunikation von Service und Container-Komponente angewendet. Die Komponente registriert sich beim Service für Updates am Zustand (Abbildung 4, Schritt 1). Ein solches Update könnte die aktuelle Zahl der offenen Todos enthalten. In diesem Fall ist es so, dass der Service immer die Todos selbst als Liste übergibt. Die Zahl der offenen Todos erschließt sich somit aus der Listengröße. Diese Transformation geschieht innerhalb der Container-Komponente.

Wird ein neues Todo erstellt, so delegiert die Presentational Komponente diese Zustandsänderung an seine Container-Komponente (Abbildung 4, Schritt 2), welche es an den Verwalter der Todos, den TodoService, weiterreicht (Abbildung 4, Schritt 3). Der TodoService nimmt die Änderung an seinem Zustand vor und benachrichtigt seine Listener (Abbildung 4, Schritt 4). Die Container-Komponente reicht dieses Update dann an die Presentational Komponente weiter (Abbildung 4, Schritt 5/6).

In einer ersten Version könnte der Service den Text des Todos einfach dem Array aller offenen Todos hinzufügen. Später könnte dies um eine Backend-Interaktion erweitert werden – dies ist für die Komponente völlig transparent, da sie die offenen Todos aus dem Zustand bekommt, den sie unabhängig vom Methodenaufruf per Push-Prinzip bekommt. Es existiert also ein unidirektionaler Datenfluss mit einem klar definierten Zustandsverwalter.

Würde man das Abhaken von Todos in einer anderen Komponente implementieren, so müsste man den TodoService lediglich um eine entsprechende Methode erweitern, und der Zähler würde – wieder transparent für die Komponente – nach erfolgreichem Abhaken nach unten gehen.

Schon anhand dieses kleinen Beispiels sieht man also, wie eine Anwendung durch Berücksichtigung der Prinzipien modular und erweiterbar gestaltet werden kann.

Abb. 4: Beispiel des Zusammenspiels von Container-/Presentational Komponenten mit Push-Prinzip und unidirektionalem Datenfluss

Fazit

Mithilfe der in diesem Artikel vorgestellten Prinzipien ist es möglich, modulare und erweiterbare Frontend-Anwendungen zu schreiben. Man schneidet die Komponenten in sinnvolle Blöcke, beachtet, was besser in die Serviceschicht verschoben werden sollte, und sorgt für eine Trennung von Daten und Änderungswünschen. Daten werden stets an einem Punkt verwaltet und geben diese per Push-Prinzip an die Komponenten weiter.

So behält man auch später noch den Überblick über den Zustand seiner Anwendung und kann nachvollziehen, wer wann welche Änderung vollzogen hat.||

Weitere Informationen

[Gar18] M. Garreau, W. Faurot, Redux in Action, Manning, 2018

[Man18] S. Mansilla, Reactive Programming with RxJs 5, O’Reilly, 2018

[Mar02] R. C. Martin, Agile Software Development. Principles, Patterns, and Practices, Prentice Hall, 2002

[Med] D. Abramov, Presentational and Container Components, 23.3.2015, siehe: https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0

[Rea] Offizielle Webseite RxJs, siehe: http://reactivex.io/

[Red] Offizielle Webseite Redux, siehe: https://redux.js.org/

. . .

Author Image
Zu Inhalten
Simon Holthausen ist Softwareentwickler bei Accso. Sein Schwerpunkt liegt auf Web-Frontends. In großen Kundenprojekten setzt er für die Umsetzung von Frontends auf Angular, Redux und RxJs.

Artikel teilen