Robuste React-Benutzeroberflächen mit endlichen Zustandsautomaten

Avatar of David Khourshid
David Khourshid am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Benutzeroberflächen können durch zwei Dinge ausgedrückt werden

  1. Der Zustand der Benutzeroberfläche
  2. Aktionen, die diesen Zustand ändern können

Von Kreditkartenlesegeräten und Bildschirmen von Zapfsäulen bis hin zur Software, die Ihr Unternehmen entwickelt, reagieren Benutzeroberflächen auf die Aktionen des Benutzers und andere Quellen und ändern ihren Zustand entsprechend. Dieses Konzept ist nicht nur auf die Technologie beschränkt, es ist ein grundlegender Bestandteil, wie *alles* funktioniert

Zu jeder Aktion gibt es eine gleichwertige und entgegengesetzte Reaktion.

– Isaac Newton

Dies ist ein Konzept, das wir auf die Entwicklung besserer Benutzeroberflächen anwenden können, aber bevor wir dorthin gelangen, möchte ich, dass Sie etwas ausprobieren. Betrachten Sie eine Fotogalerie-Oberfläche mit diesem Benutzerinteraktionsfluss

  1. Zeigen Sie ein Suchfeld und eine Suchschaltfläche an, mit denen der Benutzer nach Fotos suchen kann
  2. Wenn die Suchschaltfläche angeklickt wird, rufen Sie Fotos mit dem Suchbegriff von Flickr ab
  3. Zeigen Sie die Suchergebnisse in einem Raster aus kleinen Fotos an
  4. Wenn ein Foto angeklickt/getippt wird, zeigen Sie das Foto in voller Größe an
  5. Wenn ein Vollbildfoto erneut angeklickt/getippt wird, kehren Sie zur Galerieansicht zurück

Überlegen Sie nun, wie Sie es entwickeln würden. Versuchen Sie vielleicht sogar, es in React zu programmieren. Ich warte; ich bin nur ein Artikel. Ich gehe nirgendwohin.

Fertig? Großartig! Das war nicht zu schwierig, oder? Denken Sie nun über die folgenden Szenarien nach, die Sie vielleicht vergessen haben

  • Was passiert, wenn der Benutzer wiederholt auf die Suchschaltfläche klickt?
  • Was passiert, wenn der Benutzer die Suche abbrechen möchte, während sie noch läuft?
  • Ist die Suchschaltfläche während der Suche deaktiviert?
  • Was passiert, wenn der Benutzer die deaktivierte Schaltfläche bösartig aktiviert?
  • Gibt es eine Anzeige dafür, dass die Ergebnisse geladen werden?
  • Was passiert, wenn ein Fehler auftritt? Kann der Benutzer die Suche wiederholen?
  • Was passiert, wenn der Benutzer sucht und dann auf ein Foto klickt? Was sollte passieren?

Dies sind nur *einige* der potenziellen Probleme, die während der Planung, Entwicklung oder beim Testen auftreten können. Wenig ist schlimmer in der Softwareentwicklung, als zu glauben, alle möglichen Anwendungsfälle abgedeckt zu haben, und dann neue Edge Cases zu entdecken (oder zu erhalten), die Ihren Code weiter verkomplizieren, sobald Sie sie berücksichtigen. Es ist besonders schwierig, in ein bestehendes Projekt einzusteigen, bei dem all diese Anwendungsfälle undokumentiert sind, sondern stattdessen in Spaghetti-Code versteckt sind und darauf warten, dass Sie ihn entschlüsseln.

Das Offensichtliche darlegen

Was wäre, wenn wir alle möglichen UI-Zustände ermitteln könnten, die aus allen möglichen Aktionen, die auf jeden Zustand angewendet werden, resultieren? Und was wäre, wenn wir diese Zustände, Aktionen und Übergänge zwischen Zuständen visualisieren könnten? Designer tun dies intuitiv, in sogenannten „User Flows“ (oder „UX Flows“), um darzustellen, wie der nächste Zustand der UI in Abhängigkeit von der Benutzerinteraktion aussehen soll.

Bildnachweis: Vereinfachter Checkout-Prozess von Michael Pons

In der Informatik gibt es ein Berechnungsmodell namens endlicher Automat, oder „endliche Zustandsautomaten“ (FSM), das die gleiche Art von Information ausdrücken kann. Das heißt, sie beschreiben, welcher Zustand als nächstes eintritt, wenn eine Aktion auf den aktuellen Zustand angewendet wird. Genau wie User Flows können diese endlichen Zustandsautomaten klar und eindeutig visualisiert werden. Hier ist zum Beispiel das Zustandsübergangsdiagramm, das den FSM einer Ampel beschreibt

