Undo, Redo, und das Command-Pattern

Wenn wir eine komplexe Anwendung erstellen, können wir diese als eine Reihe verschiedener Zustände beschreiben. Wir beginnen in einem Anfangszustand, dann tritt ein Ereignis ein (z. B. der Benutzer klickt auf eine Schaltfläche) und wir transformieren die Anwendungsdaten, um einen neuen Zustand zu erstellen. Das könnte etwa so aussehen:

Image

Hier werden die Schritte zwischen den verschiedenen Zuständen als eine bestimmte Menge an Code beschrieben. Dieser Code ist nicht unbedingt leicht zu beobachten – er kann alle möglichen Änderungen am Zustand vornehmen und auch Nebenwirkungen haben.

Stellen wir uns nun vor, wir möchten in unserer Anwendung die Funktionen „undo“ und „redo“ einführen. Wenn der Benutzer auf „undo“ klickt, müssen wir in unserer Anwendung einen Schritt zurückgehen – anstatt zum nächsten Zustand zu wechseln, möchten wir zum vorherigen wechseln. Ähnlich verhält es sich mit der Funktion „redo“, die uns einen Schritt vorwärts in unserer Zustandsreihe bringt.

Image

Das Problem, das wir nun lösen müssen, lautet: Wie gelangen wir zurück zum vorherigen Zustand?

Eine einfache Antwort wäre, alle Zustände zu speichern, die wir auf unserem Weg durchlaufen. Wenn man mit dem Redux-Ökosystem vertraut ist, hat man vielleicht schon Tools wie „redux-undo“ verwendet, die dies automatisch erledigen. Man schreibt den Code in die Black Boxes, und die Bibliothek verwaltet automatisch die verschiedenen Zustände und wechselt zwischen ihnen.

Eine andere, ähnliche Option könnte darin bestehen, stattdessen die Unterschiede zwischen den verschiedenen Zuständen zu speichern. Immer wenn wir einen neuen Zustand erstellen, vergleichen wir ihn mit dem alten Zustand und speichern eine Aufzeichnung aller vorgenommenen Änderungen. Dies kann speichereffizienter sein (die Unterschiede sind wahrscheinlich viel kleiner als eine Kopie des gesamten Zustands), aber die Berechnung der Unterschiede kann zunächst weniger effizient sein.

Beides sind sehr gute Optionen, die in vielen Fällen funktionieren. Wenn man den Status Ihrer Anwendung zentral verwaltet, in der Regel mithilfe des Flux-Musters, ist es normalerweise sehr einfach, einen dieser Ansätze zu verwenden und Undo/Redo fast ohne zusätzlichen Aufwand zu erhalten.

Dieser Blogbeitrag befasst sich mit einem anderen Ansatz.

Warum man vielleicht einen anderen Ansatz bevorzugt

Es gibt zwei Hauptgründe, warum die oben genannten Ansätze für dich möglicherweise nicht geeignet sind.

Der erste Grund ist, dass beide Ansätze davon ausgehen, dass Ihr Zustand an einem einzigen, zentralen Ort verwaltet wird. Es gibt einige Architekturen, bei denen dies sehr einfach zu erreichen ist, aber wenn Ihr Zustand größer und komplexer wird, ist es oft einfacher, den Zustand in kleinere Teile zu zerlegen und diese Teile unabhängig voneinander zu bearbeiten. Dies ermöglicht mehr Flexibilität, bedeutet aber auch, dass du nicht mehr über eine einzige Quelle der Wahrheit verfügt.

Der zweite Grund ist, dass Ihre Zustandsübergänge andere Dinge als den Zustand beeinflussen können – oder mit anderen Worten, Nebenwirkungen haben können. Auf den ersten Blick scheint es naheliegend, die Nebenwirkungen von vornherein zu vermeiden, aber oft sind die Nebenwirkungen genau das, was wir wollen. Betrachten wir einen klassischen Zähler mit einer Schaltfläche zum Erhöhen des internen Zustands. Wenn ich auf die Schaltfläche klicke und den Zustand ändere, möchte ich auch die Benutzeroberfläche ändern, um den neuen Zustand des Zählers widerzuspiegeln. Dies ist einer der wichtigsten Nebeneffekte, mit denen wir uns befassen müssen.

In einem aktuellen Projekt, das diesen Beitrag inspiriert hat, war unsere Anwendung sehr umfangreich, weshalb wir sie in mehrere Controller aufgeteilt haben. Jeder Controller arbeitete unabhängig (und konnte somit auch unabhängig getestet/verstanden werden) und verwaltete seinen eigenen Status. Gleichzeitig verwendete die Anwendung SolidJS zur Verwaltung der Benutzeroberfläche. In SolidJS werden bei Aktualisierungen des internen Zustands Nebenwirkungen ausgeführt, die das DOM bei Bedarf direkt aktualisieren. Dies führt zu sehr effizienten DOM-Aktualisierungen (der berühmten „fine-grained reactivity”), bedeutet aber auch, dass wir unseren Zustand nicht mehr rein als Daten behandeln können – wir müssen auch verstehen, wie er sich verändert.

