So modellieren Sie das Verhalten von Redux-Apps mithilfe von Statusdiagrammen

Statecharts: ein visueller Formalismus für komplexe Systeme

Unsere App befindet sich zu einem bestimmten Zeitpunkt in einem bestimmten Zustand, unabhängig davon, ob es uns gefällt oder nicht. Beim Codieren von Benutzeroberflächen (User Interfaces, UI) beschreiben wir diese Zustände mithilfe von Daten (z. B. eines Redux-Speichers), geben jedoch niemals jedem Zustand einen formalen Namen.

Noch wichtiger ist, dass es Ereignisse gibt, die in einem bestimmten Zustand nicht ausgelöst werden sollten.

Es stellt sich heraus, dass diese Idee der Beschreibung von Zuständen und den Ereignissen, die von einem Zustand in einen anderen übergehen, ein gut untersuchtes Konzept ist. Zustandsdiagramme bieten beispielsweise einen visuellen Formalismus zur Beschreibung des Verhaltens reaktiver Anwendungen, wie z. B. Benutzeroberflächen.

In diesem Artikel werde ich erläutern, wie das Verhalten von Redux-Apps von Komponenten, Containern oder Middlewares - also Orten, an denen wir normalerweise eine solche Logik beibehalten - entkoppelt und mithilfe eines Zustandsdiagramms vollständig beschrieben werden kann. Dies ermöglicht eine wesentlich einfachere Umgestaltung und Visualisierung des Verhaltens unserer Anwendung.

Redux und Statecharts

Redux ist ganz einfach. Wir haben eine UI-Komponente, die ein Ereignis auslöst. Wir werden dann eine Aktion auslösen, wenn dieses Ereignis eintritt. Ein Reduzierer verwendet diese Aktion, um den Speicher zu aktualisieren. Schließlich wird unsere Komponente direkt von den Aktualisierungen in den Shop eingespeist:

// Unsere UI-Komponente
Funktionszähler ({currentCount, onPlusClick}) {
  return <>
    
// Verbinden wir die Komponente mit Redux
verbinden(
  state => ({currentCount: state.currentCount}),
  dispatch => ({
    onPlusClick: () => dispatch ({type: INCREMENT})
  })
)(Zähler)
// Behandle das INCREMENT-Update mit einem Reduzierer
Funktion currentCountReducer (Zustand = 0, Aktion) {
  switch (action.type) {
    case INCREMENT:
      Rückgabestatus + 1;
    Standard:
      Rückgabezustand;
  }
}

Das ist so ziemlich alles, was Redux zu bieten hat.

Um Zustandsdiagramme einzuführen, ordnen wir unser Ereignis nicht direkt der Aktualisierungsaktion zu, sondern einer generischen Aktion, die keine Daten aktualisiert (kein Reduzierer verarbeitet dies):

// Derzeit ordnen wir unser Event dem Update zu:
// onPlusClick -> INCREMENT
// Stattdessen senden wir ein generisches Ereignis, das kein Update ist:
// onPlusClick -> CLICKED_PLUS
// auf diese weise entkoppeln wir unseren container vom wissen
// welches Update wird passieren.
// Das Zustandsdiagramm sorgt dafür, dass das richtige Update ausgelöst wird.
verbinden(
  state => ({currentCount: state.currentCount}),
  dispatch => ({
    onPlusClick: () => dispatch ({type: CLICKED_PLUS})
  })
)(Zähler)

Kein Reduzierer behandelt CLICKED_PLUS, also lassen wir stattdessen ein Zustandsdiagramm damit umgehen:

const statechart = {
  Initiale: 'Init',
  Zustände: {
    Drin: {
      on: {CLICKED_PLUS: 'Increment'}
    },
    Zuwachs: {
      onEntry: INCREMENT, // <- wird aktualisiert, wenn wir in diesen Zustand eintreten
      on: {CLICKED_PLUS: 'Increment'}
    }
  }
}

Das Zustandsdiagramm behandelt die Ereignisse, die es empfängt, ähnlich wie ein Reduzierer, jedoch nur dann, wenn es sich in einem Zustand befindet, in dem ein solches Ereignis möglich ist. Ereignisse in diesem Kontext sind Redux-Aktionen, die den Store nicht aktualisieren.

In dem oben erwähnten Beispiel beginnen wir mit dem Zustand Init. Wenn das CLICKED_PLUS-Ereignis eintritt, wechseln wir in den Increment-Status, der ein onEntry-Feld enthält. Dies macht den Zustandsdiagrammversand zu einer INCREMENT-Aktion - diesmal von einem Reduzierer, der den Speicher aktualisiert.

Sie könnten sich fragen, warum wir den Container von der Kenntnis über das Update entkoppelt haben? Wir haben es so gemacht, dass das gesamte Verhalten, wann die Aktualisierung erfolgen muss, in der JSON-Struktur des Zustandsdiagramms enthalten ist. Das heißt, es kann auch visualisiert werden:

Dies kann zu einer Verbesserung des Verhaltens unserer App führen, indem Sie einfach die JSON-Beschreibung des Statusdiagramms ändern. Lassen Sie uns unser Design verbessern, indem wir die beiden CLICKED_PLUS-Übergänge nach dem Konzept hierarchischer Zustände zu einem zusammenfassen:

Um dies zu erreichen, mussten wir nur unsere Zustandsdiagrammdefinition ändern. Unsere UI-Komponenten und Reduzierungen bleiben unberührt.

{
  Initiale: 'Init',
  Zustände: {
    Drin: {
      on: {CLICKED_PLUS: 'Init.Increment'},
      Zustände: {
        Zuwachs: {
          onEntry: INCREMENT
        }
      }
    }
  }
}

Asynchrone Nebenwirkungen

Stellen wir uns vor, dass wir beim Klicken auf einen eine HTTP-Anforderung starten möchten. So würden wir es derzeit in Redux ohne Statecharts machen:

verbinden(
  Null,
  dispatch => ({
    onFetchDataClick: () => dispatch ({type: FETCH_DATA_CLICKED})
  })
) (FetchDataButton)

Dann hätten wir wahrscheinlich ein Epos, um mit solchen Aktionen fertig zu werden. Im Folgenden verwenden wir redux-observable, aber es können auch redux-saga oder redux-thunk verwendet werden:

Funktion handleFetchDataClicked (Aktion $, speichern) {
  Rückgabeaktion $ .ofType ('FETCH_DATA_CLICKED')
    .mergeMap (action =>
      ajax ('http://foo.bar')
        .mapTo ({type: 'FETCH_DATA_SUCCESS'})
        .takeUntil (Aktion $ .ofType ('FETCH_DATA_CANCEL'))
    )
}

Auch wenn wir den Container vom Nebeneffekt abgekoppelt haben (der Container sagt einfach nur "Hey, der Datenabruf-Button wurde angeklickt"), haben wir immer noch das Problem, dass die HTTP-Anfrage ausgelöst wird, egal in welchem ​​Zustand wir uns befinden .

Was passiert, wenn FETCH_DATA_CLICKED keine HTTP-Anforderung auslösen soll?

Dieser Fall kann leicht von Zustandsdiagrammen behandelt werden. Wenn FETCH_DATA_CLICKED auftritt, wechseln wir in einen FetchingData-Status. Erst beim Eintreten in diesen Zustand (onEntry) wird die Aktion FETCH_DATA_REQUEST ausgelöst:

{
  Initiale: 'Init',
  Zustände: {
    Drin: {
      auf: {
        FETCH_DATA_CLICKED: 'FetchingData',
      },
      Initiale: 'NoData',
      Zustände: {
        ShowData: {},
        Error: {},
        Keine Daten: {}
      }
    },
    FetchingData: {
      auf: {
        FETCH_DATA_SUCCESS: 'Init.ShowData',
        FETCH_DATA_FAILURE: 'Init.Error',
        CLICKED_CANCEL: 'Init.NoData',
      },
      onEntry: 'FETCH_DATA_REQUEST',
      onExit: 'FETCH_DATA_CANCEL',
    },
  }
}

Dann ändern wir unser Epos, um basierend auf der neu hinzugefügten FETCH_DATA_REQUEST-Aktion zu reagieren:

function handleFetchDataRequest (action $, store) {
  // FETCH_DATA_REQUEST statt FETCH_DATA_CLICKED behandeln
  Rückgabeaktion $ .ofType ('FETCH_DATA_REQUEST')
    .mergeMap (action =>
      ajax ('http://foo.bar')
        .mapTo ({type: 'FETCH_DATA_SUCCESS'})
        .takeUntil (Aktion $ .ofType ('FETCH_DATA_CANCEL'))
    )
}

Auf diese Weise wird die Anforderung nur ausgelöst, wenn wir uns im Status "FetchingData" befinden.

Auf diese Weise haben wir das gesamte Verhalten innerhalb des JSON-Zustandsdiagramms verschoben, wodurch die Umgestaltung vereinfacht und die Visualisierung von Elementen ermöglicht wurde, die ansonsten im Code verborgen geblieben wären:

Eine interessante Eigenschaft dieses speziellen Entwurfs ist, dass beim Verlassen des Status FetchingData die Aktion FETCH_DATA_CANCEL ausgelöst wird. Wir können Aktionen nicht nur bei der Eingabe von Zuständen, sondern auch beim Verlassen von Zuständen auslösen. Wie in unserem Epos definiert, führt dies zum Abbruch der HTTP-Anforderung.

Es ist wichtig zu beachten, dass ich dieses spezielle HTTP-Abbruchverhalten erst nach Betrachtung der resultierenden Statusdiagramm-Visualisierung hinzugefügt habe. Durch einen einfachen Blick auf das Diagramm wurde deutlich, dass die HTTP-Anforderung beim Beenden von FetchingData bereinigt werden sollte. Dies wäre ohne eine solche visuelle Darstellung möglicherweise nicht so offensichtlich gewesen.

Mittlerweile können wir die Intuition sammeln, dass Statecharts unsere Shop-Updates steuern. Wir erfahren, welche Nebenwirkungen auftreten müssen und wann sie auftreten müssen, basierend auf dem aktuellen Status, in dem wir uns befinden.

Die wichtigste Erkenntnis hier ist, dass unsere Reduzierer und Epen immer auf der Grundlage der Ausgabeaktionen unseres Zustandsdiagramms und nicht auf der Grundlage unserer Benutzeroberfläche reagieren.

Tatsächlich kann ein Zustandsdiagramm als ein zustandsbehafteter Ereignisemitter implementiert werden: Sie teilen ihm mit, was passiert ist (auslösen eines Ereignisses), und wenn Sie sich an den letzten Zustand erinnern, in dem Sie sich befanden, erfahren Sie, was zu tun ist (Aktionen).

Problemzustandsdiagramme helfen bei der Lösung

Als UI-Entwickler ist es unsere Aufgabe, statische Bilder zum Leben zu erwecken. Dieser Prozess hat mehrere Probleme:

  • Wenn wir statische Bilder in Code konvertieren, verlieren wir das umfassende Verständnis unserer App. Mit zunehmender Verbreitung unserer App wird es immer schwieriger zu verstehen, welcher Codeabschnitt für die einzelnen Bilder verantwortlich ist.
  • Nicht alle Fragen können mit einer Reihe von Bildern beantwortet werden. - Was passiert, wenn der Benutzer wiederholt auf die Schaltfläche klickt? Was ist, wenn der Benutzer die Anfrage während des Flugs stornieren möchte?
  • Ereignisse sind in unserem Code verstreut und haben unvorhersehbare Auswirkungen - Was passiert genau, wenn der Benutzer auf eine Schaltfläche klickt? Wir brauchen eine bessere Abstraktion, die uns hilft, die Auswirkungen von Zündereignissen zu verstehen.
  • Viele isFetching-, isShowing- und isDisabled-Variablen - Wir müssen alle Änderungen in unserer Benutzeroberfläche nachverfolgen.

Zustandsdiagramme helfen, diese Probleme zu lösen, indem sie einen strengen visuellen Formalismus des Verhaltens unserer App bereitstellen. Durch das Zeichnen eines Zustandsdiagramms erhalten wir ein umfassendes Verständnis unserer App, mit dem wir Fragen anhand visueller Hinweise beantworten können.

Während dieses Vorgangs werden alle Zustände einer App untersucht und Ereignisse werden explizit gekennzeichnet, sodass wir vorhersagen können, was nach einem bestimmten Ereignis passieren wird.

Darüber hinaus kann ein Zustandsdiagramm direkt aus den Modellen der Designer erstellt werden, sodass auch Nichtingenieure verstehen können, was gerade passiert, ohne sich mit dem tatsächlichen Code befassen zu müssen.

Mehr erfahren

Als konkretes Beispiel hierfür habe ich Redux-Statecharts erstellt, eine Redux-Middleware, die wie in den vorherigen Beispielen gezeigt verwendet werden kann. Es verwendet die xstate-Bibliothek - eine reine Funktion zum Übergang eines Zustandsdiagramms.

Wenn Sie mehr über Statecharts erfahren möchten, finden Sie hier eine hervorragende Ressource: https://statecharts.github.io/

Schauen Sie sich auch meine Präsentation zum Thema an: Sind Statecharts das nächste große UI-Paradigma?