Was ist ein endlicher Zustandsautomat?

Ein Zustandsautomat ist eine nützliche Methode zur Modellierung des Verhaltens in einer Anwendung: Für jede Aktion gibt es eine Reaktion in Form einer Zustandsänderung. Eine klassische endliche Zustandsmaschine hat 5 Teile

  1. Eine Menge von Zuständen (z. B. idle, loading, success, error usw.)
  2. Eine Menge von Aktionen (z. B. SEARCH, CANCEL, SELECT_PHOTO usw.)
  3. Ein Anfangszustand (z. B. idle)
  4. Eine Übergangsfunktion (z. B. transition('idle', 'SEARCH') == 'loading')
  5. Endzustände (die für diesen Artikel nicht relevant sind.)

Deterministische endliche Zustandsautomaten (mit denen wir uns befassen werden) haben ebenfalls einige Einschränkungen

  • Es gibt eine endliche Anzahl möglicher Zustände
  • Es gibt eine endliche Anzahl möglicher Aktionen (das sind die „endlichen“ Teile)
  • Die Anwendung kann sich zu jedem Zeitpunkt nur in einem dieser Zustände befinden
  • Gegeben ein currentState und eine action, muss die Übergangsfunktion immer denselben nextState zurückgeben (das ist der „deterministische“ Teil)

Darstellung von endlichen Zustandsautomaten

Ein endlicher Zustandsautomat kann als eine Zuordnung von einem state zu seinen „Übergängen“ dargestellt werden, wobei jeder Übergang eine action und der nextState ist, der auf diese Aktion folgt. Diese Zuordnung ist ein einfaches JavaScript-Objekt.

Betrachten wir ein Beispiel einer amerikanischen Ampel, eines der einfachsten FSM-Beispiele. Nehmen wir an, wir starten auf green, wechseln dann nach einiger TIMER zu yellow und dann nach einem weiteren TIMER zu RED und nach einem weiteren TIMER zurück zu green

const machine = {
  green: { TIMER: 'yellow' },
  yellow: { TIMER: 'red' },
  red: { TIMER: 'green' }
};
const initialState = 'green';

Eine Übergangsfunktion beantwortet die Frage

Was ist der nächste Zustand, wenn der aktuelle Zustand und eine Aktion gegeben sind?

Bei unserem Setup ist der Übergang zum nächsten Zustand basierend auf einer Aktion (in diesem Fall TIMER) nur ein Nachschlagen des currentState und der action im machine-Objekt, da

  • machine[currentState] gibt uns die nächste Aktionszuordnung, z. B.: machine['green'] == {TIMER: 'yellow'}
  • machine[currentState][action] gibt uns den nächsten Zustand aus der Aktion, z. B.: machine['green']['TIMER'] == 'yellow'
// ...
function transition(currentState, action) {
  return machine[currentState][action];
}

transition('green', 'TIMER');
// => 'yellow'

Anstatt if/else- oder switch-Anweisungen zu verwenden, um den nächsten Zustand zu bestimmen, z. B. if (currentState === 'green') return 'yellow';, haben wir die gesamte Logik in ein einfaches JavaScript-Objekt verschoben, das in JSON serialisiert werden kann. Das ist eine Strategie, die sich in Bezug auf Tests, Visualisierung, Wiederverwendung, Analyse, Flexibilität und Konfigurierbarkeit stark auszahlen wird.

Siehe den Pen Einfaches Beispiel für einen endlichen Zustandsautomaten von David Khourshid (@davidkpiano) auf CodePen.

Endliche Zustandsautomaten in React

Betrachten wir ein komplexeres Beispiel, wie wir unsere Galerie-App mit einem endlichen Zustandsautomaten darstellen können. Die App kann sich in einem von mehreren Zuständen befinden

  • start – die Ansicht der anfänglichen Suchseite
  • loading – Ansicht zum Abrufen von Suchergebnissen
  • error – Ansicht, wenn die Suche fehlschlägt
  • gallery – Ansicht der erfolgreichen Suchergebnisse
  • photo – Ansicht eines einzelnen detaillierten Fotos

Und mehrere Aktionen können ausgeführt werden, entweder vom Benutzer oder von der App selbst

  • SEARCH – der Benutzer klickt auf die Schaltfläche „Suchen“
  • SEARCH_SUCCESS – die Suche war erfolgreich mit den abgefragten Fotos
  • SEARCH_FAILURE – die Suche ist aufgrund eines Fehlers fehlgeschlagen
  • CANCEL_SEARCH – der Benutzer klickt auf die Schaltfläche „Suche abbrechen“
  • SELECT_PHOTO – der Benutzer klickt auf ein Foto in der Galerie
  • EXIT_PHOTO – der Benutzer klickt, um die detaillierte Fotoansicht zu beenden

