Der Aufstieg der Staatsmaschinen

Veröffentlicht: 2022-03-10
Kurze Zusammenfassung ↬ Die UI-Entwicklung wurde in den letzten Jahren schwierig. Das liegt daran, dass wir die Zustandsverwaltung in den Browser verschoben haben. Und die Verwaltung des Staates macht unsere Arbeit zu einer Herausforderung. Wenn wir es richtig machen, werden wir sehen, wie unsere Anwendung leicht und ohne Fehler skaliert. In diesem Artikel werden wir sehen, wie das Zustandsmaschinenkonzept zur Lösung von Zustandsverwaltungsproblemen verwendet wird.

Es ist bereits 2018 und unzählige Frontend-Entwickler führen immer noch einen Kampf gegen Komplexität und Immobilität. Monat für Monat haben sie nach dem heiligen Gral gesucht: einer fehlerfreien Anwendungsarchitektur, die ihnen hilft, schnell und mit hoher Qualität zu liefern. Ich bin einer dieser Entwickler und habe etwas Interessantes gefunden, das helfen könnte.

Mit Tools wie React und Redux sind wir einen guten Schritt weitergekommen. Für großflächige Anwendungen reichen sie jedoch allein nicht aus. Dieser Artikel stellt Ihnen das Konzept von Zustandsmaschinen im Kontext der Frontend-Entwicklung vor. Sie haben wahrscheinlich schon mehrere davon gebaut, ohne es zu merken.

Eine Einführung in Zustandsmaschinen

Eine Zustandsmaschine ist ein mathematisches Berechnungsmodell. Es ist ein abstraktes Konzept, bei dem die Maschine verschiedene Zustände haben kann, aber zu einem bestimmten Zeitpunkt nur einen davon erfüllt. Es gibt verschiedene Arten von Zustandsmaschinen. Die berühmteste, glaube ich, ist die Turing-Maschine. Es ist eine unendliche Zustandsmaschine, was bedeutet, dass sie eine unzählige Anzahl von Zuständen haben kann. Die Turing-Maschine passt nicht gut in die heutige UI-Entwicklung, da wir in den meisten Fällen eine endliche Anzahl von Zuständen haben. Aus diesem Grund sind endliche Zustandsautomaten wie Mealy und Moore sinnvoller.

Der Unterschied zwischen ihnen besteht darin, dass die Moore-Maschine ihren Zustand nur basierend auf ihrem vorherigen Zustand ändert. Leider haben wir viele externe Faktoren, wie Benutzerinteraktionen und Netzwerkprozesse, was bedeutet, dass die Moore-Maschine auch für uns nicht gut genug ist. Was wir suchen, ist die Mealy-Maschine. Es hat einen Anfangszustand und geht dann basierend auf der Eingabe und seinem aktuellen Zustand in neue Zustände über.

Mehr nach dem Sprung! Lesen Sie unten weiter ↓

Eine der einfachsten Möglichkeiten, die Funktionsweise einer Zustandsmaschine zu veranschaulichen, ist die Betrachtung eines Drehkreuzes. Es hat eine endliche Anzahl von Zuständen: gesperrt und entsperrt. Hier ist eine einfache Grafik, die uns diese Zustände mit ihren möglichen Eingängen und Übergängen zeigt.

Drehkreuz

Der Ausgangszustand des Drehkreuzes ist verriegelt. Egal wie oft wir es drücken, es bleibt in diesem gesperrten Zustand. Wenn wir ihm jedoch eine Münze übergeben, wechselt er in den entsperrten Zustand. Eine andere Münze an diesem Punkt würde nichts bewirken; es wäre immer noch im entsperrten Zustand. Ein Stoß von der anderen Seite würde funktionieren, und wir könnten passieren. Diese Aktion versetzt die Maschine auch in den ursprünglichen gesperrten Zustand.