Letztendlich haben wir uns für das Befehlsmuster entschieden. Schauen wir uns einmal an, wie das aussieht.

Das Befehlsmuster

In unserem ursprünglichen Beispiel haben wir den Code, der uns zwischen verschiedenen Zuständen bewegte, als Black Box behandelt. Als Entwickler konnten wir ihn uns zwar ansehen und verstehen, wie er funktionierte, aber wir hatten keine Tools, um ihn zu untersuchen und Teile davon rückgängig zu machen oder wiederzugeben.

Im Befehlsmuster beschreiben wir stattdessen jeden Übergang durch eine Kombination aus Befehlen und Daten. Befehle sind die verschiedenen Aktionen, die wir mit unserem Zustand ausführen können – für eine To-Do-App könnten wir Befehle wie „To-Do hinzufügen”, „To-Do löschen”, „To-Do als erledigt markieren” usw. haben. Die Daten sind die spezifischen Argumente, die wir an den Befehl übergeben. Das Ergebnis sieht in etwa so aus:

Image

Wenn wir zu unserer To-Do-App zurückkehren, würden wir, wenn wir auf eines der „Erledigt”-Kontrollkästchen in unserer Benutzeroberfläche klicken, den Befehl „To-Do als erledigt markieren” mit einigen Daten (wahrscheinlich der ID des gewünschten To-Dos) aufrufen, und diese Funktion würde den internen Datenspeicher aktualisieren und die notwendigen Nebenwirkungen auslösen, um den nächsten Zustand zu erzeugen.

Allerdings können wir noch nichts rückgängig machen. Dazu benötigen wir die zweite Funktion von Befehlen, nämlich dass sie wissen, wie sie sich selbst rückgängig machen können. Der Befehl „Todo hinzufügen” hat eine Funktion, die ein Todo zum Status hinzufügt und die Benutzeroberfläche aktualisiert, aber er hat auch eine Funktion, die dasselbe Todo wieder entfernt. Jeder Befehl weiß also, wie er seine Aktion ausführen und rückgängig machen kann.

Image

Damit können wir unser Undo/Redo-System aufbauen. Jedes Mal, wenn wir einen Befehl ausführen, zeichnen wir auch auf:

  • Welcher Befehl ausgeführt wurde
  • Mit welchen Daten er ausgeführt wurde

Wenn wir eine Aktion rückgängig machen wollen, rufen wir die Undo-Funktion des Befehls auf und übergeben ihr dieselben Daten, die sie zuvor hatte. Dadurch werden alle zuvor vorgenommenen Änderungen rückgängig gemacht und wir befinden uns wieder genau in dem Zustand, in dem wir zuvor waren.

Wenn wir zu unseren Gründen für einen anderen Ansatz zurückkehren, sehen wir, dass das Befehlsmuster beide Probleme elegant löst:

  • Jede Komponente des Codes kann ihre eigenen Befehle definieren (genauso wie sie ihre eigenen Methoden oder Funktionen definieren kann), was bedeutet, dass wir jede Komponente weiterhin isoliert behandeln können.
  • Der Befehl ist eine Funktion, was bedeutet, dass er den Zustand aktualisieren und bei Bedarf beliebige Nebenwirkungen aufrufen kann.

Zeig mir den Code

Schauen wir uns an, wie wir die Logik einer To-Do-App in Befehlsform umsetzen könnten.

Definieren wir zunächst, was unser Befehl eigentlich ist. In anderen Sprachen würden wir vielleicht Klassen verwenden, aber in TypeScript kommen wir mit einem relativ einfachen Objekt aus:

type Command<Data> = {
  do: (data: Data) => void;
  undo: (data: Data) => void;
};

Wir werden auch unseren Verlauf benötigen. Dafür brauchen wir eine Liste der Aktionen, die rückgängig gemacht werden können, und eine Liste der Aktionen, die danach wiederholt werden können. Wir werden auch eine Funktion zum Hinzufügen eines neuen Eintrags zu den Listen bereitstellen, da es hier eine gewisse Logik gibt, die wir nicht überall wiederholen möchten:

type CommandPair = { command: Command<any>, data: any };
const undoableCommands: CommandPair[] = [];
const redoableCommands: CommandPair[] = [];

function pushCommand<Data>(command: Command<Data>, data: Data) {
  command.do(data);
  undoableCommands.push({ command, data });
  redoableCommands.length = 0;
}