Der beste Weg, um zunächst zu visualisieren, wie diese Zustände und Aktionen zusammenkommen, sind zwei sehr mächtige Werkzeuge: Bleistift und Papier. Zeichnen Sie Pfeile zwischen den Zuständen und beschriften Sie die Pfeile mit den Aktionen, die Zustandsübergänge zwischen den Zuständen verursachen

Wir können diese Übergänge nun in einem Objekt darstellen, genau wie im Beispiel der Ampel

const galleryMachine = {
  start: {
    SEARCH: 'loading'
  },
  loading: {
    SEARCH_SUCCESS: 'gallery',
    SEARCH_FAILURE: 'error',
    CANCEL_SEARCH: 'gallery'
  },
  error: {
    SEARCH: 'loading'
  },
  gallery: {
    SEARCH: 'loading',
    SELECT_PHOTO: 'photo'
  },
  photo: {
    EXIT_PHOTO: 'gallery'
  }
};

const initialState = 'start';

Nun sehen wir, wie wir diese Konfiguration des endlichen Zustandsautomaten und die Übergangsfunktion in unsere Galerie-App integrieren können. Im Zustand der App-Komponente wird es eine einzelne Eigenschaft geben, die den aktuellen endlichen Zustand angibt: gallery

class App extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      gallery: 'start', // initial finite state
      query: '',
      items: []
    };
  }
  // ...

Die transition-Funktion wird eine Methode dieser App-Klasse sein, damit wir den aktuellen endlichen Zustand abrufen können

  // ...
  transition(action) {
    const currentGalleryState = this.state.gallery;
    const nextGalleryState =
      galleryMachine[currentGalleryState][action.type];

    if (nextGalleryState) {
      const nextState = this.command(nextGalleryState, action);

      this.setState({
        gallery: nextGalleryState,
        ...nextState // extended state
      });
    }
  }
  // ...

Dies ähnelt der zuvor beschriebenen Funktion transition(currentState, action), mit einigen Unterschieden

  • Die action ist ein Objekt mit einer Eigenschaft type, die den String-Aktionstyp angibt, z. B. type: 'SEARCH'
  • Nur die action wird übergeben, da wir den aktuellen endlichen Zustand aus this.state.gallery abrufen können
  • Der gesamte App-Zustand wird mit dem nächsten endlichen Zustand, d. h. nextGalleryState, sowie mit jedem *erweiterten Zustand* (nextState) aktualisiert, der sich aus der Ausführung eines Befehls basierend auf der Nutzlast des nächsten Zustands und der Aktion ergibt (siehe Abschnitt „Befehle ausführen“)

Befehle ausführen

Wenn eine Zustandsänderung eintritt, können „Seiteneffekte“ (oder „Befehle“, wie wir sie nennen werden) ausgeführt werden. Wenn ein Benutzer beispielsweise auf die Schaltfläche „Suchen“ klickt und eine 'SEARCH'-Aktion ausgelöst wird, wechselt der Zustand zu 'loading', und eine asynchrone Flickr-Suche sollte ausgeführt werden (andernfalls wäre 'loading' eine Lüge, und Entwickler sollten niemals lügen).

Wir können diese Seiteneffekte in einer command(nextState, action)-Methode behandeln, die bestimmt, was ausgeführt werden soll, basierend auf dem nächsten endlichen Zustand und der Aktionsnutzlast, sowie wie der *erweiterte Zustand* aussehen soll

  // ...
  command(nextState, action) {
    switch (nextState) {
      case 'loading':
        // execute the search command
        this.search(action.query);
        break;
      case 'gallery':
        if (action.items) {
          // update the state with the found items
          return { items: action.items };
        }
        break;
      case 'photo':
        if (action.item) {
          // update the state with the selected photo item
          return { photo: action.item };
        }
        break;
      default:
        break;
    }
  }
  // ...

Aktionen können Nutzlasten enthalten, die über den type der Aktion hinausgehen und mit denen der App-Zustand aktualisiert werden muss. Wenn beispielsweise eine Aktion 'SEARCH' erfolgreich ist, kann eine Aktion 'SEARCH_SUCCESS' mit den items aus dem Suchergebnis ausgelöst werden

    // ...
    fetchJsonp(
      `https://api.flickr.com/services/feeds/photos_public.gne?lang=en-us&format=json&tags=${encodedQuery}`,
      { jsonpCallback: 'jsoncallback' })
      .then(res => res.json())
      .then(data => {
        this.transition({ type: 'SEARCH_SUCCESS', items: data.items });
      })
      .catch(error => {
        this.transition({ type: 'SEARCH_FAILURE' });
      });
    // ...