Wenn wir eine einzelne Funktion implementieren wollten, die das Drehkreuz steuert, würden wir wahrscheinlich zwei Argumente erhalten: den aktuellen Zustand und eine Aktion. Und wenn Sie Redux verwenden, kommt Ihnen das wahrscheinlich bekannt vor. Es ähnelt der bekannten Reducer-Funktion, bei der wir den aktuellen Zustand erhalten und anhand der Nutzlast der Aktion entscheiden, was der nächste Zustand sein wird. Der Reducer ist der Übergang im Kontext von Zustandsmaschinen. Tatsächlich kann jede Anwendung, die einen Zustand hat, den wir irgendwie ändern können, als Zustandsmaschine bezeichnet werden. Es ist nur so, dass wir immer wieder alles manuell implementieren.

Wie ist eine Zustandsmaschine besser?

Bei der Arbeit verwenden wir Redux und ich bin sehr zufrieden damit. Allerdings habe ich angefangen, Muster zu sehen, die mir nicht gefallen. Mit „mag ich nicht“ meine ich nicht, dass sie nicht funktionieren. Es ist vielmehr so, dass sie die Komplexität erhöhen und mich dazu zwingen, mehr Code zu schreiben. Ich musste ein Nebenprojekt durchführen, in dem ich Raum zum Experimentieren hatte, und beschloss, unsere React- und Redux-Entwicklungspraktiken zu überdenken. Ich fing an, mir Notizen über die Dinge zu machen, die mich beschäftigten, und mir wurde klar, dass eine Zustandsmaschinen-Abstraktion einige dieser Probleme wirklich lösen würde. Lassen Sie uns einsteigen und sehen, wie eine Zustandsmaschine in JavaScript implementiert wird.

Wir werden ein einfaches Problem angreifen. Wir möchten Daten von einer Back-End-API abrufen und dem Benutzer anzeigen. Der allererste Schritt besteht darin, zu lernen, in Zuständen statt in Übergängen zu denken. Bevor wir uns mit Zustandsautomaten befassen, sah mein Arbeitsablauf zum Erstellen eines solchen Features früher ungefähr so ​​aus:

  • Wir zeigen eine Schaltfläche zum Abrufen von Daten an.
  • Der Benutzer klickt auf die Schaltfläche zum Abrufen von Daten.
  • Feuern Sie die Anfrage an das Backend ab.
  • Rufen Sie die Daten ab und analysieren Sie sie.
  • Zeigen Sie es dem Benutzer.
  • Oder zeigen Sie bei einem Fehler die Fehlermeldung und die Schaltfläche zum Abrufen von Daten an, damit wir den Vorgang erneut auslösen können.
Lineares Denken

Wir denken linear und versuchen grundsätzlich alle möglichen Richtungen bis zum Endergebnis abzudecken. Ein Schritt führt zum nächsten, und wir würden schnell anfangen, unseren Code zu verzweigen. Was ist mit Problemen, wie wenn der Benutzer auf die Schaltfläche doppelklickt oder wenn der Benutzer auf die Schaltfläche klickt, während wir auf die Antwort des Backends warten, oder wenn die Anfrage erfolgreich ist, aber die Daten beschädigt sind? In diesen Fällen hätten wir wahrscheinlich verschiedene Flaggen, die uns zeigen, was passiert ist. Flags zu haben bedeutet mehr if -Klauseln und in komplexeren Apps mehr Konflikte.

Lineares Denken

