Dieser Beitrag ist eine Einführung in XState, wie es in einem Svelte-Projekt verwendet werden könnte. XState ist im JavaScript-Ökosystem einzigartig. Es wird Ihren DOM nicht mit Ihrem Anwendungszustand synchron halten, aber es wird Ihnen helfen, den Zustand Ihrer Anwendung zu verwalten, indem es Ihnen ermöglicht, ihn als endliche Zustandsmaschine (FSM) zu modellieren.
Ein tiefer Einblick in Zustandsmaschinen und formale Sprachen sprengt den Rahmen dieses Beitrags, aber Jon Bellah tut dies in einem anderen CSS-Tricks-Artikel. Vorerst stellen Sie sich eine FSM als ein Flussdiagramm vor. Flussdiagramme haben eine Reihe von Zuständen, dargestellt als Kreise, und Pfeile, die von einem Zustand zum nächsten führen und einen Übergang von einem Zustand zum nächsten bedeuten. Zustandsmaschinen können mehr als einen Pfeil haben, der aus einem Zustand herausführt, oder gar keinen, wenn es ein Endzustand ist, und sie können sogar Pfeile haben, die aus einem Zustand herausführen und direkt auf denselben Zustand zurückzeigen.
Wenn das alles überwältigend klingt, entspannen Sie sich, wir werden uns alle Details schön langsam ansehen. Vorerst lautet die übergeordnete Ansicht, dass wir beim Modellieren unserer Anwendung als Zustandsmaschine verschiedene „Zustände“ erstellen werden, in denen sich unsere Anwendung befinden kann (verstehen Sie ... Zustandsmaschine ... Zustände?), und die Ereignisse, die geschehen und Zustandsänderungen verursachen, werden die Pfeile zwischen diesen Zuständen sein. XState nennt die Zustände „states“ und die Pfeile zwischen den Zuständen „actions“.
Unser Beispiel
XState hat eine Lernkurve, was es schwierig zu vermitteln macht. Mit einem zu konstruierten Anwendungsfall erscheint es unnötig komplex. Erst wenn der Code einer Anwendung etwas verheddert ist, glänzt XState. Das macht das Schreiben darüber schwierig. Nichtsdestotrotz ist das Beispiel, das wir uns ansehen werden, ein Autocomplete-Widget (manchmal auch Autosuggest genannt) oder ein Eingabefeld, das beim Anklicken eine Liste von auswählbaren Elementen anzeigt, die sich während der Eingabe im Feld filtern.
Für diesen Beitrag werden wir uns ansehen, wie der Animationscode bereinigt wird. Hier ist der Ausgangspunkt
Dies ist tatsächlicher Code aus meiner svelte-helpers-Bibliothek, wenn auch mit unnötigen Teilen, die für diesen Beitrag entfernt wurden. Sie können auf das Eingabefeld klicken und die Elemente filtern, aber Sie können nichts auswählen, nicht durch die Elemente „Pfeil nach unten“ navigieren, nicht mit der Maus darüber fahren usw. Ich habe allen Code entfernt, der für diesen Beitrag irrelevant ist.
Wir werden uns mit der Animation der Elementliste beschäftigen. Wenn Sie auf das Eingabefeld klicken und die Ergebnisliste zuerst gerendert wird, wollen wir sie nach unten animieren. Während Sie tippen und filtern, werden Änderungen an den Dimensionen der Liste größer und kleiner animiert. Und wenn das Eingabefeld den Fokus verliert oder Sie auf ESC klicken, animieren wir die Höhe der Liste auf Null, während wir sie ausblenden, und entfernen sie dann aus dem DOM (und nicht vorher). Um die Dinge interessanter (und angenehmer für den Benutzer) zu gestalten, verwenden wir für das Öffnen eine andere Federkonfiguration als für das Schließen, sodass die Liste etwas schneller oder steifer schließt, damit unnötige UX nicht zu lange auf dem Bildschirm verbleibt.
Wenn Sie sich fragen, warum ich keine Svelte-Übergänge verwende, um die Animationen in und aus dem DOM zu steuern, liegt das daran, dass ich auch die Dimensionen der Liste animiere, wenn sie geöffnet ist und der Benutzer filtert, und die Koordination zwischen Übergangs- und regulären Federanimationen ist viel schwieriger, als einfach auf ein Feder-Update zu warten, bis es Null erreicht, bevor ein Element aus dem DOM entfernt wird. Was passiert zum Beispiel, wenn der Benutzer schnell tippt und die Liste während der Animation einblendet? Wie wir sehen werden, macht XState solche komplizierten Zustandsübergänge einfach.
Eingrenzung des Problems
Werfen wir einen Blick auf den Code aus dem bisherigen Beispiel. Wir haben eine Variable open, die steuert, wann die Liste geöffnet ist, und eine Eigenschaft resultsListVisible, die steuert, ob sie im DOM sein soll. Wir haben auch eine Variable closing, die steuert, ob sich die Liste im Schließvorgang befindet.
In Zeile 28 gibt es eine Methode inputEngaged, die ausgeführt wird, wenn das Eingabefeld angeklickt oder fokussiert wird. Vorerst notieren wir nur, dass sie open und resultsListVisible auf true setzt. inputChanged wird aufgerufen, wenn der Benutzer in das Eingabefeld tippt, und setzt open auf true. Das ist für den Fall, dass das Eingabefeld fokussiert ist, der Benutzer Escape drückt, um es zu schließen, und dann zu tippen beginnt, sodass es wieder geöffnet werden kann. Und natürlich läuft die Funktion inputBlurred, wenn Sie es erwarten, und setzt closing auf true und open auf false.
Lassen Sie uns dieses verhedderte Durcheinander auseinandernehmen und sehen, wie die Animationen funktionieren. Beachten Sie slideInSpring und opacitySpring oben. Das erstere verschiebt die Liste nach oben und unten und passt die Größe an, während der Benutzer tippt. Das letztere blendet die Liste aus, wenn sie versteckt ist. Wir werden uns hauptsächlich auf slideInSpring konzentrieren.
Werfen Sie einen Blick auf das Monstrum von einer Funktion namens setSpringDimensions. Diese aktualisiert unsere Gleitfeder. Wenn wir uns auf die wichtigen Teile konzentrieren, nehmen wir ein paar boolesche Eigenschaften. Wenn die Liste sich öffnet, setzen wir die Konfiguration der Öffnungsfeder, setzen sofort die Breite der Liste (ich möchte, dass die Liste nur nach unten gleitet, nicht nach unten und heraus) über die Konfiguration { hard: true } und setzen dann die Höhe. Wenn wir schließen, animieren wir auf Null, und wenn die Animation abgeschlossen ist, setzen wir resultsListVisible auf false (wenn die Schließanimation unterbrochen wird, ist Svelte schlau genug, das Promise **nicht** aufzulösen, sodass der Callback niemals ausgeführt wird). Schließlich wird diese Methode auch jedes Mal aufgerufen, wenn sich die Größe der Ergebnisliste ändert, d. h. wenn der Benutzer filtert. Wir haben woanders einen ResizeObserver eingerichtet, um dies zu verwalten.
Spaghetti pur
Nehmen wir diesen Code unter die Lupe.
- Wir haben unsere Variable
open, die verfolgt, ob die Liste geöffnet ist. - Wir haben die Variable
resultsListVisible, die verfolgt, ob die Liste im DOM sein sollte (und auf false gesetzt wird, nachdem die Schließanimation abgeschlossen ist). - Wir haben die Variable
closing, die verfolgt, ob sich die Liste im Schließvorgang befindet, was wir im Handler für den Fokus/Klick des Eingabefelds überprüfen, damit wir die Schließanimation umkehren können, wenn der Benutzer das Widget schnell wieder aktiviert, bevor es vollständig geschlossen ist. - Wir haben auch
setSpringDimensions, das wir an vier verschiedenen Stellen aufrufen. Es setzt unsere Federn je nachdem, ob die Liste geöffnet, geschlossen oder gerade beim Ändern der Größe im geöffneten Zustand ist (d. h. wenn der Benutzer die Liste filtert). - Zuletzt haben wir eine Svelte-Aktion
resultsListRendered, die ausgeführt wird, wenn das DOM-Element der Ergebnisliste gerendert wird. Sie startet unserenResizeObserver, und wenn der DOM-Knoten demontiert wird, setzt sieclosingauf false.
Haben Sie den Fehler bemerkt? Wenn die ESC-Taste gedrückt wird, setze ich nur open auf false. Ich habe vergessen, closing auf true zu setzen und setSpringDimensions(false, true) aufzurufen. Dieser Fehler wurde nicht absichtlich für diesen Blogbeitrag konstruiert! Das ist ein tatsächlicher Fehler, den ich gemacht habe, als ich die Animationen dieses Widgets überarbeitet habe. Ich könnte den Code in inputBlured einfach kopieren und dort einfügen, wo die Escape-Taste abgefangen wird, oder ihn sogar in eine neue Funktion verschieben und ihn von beiden Stellen aus aufrufen. Dieser Fehler ist nicht grundsätzlich schwer zu lösen, aber er erhöht die kognitive Belastung des Codes.
Es gibt viele Dinge, die wir im Auge behalten müssen, aber am schlimmsten ist, dass dieser Zustand über das gesamte Modul verstreut ist. Nehmen Sie jedes der oben beschriebenen Zustände und verwenden Sie die Suchfunktion von CodeSandbox, um alle Stellen anzuzeigen, an denen dieser Zustand verwendet wird. Sie werden sehen, wie Ihr Cursor über die Datei springt. Stellen Sie sich nun vor, Sie sind neu in diesem Code und versuchen, ihn zu verstehen. Denken Sie an das wachsende mentale Modell all dieser Zustandsstücke, das Sie im Auge behalten müssen, und verstehen Sie, wie es basierend auf all den Stellen funktioniert, an denen es existiert. Wir waren alle schon da; es ist scheußlich. XState bietet einen besseren Weg; sehen wir uns an, wie.
Einführung in XState
Lassen Sie uns einen Schritt zurücktreten. Wäre es nicht einfacher, unser Widget in Bezug auf seinen Zustand zu modellieren, mit Ereignissen, die während der Benutzerinteraktion auftreten, die Nebeneffekte und Übergänge zu neuen Zuständen verursachen? Natürlich, aber das haben wir bereits getan; das Problem ist, dass der Code überall verstreut ist. XState gibt uns die Möglichkeit, unseren Zustand auf diese Weise richtig zu modellieren.
Erwartungen setzen
Erwarten Sie nicht, dass XState unsere Komplexität magisch verschwinden lässt. Wir müssen unsere Federn immer noch koordinieren, die Federkonfiguration basierend auf offenen und schließenden Zuständen anpassen, Resizes handhaben usw. Was XState uns bietet, ist die Möglichkeit, diesen Zustand-Management-Code auf eine Weise zu zentralisieren, die leicht zu verstehen und anzupassen ist. Tatsächlich wird sich unsere Gesamtzahl der Zeilen aufgrund unserer Zustandsautomaten-Einrichtung etwas erhöhen. Werfen wir einen Blick darauf.
Ihr erster Zustandsautomat
Lassen Sie uns direkt einsteigen und sehen, wie ein minimaler Zustandsautomat aussieht. Ich verwende das FSM-Paket von XState, eine minimale, abgespeckte Version von XState mit einer winzigen Bundle-Größe von 1 KB, perfekt für Bibliotheken (wie ein Autosuggest-Widget). Es hat nicht viele fortgeschrittene Funktionen wie das vollständige XState-Paket, aber wir würden sie für unseren Anwendungsfall nicht benötigen und sie für einen einführenden Beitrag wie diesen nicht wollen.
Der Code für unseren Zustandsautomaten ist unten, und die interaktive Demo finden Sie bei Code Sandbox. Es gibt viel zu sehen, aber wir werden es kurz besprechen. Und um es klar zu sagen, es funktioniert noch nicht.
const stateMachine = createMachine(
{
initial: "initial",
context: {
open: false,
node: null
},
states: {
initial: {
on: { OPEN: "open" }
},
open: {
on: {
RENDERED: { actions: "rendered" },
RESIZE: { actions: "resize" },
CLOSE: "closing"
},
entry: "opened"
},
closing: {
on: {
OPEN: { target: "open", actions: ["resize"] },
CLOSED: "closed"
},
entry: "close"
},
closed: {
on: {
OPEN: "open"
},
entry: "closed"
}
}
},
{
actions: {
opened: assign(context => {
return { ...context, open: true };
}),
rendered: assign((context, evt) => {
const { node } = evt;
return { ...context, node };
}),
close() {},
resize(context) {},
closed: assign(() => {
return { open: false, node: null };
})
}
}
);
Gehen wir von oben nach unten. Die Eigenschaft initial steuert den Anfangszustand, den ich „initial“ genannt habe. context sind die Daten, die unserem Zustandsautomaten zugeordnet sind. Ich speichere einen booleschen Wert dafür, ob die Ergebnisliste derzeit geöffnet ist, sowie ein node-Objekt für dieselbe Ergebnisliste. Als Nächstes sehen wir unsere Zustände. Jeder Zustand ist ein Schlüssel in der Eigenschaft states. Für die meisten Zustände sehen Sie eine Eigenschaft on und eine Eigenschaft entry.
on konfiguriert Ereignisse. Für jedes Ereignis können wir zu einem neuen Zustand übergehen; wir können Nebeneffekte ausführen, genannt actions; oder beides. Wenn zum Beispiel das Ereignis OPEN im Zustand initial auftritt, wechseln wir in den Zustand open. Wenn das Ereignis RENDERED im Zustand open auftritt, führen wir die Aktion rendered aus. Und wenn das Ereignis OPEN im Zustand closing auftritt, wechseln wir in den Zustand open und führen auch die Resize-Aktion aus. Das Feld entry, das Sie bei den meisten Zuständen sehen, konfiguriert eine Aktion, die automatisch ausgeführt wird, wenn ein Zustand betreten wird. Es gibt auch exit-Aktionen, obwohl wir sie hier nicht benötigen.
Wir haben noch ein paar Dinge zu behandeln. Sehen wir uns an, wie die Daten oder der Kontext unseres Zustandsautomaten geändert werden können. Wenn wir möchten, dass eine Aktion den Kontext modifiziert, verpacken wir sie in assign und geben den neuen Kontext aus unserer Aktion zurück; wenn wir keine Verarbeitung benötigen, können wir den neuen Zustand direkt an assign übergeben. Wenn unsere Aktion den Kontext nicht aktualisiert (d. h. sie dient nur Nebeneffekten), verpacken wir unsere Aktionsfunktion nicht in assign und führen einfach die benötigten Nebeneffekte aus.
Veränderung in unserem Zustandsautomaten bewirken
Wir haben ein cooles Modell für unseren Zustandsautomaten, aber wie *führen* wir ihn aus? Wir verwenden die Funktion interpret.
const stateMachineService = interpret(stateMachine).start();
Nun ist stateMachineService unser laufender Zustandsautomat, auf dem wir Ereignisse auslösen können, um unsere Übergänge und Aktionen zu erzwingen. Um ein Ereignis auszulösen, rufen wir send auf und übergeben den Ereignisnamen und dann optional das Ereignisobjekt. Zum Beispiel haben wir in unserer Svelte-Aktion, die ausgeführt wird, wenn die Ergebnisliste zum ersten Mal im DOM montiert wird, Folgendes:
stateMachineService.send({ type: "RENDERED", node });
So erhält die gerenderte Aktion den Knoten für die Ergebnisliste. Wenn Sie sich den Rest der Datei AutoComplete.svelte ansehen, werden Sie sehen, dass der gesamte Ad-hoc-Zustandsmanagement-Code durch einzelne Zeilen von Ereignisverteilungen ersetzt wurde. Im Ereignishandler für unseren Klick/Fokus auf das Eingabefeld führen wir das Ereignis OPEN aus. Unser ResizeObserver löst das Ereignis RESIZE aus. Und so weiter.
Lassen Sie uns einen Moment innehalten und die Dinge würdigen, die uns XState hier kostenlos bietet. Schauen wir uns den Handler an, der ausgeführt wird, wenn unser Eingabefeld angeklickt oder fokussiert wird, bevor wir XState hinzugefügt haben.
function inputEngaged(evt) {
if (closing) {
setSpringDimensions();
}
open = true;
resultsListVisible = true;
}
Zuvor haben wir geprüft, ob wir uns im Schließvorgang befanden, und falls ja, eine Neuberechnung unserer Gleitfeder erzwungen. Andernfalls haben wir unser Widget geöffnet. Aber was passierte, wenn wir auf das Eingabefeld klickten, als es bereits geöffnet war? Derselbe Code wurde erneut ausgeführt. Glücklicherweise machte das keinen wirklichen Unterschied. Svelte ist es egal, ob wir open und resultsListVisible auf die Werte zurücksetzen, die sie bereits hatten. Aber diese Bedenken verschwinden mit XState. Die neue Version sieht so aus:
function inputEngaged(evt) {
stateMachineService.send("OPEN");
}
Wenn sich unser Zustandsautomat bereits im offenen Zustand befindet und wir das Ereignis OPEN auslösen, passiert nichts, da für diesen Zustand kein Ereignis OPEN konfiguriert ist. Und diese spezielle Behandlung für den Fall, dass das Eingabefeld angeklickt wird, während die Ergebnisse geschlossen werden? Das wird ebenfalls direkt in der Zustandsautomaten-Konfiguration gehandhabt – beachten Sie, wie das Ereignis OPEN die Aktion resize anhängt, wenn es aus dem Zustand closing ausgeführt wird.
Und natürlich haben wir den Fehler der ESC-Taste von zuvor behoben. Jetzt drückt die Taste einfach das Ereignis CLOSE aus, und das war's.
Abschluss
Das Ende ist fast anti-klimaktisch. Wir müssen all die Arbeit, die wir vorher geleistet haben, einfach an die richtige Stelle in unseren Aktionen verschieben. XState nimmt uns nicht die Notwendigkeit, Code zu schreiben, ab; es bietet nur einen strukturierten, klaren Ort, um ihn abzulegen.
{
actions: {
opened: assign({ open: true }),
rendered: assign((context, evt) => {
const { node } = evt;
const dimensions = getResultsListDimensions(node);
itemsHeightObserver.observe(node);
opacitySpring.set(1, { hard: true });
Object.assign(slideInSpring, SLIDE_OPEN);
slideInSpring.update(prev => ({ ...prev, width: dimensions.width }), {
hard: true
});
slideInSpring.set(dimensions, { hard: false });
return { ...context, node };
}),
close() {
opacitySpring.set(0);
Object.assign(slideInSpring, SLIDE_CLOSE);
slideInSpring
.update(prev => ({ ...prev, height: 0 }))
.then(() => {
stateMachineService.send("CLOSED");
});
},
resize(context) {
opacitySpring.set(1);
slideInSpring.set(getResultsListDimensions(context.node));
},
closed: assign(() => {
itemsHeightObserver.unobserve(resultsList);
return { open: false, node: null };
})
}
}
Verschiedenes
Unser Animationszustand befindet sich in unserem Zustandsautomaten, aber wie bekommen wir ihn *heraus*? Wir benötigen den Zustand open, um das Rendern unserer Ergebnisliste zu steuern, und obwohl er in dieser Demo nicht verwendet wird, benötigt die reale Version dieses Autosuggest-Widgets den DOM-Knoten der Ergebnisliste für Dinge wie das Scrollen des aktuell hervorgehobenen Elements in Sicht.
Es stellt sich heraus, dass unser stateMachineService eine Methode subscribe hat, die ausgelöst wird, wenn sich der Zustand ändert. Der Callback, den Sie übergeben, wird mit dem aktuellen Zustand des Zustandsautomaten aufgerufen, der ein context-Objekt enthält. Aber Svelte hat einen besonderen Trick auf Lager: Seine reaktive Syntax von $: funktioniert nicht nur mit Komponentenvariablen und Svelte-Stores; sie funktioniert auch mit jedem Objekt mit einer subscribe-Methode. Das bedeutet, wir können mit unserem Zustandsautomaten mit etwas so Einfachem synchronisieren wie diesem:
$: ({ open, node: resultsList } = $stateMachineService.context);
Nur eine normale Destrukturierung, mit einigen Klammern, um die korrekte Interpretation zu erleichtern.
Ein kurzer Hinweis hier, als Bereich für Verbesserungen. Im Moment haben wir einige Aktionen, die sowohl Nebeneffekte ausführen als auch den Zustand aktualisieren. Idealerweise sollten wir diese wahrscheinlich in zwei Aktionen aufteilen, eine nur für den Nebeneffekt und die andere, die assign für den neuen Zustand verwendet. Aber ich habe beschlossen, die Dinge für diesen Artikel so einfach wie möglich zu halten, um die Einführung von XState zu erleichtern, auch wenn ein paar Dinge nicht ganz optimal waren.
Hier ist die Demo
Abschließende Gedanken
Ich hoffe, dieser Beitrag hat Ihr Interesse an XState geweckt. Ich habe es als ein unglaublich nützliches, einfach zu bedienendes Werkzeug für die Verwaltung komplexer Zustände empfunden. Bitte bedenken Sie, dass wir nur an der Oberfläche gekratzt haben. Wir haben uns auf das minimale fsm-Paket konzentriert, aber die gesamte XState-Bibliothek kann mehr als das, was wir hier behandelt haben, von verschachtelten Zuständen bis hin zur erstklassigen Unterstützung für Promises, und sie verfügt sogar über ein State-Visualisierungstool! Ich ermutige Sie, es sich anzusehen.
Viel Spaß beim Codieren!
Coole Sache! Das Gute an Zustandsautomaten ist, dass sie, ähnlich wie das Schreiben von Tests zuerst, Sie zwingen, nachzudenken, bevor Sie mit der Implementierung beginnen. XState ist sicher ein Werkzeug, aber es gibt einfachere. Auch in diesem Fall liegt der Wert im Schreiben des Automaten, nicht so sehr in der Bibliothek. Sobald Sie sich über das gewünschte Verhalten im Klaren sind, können Sie es immer von Hand schreiben. Das vermeidet es, eine Abhängigkeit zu Ihrem Projekt hinzuzufügen und den Wald zu lernen, wenn Sie nur ein Blatt an einem Baum brauchen. Zugegebenermaßen ist das wahrscheinlich das, worauf Sie angespielt haben, als Sie sagten, dass das Beispiel irgendwie konstruiert war und es schwierig ist, ein zufriedenstellendes Beispiel zum (schnellen) Schreiben zu finden.
Etwas ist in Ihrer Demo kaputt, die Sandboxes zeigen nur einen Fehler an
TypeError
append_styles ist keine Funktion. (In ‘append_styles($$.root)’, ‘append_styles’ ist eine Instanz von Array)