Endliche Zustandsautomaten mit @wordpress/data und TypeScript
Veröffentlicht: 2021-09-16Angenommen, wir möchten in unserer Anwendung einen Anmeldebildschirm entwerfen. Wenn der Benutzer es zum ersten Mal sieht, gibt es ein leeres Formular. Der Benutzer füllt die Felder aus und drückt, sobald er fertig ist, die Anmeldeschaltfläche, um die Anmeldeinformationen zu validieren, und meldet sich an. Wenn die Validierung erfolgreich ist, gelangen sie zum nächsten Bildschirm. Wenn dies jedoch nicht der Fall ist, wird ihnen eine Fehlermeldung angezeigt und sie werden aufgefordert, es erneut zu versuchen.
Wenn Sie den Zustand eines solchen Bildschirms implementieren würden und meine Posts darüber lesen, wie man den Zustand einer Anwendung mit TypeScript und @wordpress/data
definiert, würde ich wetten, dass Sie so etwas tun würden:
type State = { readonly username: string; readonly password: string; readonly isLoggingIn: boolean; readonly errorMessage: string; };
das alle notwendigen Felder hat, um den Staat zu verwalten, aber ... ist das das Beste, was wir tun können?
Endliche Zustandsmaschinen
Eine endliche Zustandsmaschine (FSM) ist im Großen und Ganzen ein mathematisches Modell, das es uns ermöglicht, eine endliche Menge von Zuständen und die möglichen Übergänge zwischen ihnen zu definieren. (weitere Informationen auf Wikipedia). Ich mag diese Abstraktion, weil sie perfekt zu unseren Anforderungen passt, wenn es darum geht, den Zustand unserer Anwendungen zu modellieren. Das Zustandsdiagramm für unseren Anmeldebildschirm könnte beispielsweise so aussehen:

die, wie Sie sehen können, das Verhalten, das wir implementieren möchten, auf klare und prägnante Weise erfasst.
Zustände definieren mit @wordpress/data
In unserer Einführungsserie zu React haben wir gesehen, wie man das Paket @wordpress/data
, um den Status unserer App zu erstellen und zu verwalten. Dazu haben wir die Hauptkomponenten unseres Datenspeichers definiert:
- Ein Satz von Selektoren zum Abfragen des Zustands
- Eine Reihe von Aktionen zum Auslösen einer Aktualisierungsanforderung
- Eine Reduzierfunktion , um bei gegebenem aktuellen Zustand und einer Aktualisierungsaktion den Zustand zu aktualisieren
Im Wesentlichen verhalten sich Stores in @wordpress/data
bereits wie endliche Zustandsmaschinen, da die Reducer-Funktion mithilfe einer Aktion von einem Zustand in einen anderen „übergeht“:
( state: State, action: Action ) => State
So definieren Sie einen endlichen Zustandsautomaten mit TypeScript in @wordpress/data
Es sieht so aus, als ob @wordpress/data
stores ziemlich nah an dem FSM-Modell sind, das wir ein paar Zeilen weiter oben eingeführt haben: Uns fehlen nur die spezifischen Zustände, in denen wir uns befinden können, und die Übergänge zwischen ihnen … Schauen wir uns also genauer an, wie wir das können Beheben Sie diese Probleme Schritt für Schritt.
Explizite Zustände
Wir haben diesen Beitrag damit begonnen, eine mögliche Implementierung des Zustands unserer App zu zeigen. Unser erster Vorschlag war eine Reihe von Attributen, die die Arbeit erledigten, aber es sah überhaupt nicht wie eine endliche Zustandsmaschine aus. Beginnen wir also damit, sicherzustellen, dass unsere Zustände in unserem Modell explizit definiert sind:
type State = | Form | LoggingIn | Success | Error; type Form = { readonly status: 'form'; readonly username: string; readonly password: string; }; type LoggingIn = { readonly status: 'logging-in'; readonly username: string; readonly password: string; }; type Success = { readonly status: 'success'; // ... }; type Error = { readonly status: 'error'; readonly message: string; };
Ziemlich offensichtlich, oder? Es gibt einen Typ pro Status, und der Gesamtstatus der App wird als diskriminierte Vereinigung von Typen definiert. Es wird so genannt, weil (a) der Zustand „die Vereinigung verschiedener Typen“ ist (d. h. es ist entweder Form
, oder es ist LoggingIn
, oder es ist was auch immer ) und (b) wir ein Attribut haben (in diesem Fall , status
), die es uns erlaubt, zu unterscheiden, welchen spezifischen Zustand wir in jedem Moment haben.
Ich denke, diese Lösung ist viel besser als die, die wir am Anfang hatten. In unserer ursprünglichen Lösung konnten wir „ungültige Zustände“ definieren, weil man sich anmelden konnte (einfach isLoggingIn
auf true
setzen) und sich gleichzeitig in einem Fehlerzustand befinden konnte (einfach errorMessage
auf einen anderen Wert als leer setzen Schnur). Aber das macht eindeutig keinen Sinn! Welches ist es? Melden wir uns an oder sollen wir einen Fehler anzeigen?
Die neue Lösung hingegen ist bei der Repräsentation des Staates viel präziser: Sie macht „ungültige“ Zustände „unmöglich“. Wenn wir uns in LoggingIn
befinden, gibt es keine Möglichkeit, eine Fehlermeldung einzurichten (es gibt kein Attribut dafür!). Wenn wir einen Error
, können Sie sich nicht anmelden. Viel besser, finden Sie nicht?
Aktionen
In @wordpress/data
aktualisieren Aktionen den Store in unserem Zustand. Da wir unseren Zustand als FSM modellieren, sind unsere Aktionen die Pfeile aus unserem ursprünglichen Diagramm. Alles, was Sie tun müssen, ist, sie als diskriminierte Vereinigung von Typen zu modellieren (konventionell heißt der Diskriminator normalerweise type
) und Sie können loslegen:

type SetCredentials = { readonly type: 'SET_CREDENTIALS'; readonly username: string; readonly password: string; }; type Login = { readonly type: 'LOGIN'; }; type ShowApp = { readonly type: 'SHOW_APP'; // ... }; type ShowError = { readonly type: 'SHOW_ERROR'; readonly message: string; }; type BackToLoginForm = { readonly type: 'BACK_TO_LOGIN_FORM'; };
Unsere Aktionen sollen die Pfeile darstellen, die wir in unserem ursprünglichen Diagramm hatten, richtig? Nun, ich weiß nicht, wie es euch geht, aber ich denke, diese Aktionstypen sehen überhaupt nicht wie Pfeile aus! Pfeile haben eine Richtung (sie gehen von einem Zustand zum anderen); Aktionen nicht.
Wir brauchen einen Weg, um in unserem Code deutlich zu machen, dass bestimmte Aktionen nur nützlich sind, um von einem Zustand in einen anderen zu wechseln. Und bisher ist die beste Lösung, die ich gefunden habe, die Verwendung des Reduzierers selbst:
function reducer( state: State, action: Action ): State { switch ( state.status ) { case 'form': switch ( action.type ) { case 'SET_CREDENTIALS': return { status: 'ready', username: action.username, password: action.password, }; case 'LOGIN': return { ...state, status: 'logging-in' }; } case ...: ... } return state; }
Wenn unser Reducer damit beginnt, den status
unseres state
zu filtern, kennen wir die „Quelle“ unseres Pfeils. Dann schauen wir uns die aktuelle Aktion an und wenn es ein „Pfeil nach außen“ ist, generieren wir den neuen „Ziel“-Zustand.
Wie das funktioniert, können Sie im obigen Snippet deutlich sehen. Wenn der aktuelle status
form
ist, bringt uns die Aktion SET_CREDENTIALS
in denselben Zustand, in dem wir uns befanden (Aktualisierung der Anmeldeinformationen, duh), und die Aktion LOGIN
ändert den Status in logging-in
. Jede andere Aktion in diesem Zustand wird ignoriert und daher bleibt der Zustand unverändert.
Das ist alles gut und perfekt, aber… ich weiß nicht, ich mag den Code nicht so sehr, oder? Ich möchte sicherstellen, dass mein Code klar, prägnant, selbsterklärend und typsicher ist. Können wir das tun?
Stark typisierter Reducer
Um das Chaos zu beheben, in das wir gerade geraten sind, müssen wir nur unseren reducer
so umgestalten, dass jeder mögliche Zustand in unserem FSM seinen eigenen Reduzierer hat. Wenn wir das richtig machen, werden sie stark typisiert und es wird klar sein, was erlaubt ist und was nicht.
Beginnen wir damit, einen Typ für jeden Zustand in unserer Anwendung zu erstellen. Jeder Typ gruppiert die Aktionen, die uns von dem gegebenen Zustand zu einem anderen führen können. Zum Beispiel hat unser Form
-Zustand zwei nach außen gerichtete Pfeile ( SetCredentials
und Login
), also erstellen wir den Typ FormAction
mit der Vereinigung der beiden Aktionen. Wiederholen Sie den Vorgang für jeden Zustand und Sie erhalten Folgendes:
type FormAction = SetCredentials | Login; type LoggingInAction = ShowError | ShowApp; type SuccessAction = ...; type ErrorAction = BackToLoginForm; type Action = | FormAction | LoggingInAction | SuccessAction | ErrorAction;
Definieren Sie als Nächstes alle benötigten Reduzierer. Wieder eine für jedes Bundesland:
function reduceForm( state: Form, action: FormAction ): Form | LoggingIn { switch ( action.type ) { switch ( action.type ) { case 'SET_CREDENTIALS': return { status: 'ready', username: action.username, password: action.password, }; case 'LOGIN': return { ...state, status: 'logging-in' }; } } reduceLoggingIn: ( state: LoggingIn, action: LoggingInAction ) => Success | Error reduceError: ( state: Error, action: ErrorAction ) => Form reduceSuccess: ( state: Success, action: SuccessAction ) => ...
Dank TypeScript und den soeben definierten Typen können wir jetzt den Quellzustand und die Menge der Aktionen, die jeder Reduzierer erwartet, sowie den Zielzustand/die Zielzustände, die wir als Ergebnis erhalten können, sehr genau bestimmen.
Schließlich müssen wir das alles nur noch mit einer einzigartigen reducer
:
function reducer( state: State, action: Action ): State { switch ( state.status ) { case 'form': return reduceForm( state, action as FormActions ) ?? state; case 'logging-in': return reduceLoggingIn( state, action as LoggingInActions ) ?? state; case 'error': return reduceError( state, action as ErrorActions ) ?? state; case 'success': return reduceSuccess( state, action as SuccessActions ) ?? state; } }
Der resultierende Code ist meiner Meinung nach übersichtlicher und einfacher zu warten. Und darüber hinaus ist TypeScript jetzt in der Lage, zu überprüfen, was wir tun, und alle fehlenden Szenarien abzufangen. Fantastisch!
Fazit
In diesem Beitrag haben wir gesehen, was ein endlicher Automat ist und wie wir ihn mit TypeScript in einem @wordpress/data
store implementieren können. Unsere endgültige Lösung ist ziemlich einfach und bietet dennoch viele Vorteile: Sie erfasst den Zustand und die Absicht unserer App besser und nutzt die Leistungsfähigkeit von TypeScript.
Ich hoffe, dir hat dieser Beitrag gefallen! Wenn ja, teilen Sie es bitte mit Ihren Freunden und Kollegen. Und bitte teilen Sie mir im Kommentarbereich unten mit, wie Sie diese Probleme angehen.
Beitragsbild von Patrick Hendry auf Unsplash.