Denn wir denken in Übergängen. Wir konzentrieren uns darauf, wie und in welcher Reihenfolge diese Übergänge stattfinden. Es wäre viel einfacher, sich stattdessen auf die verschiedenen Zustände der Anwendung zu konzentrieren. Wie viele Zustände haben wir und was sind ihre möglichen Eingaben? Am selben Beispiel:

  • Leerlauf
    In diesem Zustand zeigen wir die Schaltfläche zum Abrufen von Daten an, sitzen und warten. Die mögliche Aktion ist:
    • klicken
      Wenn der Benutzer auf die Schaltfläche klickt, senden wir die Anfrage an das Backend und versetzen die Maschine dann in einen „Fetching“-Zustand.
  • holen
    Die Anfrage ist im Flug, und wir sitzen und warten. Die Aktionen sind:
    • Erfolg
      Die Daten kommen erfolgreich an und sind nicht beschädigt. Wir verwenden die Daten auf irgendeine Weise und wechseln zurück in den „Leerlauf“-Zustand.
    • Versagen
      Wenn beim Stellen der Anfrage oder beim Analysieren der Daten ein Fehler auftritt, wechseln wir in einen „Fehler“-Zustand.
  • Error
    Wir zeigen eine Fehlermeldung und zeigen die Schaltfläche zum Abrufen von Daten an. Dieser Zustand akzeptiert eine Aktion:
    • wiederholen
      Wenn der Benutzer auf die Schaltfläche „Wiederholen“ klickt, lösen wir die Anfrage erneut aus und versetzen die Maschine in den „Fetching“-Zustand.

Wir haben ungefähr die gleichen Prozesse beschrieben, aber mit Zuständen und Eingaben.

Zustandsmaschine

Dies vereinfacht die Logik und macht sie vorhersehbarer. Es löst auch einige der oben genannten Probleme. Beachten Sie, dass wir im Status „Abrufen“ keine Klicks akzeptieren. Selbst wenn der Benutzer auf die Schaltfläche klickt, passiert also nichts, da die Maschine nicht so konfiguriert ist, dass sie in diesem Zustand auf diese Aktion reagiert. Dieser Ansatz eliminiert automatisch die unvorhersehbare Verzweigung unserer Codelogik. Das bedeutet, dass wir beim Testen weniger Code abdecken müssen. Außerdem können einige Arten von Tests, wie z. B. Integrationstests, automatisiert werden. Stellen Sie sich vor, wir hätten eine wirklich klare Vorstellung davon, was unsere Anwendung tut, und wir könnten ein Skript erstellen, das die definierten Zustände und Übergänge durchgeht und Behauptungen generiert. Diese Behauptungen könnten beweisen, dass wir jeden möglichen Zustand erreicht oder eine bestimmte Reise zurückgelegt haben.

Tatsächlich ist es einfacher, alle möglichen Zustände aufzuschreiben, als alle möglichen Übergänge aufzuschreiben, weil wir wissen, welche Zustände wir brauchen oder haben. Übrigens würden die Zustände in den meisten Fällen die Geschäftslogik unserer Anwendung beschreiben, wohingegen Übergänge am Anfang sehr oft unbekannt sind. Die Fehler in unserer Software sind das Ergebnis von Aktionen, die in einem falschen Zustand und/oder zur falschen Zeit gesendet wurden. Sie verlassen unsere App in einem Zustand, von dem wir nichts wissen, und dies unterbricht unser Programm oder führt dazu, dass es sich falsch verhält. Natürlich wollen wir nicht in eine solche Situation geraten. Zustandsmaschinen sind gute Firewalls . Sie schützen uns davor, unbekannte Zustände zu erreichen, weil wir Grenzen dafür setzen, was wann passieren kann, ohne explizit zu sagen, wie. Das Konzept einer Zustandsmaschine passt sehr gut zu einem unidirektionalen Datenfluss. Zusammen reduzieren sie die Code-Komplexität und klären das Rätsel, woher ein Zustand stammt.

Erstellen einer Zustandsmaschine in JavaScript

Genug geredet – sehen wir uns etwas Code an. Wir werden dasselbe Beispiel verwenden. Basierend auf der obigen Liste beginnen wir mit Folgendem:

 const machine = { 'idle': { click: function () { ... } }, 'fetching': { success: function () { ... }, failure: function () { ... } }, 'error': { 'retry': function () { ... } } }

Wir haben die Zustände als Objekte und ihre möglichen Eingaben als Funktionen. Allerdings fehlt der Anfangszustand. Ändern wir den obigen Code wie folgt:

 const machine = { state: 'idle', transitions: { 'idle': { click: function() { ... } }, 'fetching': { success: function() { ... }, failure: function() { ... } }, 'error': { 'retry': function() { ... } } } }