Die obige command()-Methode gibt sofort jeden erweiterten Zustand (d. h. Zustand, der vom endlichen Zustand abweicht) zurück, mit dem this.state in this.setState(...) aktualisiert werden soll, zusammen mit der endlichen Zustandsänderung.

Die finale maschinenkontrollierte App

Da wir den endlichen Zustandsautomaten für die App deklarativ konfiguriert haben, können wir die richtige Benutzeroberfläche auf sauberere Weise rendern, indem wir bedingt basierend auf dem aktuellen endlichen Zustand rendern

  // ...
  render() {
    const galleryState = this.state.gallery;

    return (
      <div className="ui-app" data-state={galleryState}>
        {this.renderForm(galleryState)}
        {this.renderGallery(galleryState)}
        {this.renderPhoto(galleryState)}
      </div>
    );
  }
  // ...

Das Endergebnis

Siehe den Pen Galerie-App mit endlichen Zustandsautomaten von David Khourshid (@davidkpiano) auf CodePen.

Zustand in CSS

Sie haben möglicherweise data-state={galleryState} im obigen Code bemerkt. Durch Setzen dieses Datenattributs können wir jeden Teil unserer App mithilfe eines Attributselektors bedingt stylen

.ui-app {
  // ...
  
  &[data-state="start"] {
    justify-content: center;
  }
  
  &[data-state="loading"] {
    .ui-item {
      opacity: .5;
    }
  }
}

Dies ist besser als die Verwendung von className, da Sie die Einschränkung durchsetzen können, dass nur ein Wert gleichzeitig für data-state gesetzt werden kann, und die Spezifität die gleiche ist wie bei der Verwendung einer Klasse. Attributselektoren werden auch in den meisten gängigen CSS-in-JS-Lösungen unterstützt.

Vorteile und Ressourcen

Die Verwendung von endlichen Zustandsautomaten zur Beschreibung des Verhaltens komplexer Anwendungen ist nichts Neues. Traditionell geschah dies mit switch- und goto-Anweisungen, aber durch die Beschreibung endlicher Zustandsautomaten als eine deklarative Zuordnung zwischen Zuständen, Aktionen und nächsten Zuständen können Sie diese Daten zur Visualisierung von Zustandsübergängen verwenden

Gallery app state transition diagram

Darüber hinaus ermöglicht die Verwendung deklarativer endlicher Zustandsautomaten Ihnen

  • Anwendungslogik überall speichern, teilen und konfigurieren – ähnliche Komponenten, andere Apps, in Datenbanken, in anderen Sprachen usw.
  • Die Zusammenarbeit mit Designern und Projektmanagern erleichtern
  • Zustandsübergänge statisch analysieren und optimieren, einschließlich Zuständen, die unmöglich zu erreichen sind
  • Anwendungslogik ohne Angst leicht ändern
  • Integrationstests automatisieren

Fazit und Erkenntnisse

Endliche Zustandsautomaten sind eine *Abstraktion* zur Modellierung der Teile Ihrer App, die als endliche Zustände dargestellt werden können, und fast alle Apps haben diese Teile. Die in diesem Artikel vorgestellten FSM-Codierungsmuster

  • Können mit jedem bestehenden State-Management-Setup verwendet werden; z. B. Redux oder MobX
  • Können an jedes Framework (nicht nur React) oder gar kein Framework angepasst werden
  • Sind nicht in Stein gemeißelt; der Entwickler kann die Muster an seinen Programmierstil anpassen
  • Sind nicht für jede Situation oder jeden Anwendungsfall anwendbar

Ich ermutige Sie von nun an, wenn Sie auf „Boolean Flag“-Variablen wie isLoaded oder isSuccess stoßen, innezuhalten und darüber nachzudenken, wie Ihr App-Zustand stattdessen als endlicher Zustandsautomat modelliert werden kann. Auf diese Weise können Sie Ihre App refaktorisieren, um den Zustand als state === 'loaded' oder state === 'success' darzustellen, wobei aufzählbare Zustände anstelle von booleschen Flags verwendet werden.

Ressourcen

Ich habe auf der React Rally 2017 einen Vortrag über die Verwendung von endlichen Automaten und Statecharts zur Erstellung besserer Benutzeroberflächen gehalten, wenn Sie mehr über die Motivation und Prinzipien erfahren möchten

Folien: Unendlich bessere Benutzeroberflächen mit endlichen Automaten

Hier sind einige weitere Ressourcen