Jetzt können wir die für unser To-Do-System spezifischen Befehle definieren. Achtung: dies sing nicht alle möglichen Befehle, aber du kannst gerne selbst überlegen, welche weiteren Befehle noch notwendig sein könnten.

const todoStore = []; // Super einfaches Store, definitiv produktionsreif

// Hier sind die Daten lediglich die Zeichenfolge der Aufgabe.
// (Der Einfachheit halber gehen wir davon aus, dass alle Aufgaben eindeutig sind.)
const createTodo: Command<string> = {
  do: (data) => todoStore.push({ todo: data, done: false }),
  undo: (data) => todoStore = todoStore.filter(t => t.todo !== data),
}

// Hier speichern wir den alten (`prev`) und den neuen (`next`) Status
// des Attributs `.done`, damit wir in der Historie vorwärts und
// rückwärts blättern können.
const setTodoState: Command<{todo: string, prev: boolean, next: boolean}> = {
  do: (data) => {
    const todo = todoStore.find(t => t.todo === data.todo);
    todo.done = data.next;
  },
  undo: (data) => {
	  const todo = todoStore.find(t => t.todo === data.todo);
	  todo.done = data.prev;
	},
}

In der Praxis würde ich diese Befehle wahrscheinlich in Funktionen einbinden, die intern die Funktion „pushCommand“ aufrufen, um die Verwendung etwas angenehmer zu gestalten, aber das können wir vorerst überspringen. Zuletzt benötigen wir noch unsere Funktionen zum Rückgängigmachen und Wiederherstellen. Da wir nun unsere Befehle haben, lassen sich diese ganz einfach implementieren: Man ruft einfach die entsprechenden Funktionen für die Befehle mit den angehängten Daten auf.

function undo() {
  const cmd = undoableCommands.pop();
  if (!cmd) return false; // nichts zu tun
  cmd.command.undo(cmd.data);
  redoableCommands.push(cmd);
  return true; // Erfolg!
}

function redo() {
  const cmd = redoableCommands.pop();
  if (!cmd) return false; // nichts zu tun
  cmd.command.do(cmd.data);
  undoableCommands.push(cmd);
  return true; // Erfolg!
}

Weitere Überlegungen

Das hier implementierte Undo-System ist sehr einfach gehalten, um die grundlegenden Ideen rund um Befehle zu erforschen, aber es gibt noch viel mehr, was wir hier hinzufügen könnten.

Eine Funktion, die viele Anwendungen sehr schnell benötigen, ist die Möglichkeit, Befehle zu bündeln, sodass mit einem einzigen „Undo“-Befehl mehrere Befehle auf einmal rückgängig gemacht werden können. Dies ist wichtig, wenn jeder Befehl nur seinen eigenen Teil des Zustands beeinflussen soll, eine bestimmte Operation jedoch mehrere Teile betrifft.

Eine weitere Überlegung ist die Möglichkeit, Befehle zu aktualisieren. Betrachten wir eine Operation zum Ändern der Größe eines Bildes. Wenn ich meinen Cursor bewege, sollte die Benutzeroberfläche reibungslos aktualisiert werden, aber wenn ich die Größenänderung beende und auf „Rückgängig“ drücke, möchte ich die gesamte Größenänderung rückgängig machen, nicht nur einen Teil davon. Eine Möglichkeit, dies zu erreichen, besteht darin, neben der Funktion „pushCommand“ eine Art „upsertCommand“-Funktion hinzuzufügen, die einen neuen Eintrag im Verlauf erstellt, wenn zuvor noch keiner vorhanden war, oder den vorherigen Eintrag mit den neuen Daten aktualisiert.

Es ist auch wichtig, sich der Einschränkungen des Befehlsmusters bewusst zu sein. Einer der Vorteile der Flux-Architektur oder von Tools wie Redux besteht darin, dass sie ein striktes Framework schaffen, in dem es schwierig ist, Daten versehentlich zu verändern oder in einen unerwarteten Zustand zu geraten. Befehle hingegen sind viel flexibler, aber dafür muss man sicherstellen, dass alle Änderungen am Zustand wirklich innerhalb von Befehlen und nicht in beliebigen Funktionen stattfinden.

Fazit

Das Befehlsmuster ist eine nützliche Methode, um rückgängig machbare Zustandsübergänge in einer Anwendung zu definieren. Es ermöglicht uns, eine Anwendung in verschiedene Controller oder Datenabschnitte aufzuteilen. Außerdem können wir damit Nebenwirkungen anwenden, die konsistent wiederholt und rückgängig gemacht werden, wenn der Benutzer seinen Verlauf rückgängig macht und wiederherstellt. Hoffentlich hat dir dieser Artikel dabei geholfen, darüber nachzudenken, wann und wie du das Befehlsmuster in Ihren eigenen Tools und Anwendungen anwenden kannst.