Sobald wir alle für uns sinnvollen Zustände definiert haben, sind wir bereit, die Eingabe zu senden und den Zustand zu ändern. Wir werden dies tun, indem wir die beiden folgenden Hilfsmethoden verwenden:

 const machine = { dispatch(actionName, ...payload) { const actions = this.transitions[this.state]; const action = this.transitions[this.state][actionName]; if (action) { action.apply(machine, ...payload); } }, changeStateTo(newState) { this.state = newState; }, ... }

Die dispatch -Funktion prüft, ob es in den Transitionen des aktuellen Zustands eine Aktion mit dem angegebenen Namen gibt. Wenn dies der Fall ist, wird es mit der angegebenen Nutzlast abgefeuert. Wir rufen den action auch mit der machine als Kontext auf, damit wir mit this.dispatch(<action>) andere Aktionen absetzen oder mit this.changeStateTo(<new state>) den Zustand ändern können.

Nach der User Journey unseres Beispiels ist die erste Aktion, die wir senden müssen, click . So sieht der Handler dieser Aktion aus:

 transitions: { 'idle': { click: function () { this.changeStateTo('fetching'); service.getData().then( data => { try { this.dispatch('success', JSON.parse(data)); } catch (error) { this.dispatch('failure', error) } }, error => this.dispatch('failure', error) ); } }, ... } machine.dispatch('click');

Wir ändern zuerst den Zustand der Maschine auf fetching . Dann lösen wir die Anfrage an das Backend aus. Nehmen wir an, wir haben einen Dienst mit einer Methode getData , die ein Promise zurückgibt. Sobald es gelöst ist und die Datenanalyse in Ordnung ist, senden wir success , wenn nicht failure .

So weit, ist es gut. Als nächstes müssen wir success und failure und Eingaben unter dem fetching implementieren:

 transitions: { 'idle': { ... }, 'fetching': { success: function (data) { // render the data this.changeStateTo('idle'); }, failure: function (error) { this.changeStateTo('error'); } }, ... }

Beachten Sie, wie wir unser Gehirn davon befreit haben, über den vorherigen Prozess nachdenken zu müssen. Wir kümmern uns nicht um Benutzerklicks oder was mit der HTTP-Anforderung passiert. Wir wissen, dass sich die Anwendung in einem fetching befindet, und wir erwarten nur diese beiden Aktionen. Es ist ein bisschen so, als würde man isoliert neue Logik schreiben.

Das letzte Bit ist der error . Es wäre schön, wenn wir diese Wiederholungslogik bereitstellen würden, damit die Anwendung nach einem Fehler wiederhergestellt werden kann.

 transitions: { 'error': { retry: function () { this.changeStateTo('idle'); this.dispatch('click'); } } }

Hier müssen wir die Logik duplizieren, die wir im click -Handler geschrieben haben. Um dies zu vermeiden, sollten wir den Handler entweder als Funktion definieren, die für beide Aktionen zugänglich ist, oder zuerst in den idle wechseln und dann die click manuell auslösen.

Ein vollständiges Beispiel der funktionierenden Zustandsmaschine finden Sie in meinem Codepen.

Verwalten von Zustandsmaschinen mit einer Bibliothek

Das Finite-State-Machine-Muster funktioniert unabhängig davon, ob wir React, Vue oder Angular verwenden. Wie wir im vorherigen Abschnitt gesehen haben, können wir ohne großen Aufwand eine Zustandsmaschine implementieren. Manchmal bietet eine Bibliothek jedoch mehr Flexibilität. Einige der guten sind Machina.js und XState. In diesem Artikel werden wir jedoch über Stent sprechen, meine Redux-ähnliche Bibliothek, die auf dem Konzept endlicher Zustandsautomaten basiert.

Stent ist eine Implementierung eines Containers für Zustandsmaschinen. Es folgt einigen der Ideen in den Redux- und Redux-Saga-Projekten, bietet aber meiner Meinung nach einfachere und Boilerplate-freie Prozesse. Es wird mit Readme-gesteuerter Entwicklung entwickelt, und ich habe buchstäblich Wochen nur mit dem API-Design verbracht. Da ich die Bibliothek geschrieben habe, hatte ich die Möglichkeit, die Probleme zu beheben, auf die ich bei der Verwendung der Redux- und Flux-Architekturen gestoßen bin.

Maschinen erstellen

In den meisten Fällen decken unsere Anwendungen mehrere Domänen ab. Wir können nicht mit nur einer Maschine gehen. Stent ermöglicht also die Erstellung vieler Maschinen:

 import { Machine } from 'stent'; const machineA = Machine.create('A', { state: ..., transitions: ... }); const machineB = Machine.create('B', { state: ..., transitions: ... });

Später können wir mit der Methode Machine.get auf diese Maschinen zugreifen:

 const machineA = Machine.get('A'); const machineB = Machine.get('B');

Verbinden der Maschinen mit der Rendering-Logik

Das Rendern erfolgt in meinem Fall über React, aber wir können jede andere Bibliothek verwenden. Es läuft darauf hinaus, einen Callback auszulösen, in dem wir das Rendering auslösen. Eines der ersten Features, an denen ich gearbeitet habe, war die connect Funktion:

 import { connect } from 'stent/lib/helpers'; Machine.create('MachineA', ...); Machine.create('MachineB', ...); connect() .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { ... rendering here });

Wir sagen, welche Maschinen uns wichtig sind und nennen sie. Der Callback, den wir an map übergeben, wird anfangs einmal und später jedes Mal ausgelöst, wenn sich der Zustand einiger Maschinen ändert. Hier lösen wir das Rendering aus. An dieser Stelle haben wir direkten Zugriff auf die angeschlossenen Maschinen, um den aktuellen Stand und die Methoden abzurufen. Es gibt auch mapOnce , damit der Callback nur einmal ausgelöst wird, und mapSilent , um diese anfängliche Ausführung zu überspringen.

Der Einfachheit halber wird ein Helfer speziell für die React-Integration exportiert. Es ist connect(mapStateToProps) von Redux sehr ähnlich.

 import React from 'react'; import { connect } from 'stent/lib/react'; class TodoList extends React.Component { render() { const { isIdle, todos } = this.props; ... } } // MachineA and MachineB are machines defined // using Machine.create function export default connect(TodoList) .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { isIdle: MachineA.isIdle, todos: MachineB.state.todos });

Stent führt unseren Mapping-Callback aus und erwartet den Empfang eines Objekts – ein Objekt, das als props an unsere React-Komponente gesendet wird.

Was ist Staat im Zusammenhang mit Stents?

Bis jetzt war unser Zustand einfache Saiten. Leider müssen wir in der realen Welt mehr als eine Zeichenfolge im Zustand halten. Aus diesem Grund ist der Zustand des Stents eigentlich ein Objekt mit Eigenschaften im Inneren. Die einzige reservierte Eigenschaft ist name . Alles andere sind App-spezifische Daten. Zum Beispiel:

 { name: 'idle' } { name: 'fetching', todos: [] } { name: 'forward', speed: 120, gear: 4 }

Meine bisherige Erfahrung mit Stent zeigt mir, dass wir wahrscheinlich einen anderen Computer benötigen würden, der diese zusätzlichen Eigenschaften handhabt, wenn das Zustandsobjekt größer wird. Das Identifizieren der verschiedenen Zustände dauert einige Zeit, aber ich glaube, dass dies ein großer Schritt nach vorne ist, um besser handhabbare Anwendungen zu schreiben. Es ist ein bisschen so, als würde man die Zukunft vorhersagen und Rahmen der möglichen Aktionen zeichnen.

Arbeiten mit der Zustandsmaschine

Ähnlich wie im Beispiel am Anfang müssen wir die möglichen (endlichen) Zustände unserer Maschine definieren und die möglichen Eingaben beschreiben:

 import { Machine } from 'stent'; const machine = Machine.create('sprinter', { state: { name: 'idle' }, // initial state transitions: { 'idle': { 'run please': function () { return { name: 'running' }; } }, 'running': { 'stop now': function () { return { name: 'idle' }; } } } });

Wir haben unseren Anfangszustand, im idle , der eine Aktion von run akzeptiert. Sobald sich die Maschine in einem running Zustand befindet, können wir die stop auslösen, die uns zurück in den idle bringt.

Sie werden sich wahrscheinlich an die dispatch und changeStateTo Helfer aus unserer früheren Implementierung erinnern. Diese Bibliothek bietet die gleiche Logik, aber sie ist intern verborgen, und wir müssen nicht darüber nachdenken. Der Einfachheit halber generiert Stent basierend auf der Eigenschaft transitions Folgendes:

  • Hilfsmethoden, um zu prüfen, ob sich die Maschine in einem bestimmten Zustand befindet – der idle erzeugt die Methode isIdle() , während wir für running isRunning() haben;
  • Hilfsmethoden zum Versenden von Aktionen: runPlease() und stopNow() .

Im obigen Beispiel können wir also Folgendes verwenden:

 machine.isIdle(); // boolean machine.isRunning(); // boolean machine.runPlease(); // fires action machine.stopNow(); // fires action

Durch die Kombination der automatisch generierten Methoden mit der connect können wir den Kreis schließen. Eine Benutzerinteraktion löst die Maschineneingabe und -aktion aus, wodurch der Status aktualisiert wird. Aufgrund dieser Aktualisierung wird die an connect übergebene Mapping-Funktion ausgelöst und wir werden über die Statusänderung informiert. Dann rendern wir neu.

Eingabe- und Aktionshandler

Das wahrscheinlich wichtigste Bit sind die Aktionshandler. An dieser Stelle schreiben wir den Großteil der Anwendungslogik, da wir auf Eingaben und geänderte Zustände reagieren. Was mir an Redux sehr gefällt, ist auch hier integriert: die Unveränderlichkeit und Einfachheit der Reducer-Funktion. Die Essenz des Aktionshandlers von Stent ist dieselbe. Es empfängt den aktuellen Status und die Aktionsnutzlast und muss den neuen Status zurückgeben. Wenn der Handler nichts zurückgibt ( undefined ), bleibt der Zustand der Maschine gleich.

 transitions: { 'fetching': { 'success': function (state, payload) { const todos = [ ...state.todos, payload ]; return { name: 'idle', todos }; } } }

Nehmen wir an, wir müssen Daten von einem Remote-Server abrufen. Wir lösen die Anfrage aus und versetzen die Maschine in einen fetching . Sobald die Daten vom Backend kommen, feuern wir eine success wie folgt ab:

 machine.success({ label: '...' });

Dann kehren wir in einen idle zurück und behalten einige Daten in Form des todos -Arrays. Es gibt ein paar andere mögliche Werte, die als Aktionshandler festgelegt werden können. Der erste und einfachste Fall ist, wenn wir nur eine Zeichenfolge übergeben, die zum neuen Zustand wird.

 transitions: { 'idle': { 'run': 'running' } }

Dies ist ein Übergang von { name: 'idle' } zu { name: 'running' } unter Verwendung der Aktion run() . Dieser Ansatz ist nützlich, wenn wir synchrone Zustandsübergänge haben und keine Metadaten haben. Wenn wir also etwas anderes im Zustand halten, wird diese Art von Übergang es ausspülen. Ebenso können wir ein Zustandsobjekt direkt übergeben:

 transitions: { 'editing': { 'delete all todos': { name: 'idle', todos: [] } } }

Wir wechseln mit der Aktion deleteAllTodos von der editing in den idle .

Wir haben bereits den Funktionshandler gesehen, und die letzte Variante des Aktionshandlers ist eine Generatorfunktion. Es ist vom Redux-Saga-Projekt inspiriert und sieht so aus:

 import { call } from 'stent/lib/helpers'; Machine.create('app', { 'idle': { 'fetch data': function * (state, payload) { yield { name: 'fetching' } try { const data = yield call(requestToBackend, '/api/todos/', 'POST'); return { name: 'idle', data }; } catch (error) { return { name: 'error', error }; } } } });

Wenn Sie keine Erfahrung mit Generatoren haben, sieht dies möglicherweise etwas kryptisch aus. Aber die Generatoren in JavaScript sind ein mächtiges Werkzeug. Wir dürfen unseren Aktionshandler anhalten, den Zustand mehrmals ändern und mit asynchroner Logik umgehen.

Spaß mit Generatoren

Als ich Redux-Saga zum ersten Mal kennenlernte, dachte ich, es sei eine zu komplizierte Art, mit asynchronen Vorgängen umzugehen. Tatsächlich ist es eine ziemlich intelligente Implementierung des Befehlsentwurfsmusters. Der Hauptvorteil dieses Musters besteht darin, dass es den Aufruf der Logik von ihrer eigentlichen Implementierung trennt.

Mit anderen Worten, wir sagen, was wir wollen, aber nicht, wie es geschehen soll. Die Blogserie von Matt Hink hat mir geholfen zu verstehen, wie Sagen implementiert werden, und ich empfehle dringend, sie zu lesen. Ich habe die gleichen Ideen in Stent eingebracht, und für den Zweck dieses Artikels werden wir sagen, dass wir durch das Nachgeben von Dingen Anweisungen darüber geben, was wir wollen, ohne es tatsächlich zu tun. Sobald die Aktion ausgeführt wird, erhalten wir die Kontrolle zurück.

Im Moment können ein paar Dinge ausgesendet (ergeben) werden:

  • ein Zustandsobjekt (oder eine Zeichenfolge) zum Ändern des Zustands der Maschine;
  • ein Aufruf des call Helpers (er akzeptiert eine synchrone Funktion, das ist eine Funktion, die ein Versprechen oder eine andere Generatorfunktion zurückgibt) – wir sagen im Grunde: „Führe das für mich aus, und wenn es asynchron ist, warte. Wenn Sie fertig sind, geben Sie mir das Ergebnis.“;
  • ein Aufruf des wait -Helfers (er akzeptiert eine Zeichenfolge, die eine andere Aktion darstellt); Wenn wir diese Hilfsfunktion verwenden, halten wir den Handler an und warten, bis eine weitere Aktion ausgeführt wird.

Hier eine Funktion, die die Varianten veranschaulicht:

 const fireHTTPRequest = function () { return new Promise((resolve, reject) => { // ... }); } ... transitions: { 'idle': { 'fetch data': function * () { yield 'fetching'; // sets the state to { name: 'fetching' } yield { name: 'fetching' }; // same as above // wait for getTheData and checkForErrors actions // to be dispatched const [ data, isError ] = yield wait('get the data', 'check for errors'); // wait for the promise returned by fireHTTPRequest // to be resolved const result = yield call(fireHTTPRequest, '/api/data/users'); return { name: 'finish', users: result }; } } }

Wie wir sehen können, sieht der Code synchron aus, ist es aber nicht. Es ist nur Stent, der den langweiligen Teil erledigt, indem er auf das aufgelöste Versprechen wartet oder über einen anderen Generator iteriert.

Wie Stent meine Redux-Bedenken löst

Zu viel Boilerplate-Code

Die Architektur von Redux (und Flux) basiert auf Aktionen, die in unserem System zirkulieren. Wenn die Anwendung wächst, haben wir normalerweise viele Konstanten und Aktionsersteller. Diese beiden Dinge befinden sich sehr oft in verschiedenen Ordnern, und das Verfolgen der Ausführung des Codes nimmt manchmal Zeit in Anspruch. Außerdem müssen wir uns beim Hinzufügen einer neuen Funktion immer mit einer ganzen Reihe von Aktionen befassen, was bedeutet, dass wir mehr Aktionsnamen und Aktionsersteller definieren müssen.

In Stent haben wir keine Aktionsnamen, und die Bibliothek erstellt die Aktionsersteller automatisch für uns:

 const machine = Machine.create('todo-app', { state: { name: 'idle', todos: [] }, transitions: { 'idle': { 'add todo': function (state, todo) { ... } } } }); machine.addTodo({ title: 'Fix that bug' });

Wir haben den Ersteller der Aktion machine.addTodo direkt als Methode der Maschine definiert. Dieser Ansatz löste auch ein anderes Problem, mit dem ich konfrontiert war: das Finden des Reduzierers, der auf eine bestimmte Aktion reagiert. Normalerweise sehen wir in React-Komponenten Namen von Aktionserstellern wie addTodo ; Bei den Reduzierstücken hingegen arbeiten wir mit einer konstanten Aktionsart. Manchmal muss ich zum Aktionserstellercode springen, nur damit ich den genauen Typ sehen kann. Hier haben wir überhaupt keine Typen.

Unvorhersehbare Zustandsänderungen

Im Allgemeinen leistet Redux gute Arbeit bei der unveränderlichen Zustandsverwaltung. Das Problem liegt nicht in Redux selbst, sondern darin, dass der Entwickler jederzeit jede Aktion absetzen darf. Wenn wir sagen, dass wir eine Aktion haben, die das Licht einschaltet, ist es in Ordnung, diese Aktion zweimal hintereinander auszulösen? Wenn nicht, wie sollen wir dieses Problem dann mit Redux lösen? Nun, wir würden wahrscheinlich einen Code in den Reducer einbauen, der die Logik schützt und überprüft, ob die Lichter bereits eingeschaltet sind – vielleicht eine if -Klausel, die den aktuellen Zustand überprüft. Nun stellt sich die Frage, geht das nicht über den Rahmen des Reduzierers hinaus? Sollte der Reduzierer von solchen Grenzfällen wissen?

Was mir in Redux fehlt, ist eine Möglichkeit, das Versenden einer Aktion basierend auf dem aktuellen Status der Anwendung zu stoppen, ohne den Reducer mit bedingter Logik zu verschmutzen. Und ich möchte diese Entscheidung auch nicht auf die Ansichtsebene übertragen, wo der Aktionsersteller gefeuert wird. Bei Stent geschieht dies automatisch, da die Maschine nicht auf Aktionen reagiert, die im aktuellen Zustand nicht deklariert sind. Zum Beispiel:

 const machine = Machine.create('app', { state: { name: 'idle' }, transitions: { 'idle': { 'run': 'running', 'jump': 'jumping' }, 'running': { 'stop': 'idle' } } }); // this is fine machine.run(); // This will do nothing because at this point // the machine is in a 'running' state and there is // only 'stop' action there. machine.jump();

Die Tatsache, dass die Maschine zu einem bestimmten Zeitpunkt nur bestimmte Eingaben akzeptiert, schützt uns vor seltsamen Fehlern und macht unsere Anwendungen vorhersehbarer.

Zustände, nicht Übergänge

Redux lässt uns wie Flux in Übergängen denken. Das mentale Modell der Entwicklung mit Redux wird ziemlich stark von Aktionen angetrieben und davon, wie diese Aktionen den Zustand in unseren Reduzierern verändern. Das ist nicht schlecht, aber ich habe festgestellt, dass es sinnvoller ist, stattdessen in Zuständen zu denken – in welchen Zuständen sich die App befinden könnte und wie diese Zustände die Geschäftsanforderungen darstellen.

Fazit

Das Konzept der Zustandsmaschinen in der Programmierung, insbesondere in der UI-Entwicklung, hat mir die Augen geöffnet. Ich fing an, überall Zustandsmaschinen zu sehen, und ich habe den Wunsch, immer zu diesem Paradigma zu wechseln. Ich sehe definitiv die Vorteile, strenger definierte Zustände und Übergänge zwischen ihnen zu haben. Ich bin immer auf der Suche nach Möglichkeiten, meine Apps einfach und lesbar zu gestalten. Ich glaube, dass Zustandsmaschinen ein Schritt in diese Richtung sind. Das Konzept ist einfach und gleichzeitig leistungsstark. Es hat das Potenzial, viele Fehler zu beseitigen.