paint-brush
Vier Dinge, die ich beim Schreiben eines Frontend-Frameworks anders gemacht habevon@hacker-ss4mpor
Neue Geschichte

Vier Dinge, die ich beim Schreiben eines Frontend-Frameworks anders gemacht habe

von 17m2024/08/27
Read on Terminal Reader

Zu lang; Lesen

Vier Ideen, von denen Sie im Zusammenhang mit Frontend-Frameworks vielleicht noch nie gehört haben: - Objektliterale für HTML-Templates. - Ein globaler Store, der über Pfade adressierbar ist. - Ereignisse und Responder zur Handhabung aller Mutationen. - Ein textueller Diff-Algorithmus zur Aktualisierung des DOM.
featured image - Vier Dinge, die ich beim Schreiben eines Frontend-Frameworks anders gemacht habe
undefined HackerNoon profile picture
0-item
1-item

Im Jahr 2013 habe ich mir vorgenommen, ein minimalistisches Set an Tools für die Entwicklung von Webanwendungen zu erstellen. Das vielleicht beste Ergebnis dieses Prozesses war gotoB , ein clientseitiges, reines JS-Frontend-Framework, das in 2.000 Zeilen Code geschrieben wurde.


Die Motivation zum Schreiben dieses Artikels kam, nachdem ich mich intensiv mit der Lektüre interessanter Artikel von Autoren sehr erfolgreicher Frontend-Frameworks befasst hatte:


Was mich an diesen Artikeln so begeistert, ist die Tatsache, dass sie über die Entwicklung der Ideen sprechen, die hinter dem stehen, was sie bauen. Die Implementierung ist nur ein Weg, sie Wirklichkeit werden zu lassen, und die einzigen besprochenen Funktionen sind diejenigen, die so wesentlich sind, dass sie selbst Ideen darstellen.


Der bei weitem interessanteste Aspekt von gotoB sind die Ideen, die sich aus den Herausforderungen bei der Entwicklung ergeben haben. Darauf möchte ich hier eingehen.


Da ich das Framework von Grund auf neu erstellt habe und sowohl Minimalismus als auch interne Konsistenz erreichen wollte, habe ich vier Probleme auf eine Weise gelöst, die sich meiner Meinung nach von der Art und Weise unterscheidet, wie die meisten Frameworks dieselben Probleme lösen.


Diese vier Ideen möchte ich jetzt mit Ihnen teilen. Ich tue dies nicht, um Sie davon zu überzeugen, meine Tools zu verwenden (obwohl Sie dies gerne tun können!), sondern vielmehr in der Hoffnung, dass Sie an den Ideen selbst interessiert sein könnten.

Idee 1: Objektliterale zur Lösung von Templating-Problemen

Jede Webanwendung muss je nach Status der Anwendung spontan Markup (HTML) erstellen.


Dies lässt sich am besten anhand eines Beispiels erklären: In einer extrem einfachen To-Do-Listenanwendung könnte der Status eine Liste von Aufgaben sein: ['Item 1', 'Item 2'] . Da Sie eine Anwendung schreiben (und keine statische Seite), muss die Liste der Aufgaben änderbar sein.


Da sich der Status ändert, muss sich das HTML, das die Benutzeroberfläche Ihrer Anwendung bildet, mit dem Status ändern. Um beispielsweise Ihre Aufgaben anzuzeigen, können Sie das folgende HTML verwenden:

 <ul> <li>Item 1</li> <li>Item 2</li> </ul>


Wenn sich der Status ändert und ein drittes Element hinzugefügt wird, sieht Ihr Status nun folgendermaßen aus: ['Item 1', 'Item 2', 'Item 3'] ; Ihr HTML sollte dann folgendermaßen aussehen:

 <ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> </ul>


Das Problem der HTML-Generierung basierend auf dem Status der Anwendung wird normalerweise mit einer Template-Sprache gelöst, die Konstrukte der Programmiersprache (Variablen, Bedingungen und Schleifen) in Pseudo-HTML einfügt, das in eigentliches HTML erweitert wird.


Dies kann beispielsweise auf zwei Arten in verschiedenen Template-Tools erfolgen:

 // Assume that `todos` is defined and equal to ['Item 1', 'Item 2', 'Item 3'] // Moustache <ul> {{#todos}} <li>{{.}}</li> {{/todos}} </ul> // JSX <ul> {todos.map((item, index) => ( <li key={index}>{item}</li> ))} </ul>


Ich war nie ein Fan dieser Syntaxen, die Logik in HTML brachten. Da mir klar wurde, dass die Erstellung von Templates Programmierung erforderte und ich eine separate Syntax dafür vermeiden wollte, beschloss ich stattdessen, HTML in js zu bringen, indem ich Objektliterale verwendete. So konnte ich mein HTML einfach als Objektliterale modellieren:

 ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]]


Wenn ich dann die Liste durch Iteration generieren möchte, könnte ich einfach schreiben:

 ['ul', items.map ((item) => ['li', item])]


Und dann verwenden Sie eine Funktion, die dieses Objekt wörtlich in HTML umwandelt. Auf diese Weise kann die gesamte Vorlagenerstellung in JS erfolgen, ohne Vorlagensprache oder Transpilierung. Ich verwende den Namen „Liths“ , um diese Arrays zu beschreiben, die HTML darstellen.


Meines Wissens gibt es kein anderes JS-Framework, das auf diese Weise an die Vorlagenerstellung herangeht. Ich habe ein wenig nachgeforscht und JSONML gefunden, das fast dieselbe Struktur verwendet, um HTML in JSON-Objekten darzustellen (die fast dasselbe sind wie JS-Objektliterale), aber kein Framework gefunden, das darauf aufbaut.


Mithril und Hyperapp kommen dem von mir verwendeten Ansatz recht nahe, verwenden aber dennoch Funktionsaufrufe für jedes Element.

 // Mithril m("ul", [ m("li", "Item 1"), m("li", "Item 2") ]) // hyperapp h("ul", [ h("li", "Item 1"), h("li", "Item 2") ])


Der Ansatz, Objektliterale zu verwenden, hat bei HTML gut funktioniert, daher habe ich ihn auf CSS erweitert und generiere jetzt auch mein gesamtes CSS über Objektliterale.


Wenn Sie sich aus irgendeinem Grund in einer Umgebung befinden, in der Sie JSX nicht transpilieren oder eine Template-Sprache verwenden können, und Sie keine Zeichenfolgen verketten möchten, können Sie stattdessen diesen Ansatz verwenden.


Ich bin mir nicht sicher, ob der Mithril/Hyperapp-Ansatz besser ist als meiner. Ich stelle fest, dass ich beim Schreiben langer Objektliterale, die Liths darstellen, manchmal irgendwo ein Komma vergesse und es manchmal schwierig sein kann, das zu finden. Ansonsten gibt es wirklich nichts zu beanstanden. Und ich finde es toll, dass die Darstellung für HTML sowohl 1) Daten als auch 2) JS ist. Diese Darstellung kann tatsächlich als virtueller DOM fungieren, wie wir sehen werden, wenn wir zu Idee Nr. 4 kommen.


Bonusdetail: Wenn Sie HTML aus Objektliteralen generieren möchten, müssen Sie nur die folgenden beiden Probleme lösen:

  1. Entityify-Zeichenfolgen (d. h.: Escapen von Sonderzeichen).
  2. Wissen Sie, welche Tags geschlossen werden sollen und welche nicht.

Idee 2: ein globaler, über Pfade adressierbarer Speicher zur Speicherung aller Anwendungszustände

Komponenten haben mir noch nie gefallen. Wenn man eine Anwendung um Komponenten herum strukturieren will, muss man die Daten, die zur Komponente gehören, in der Komponente selbst platzieren. Das macht es schwierig oder sogar unmöglich, diese Daten mit anderen Teilen der Anwendung zu teilen.


Bei jedem Projekt, an dem ich gearbeitet habe, musste ich feststellen, dass ich immer einige Teile des Anwendungsstatus zwischen Komponenten teilen musste, die ziemlich weit voneinander entfernt waren. Ein typisches Beispiel ist der Benutzername: Sie benötigen ihn möglicherweise im Kontobereich und auch in der Kopfzeile. Wo gehört also der Benutzername hin?


Daher habe ich mich schon früh dazu entschlossen, ein einfaches Datenobjekt ( {} ) zu erstellen und dort alle meine Zustände unterzubringen. Ich habe es den Store genannt. Der Store enthält die Zustände aller Teile der App und kann daher von jeder Komponente verwendet werden.


Dieser Ansatz galt in den Jahren 2013 bis 2015 als etwas ketzerisch, hat jedoch seitdem an Bedeutung gewonnen und sich sogar durchgesetzt.


Was ich noch ziemlich neuartig finde, ist, dass ich Pfade verwende, um auf alle Werte im Store zuzugreifen. Wenn der Store beispielsweise:

 { user: { firstName: 'foo' lastName: 'bar' } }


Ich kann einen Pfad verwenden, um beispielsweise auf den lastName zuzugreifen, indem ich B.get ('user', 'lastName') schreibe. Wie Sie sehen, ist ['user', 'lastName'] der Pfad zu 'bar' . B.get ist eine Funktion, die auf den Store zugreift und einen bestimmten Teil davon zurückgibt, der durch den Pfad angegeben wird, den Sie an die Funktion übergeben.


Im Gegensatz zum oben Gesagten besteht der Standardweg zum Zugriff auf reaktive Eigenschaften darin, sie über eine JS-Variable zu referenzieren. Beispiel:

 // Svelte let { firstName, lastName } = $props(); firstName = 'foo'; lastName = 'bar'; // Knockout const firstName = ko.observable('foo'); const lastName = ko.observable('bar'); // mobx class UserStore { firstName = 'foo'; lastName = 'bar'; constructor() { makeAutoObservable(this); } } const userStore = new UserStore(); // SolidJS const [firstName, setFirstName] = createSignal('foo'); const [lastName, setLastName] = createSignal('bar');


Dies erfordert jedoch, dass Sie überall dort, wo Sie diesen Wert benötigen, einen Verweis auf firstName und lastName (oder userStore ) beibehalten. Der von mir verwendete Ansatz erfordert lediglich, dass Sie Zugriff auf den Store haben (der global und überall verfügbar ist) und ermöglicht Ihnen einen feinkörnigen Zugriff darauf, ohne JS-Variablen dafür definieren zu müssen.


Immutable.js und die Firebase Realtime Database machen etwas, das dem, was ich gemacht habe, viel ähnlicher ist, obwohl sie an separaten Objekten arbeiten. Aber Sie könnten sie möglicherweise verwenden, um alles an einem einzigen Ort zu speichern, der granular adressierbar wäre.

 // Immutable.js let store = Map({ user: Map({ firstName: 'foo', lastName: 'bar' }) }); const firstName = store.getIn(['user', 'firstName']); // 'foo' // Firebase const db = firebase.database(); db.ref('user').set({ firstName: 'foo', lastName: 'bar' }); db.ref('user/firstName').once('value').then(snapshot => { const firstName = snapshot.val(); // 'foo' });


Meine Daten in einem global zugänglichen Speicher zu haben, auf den über Pfade granular zugegriffen werden kann, ist ein Muster, das ich als äußerst nützlich empfunden habe. Immer wenn ich const [count, setCount] = ... oder etwas Ähnliches schreibe, fühlt es sich redundant an. Ich weiß, dass ich einfach B.get ('count') ausführen könnte, wenn ich darauf zugreifen muss, ohne count oder setCount deklarieren und weitergeben zu müssen.

Idee 3: Jede einzelne Veränderung wird durch Ereignisse ausgedrückt

Wenn Idee Nr. 2 (ein globaler Speicher, auf den über Pfade zugegriffen werden kann) Daten von Komponenten befreit, dann ist Idee Nr. 3 die Art und Weise, wie ich Code von Komponenten befreit habe. Für mich ist das die interessanteste Idee in diesem Artikel. Hier ist sie!


Unser Status besteht aus Daten, die per Definition veränderlich sind (für diejenigen, die Unveränderlichkeit verwenden, gilt das Argument weiterhin: Sie möchten immer noch, dass sich die neueste Version des Status ändert, auch wenn Sie Snapshots älterer Versionen des Status aufbewahren). Wie ändern wir den Status?


Ich habe mich für Ereignisse entschieden. Ich hatte bereits Pfade zum Store, sodass ein Ereignis einfach die Kombination eines Verbs (wie set , add oder rem ) und eines Pfads sein konnte. Wenn ich also user.firstName aktualisieren wollte, könnte ich etwa Folgendes schreiben:

 B.call ('set', ['user', 'firstName'], 'Foo')


Dies ist auf jeden Fall ausführlicher als Folgendes zu schreiben:

 user.firstName = 'Foo';


Aber es ermöglichte mir, Code zu schreiben, der auf eine Änderung von user.firstName reagiert . Und das ist die entscheidende Idee: In einer Benutzeroberfläche gibt es verschiedene Teile, die von verschiedenen Teilen des Status abhängig sind. Sie könnten beispielsweise diese Abhängigkeiten haben:

  • Header: abhängig von user und currentView
  • Kontobereich: abhängig vom user
  • To-Do-Liste: hängt von items ab


Die große Frage, die ich mir stellte, war: Wie aktualisiere ich den Header und den Kontobereich, wenn sich user ändert, aber nicht, wenn sich items ändern? Und wie verwalte ich diese Abhängigkeiten, ohne spezielle Aufrufe wie updateHeader oder updateAccountSection durchführen zu müssen? Diese Art von spezifischen Aufrufen stellt „jQuery-Programmierung“ in ihrer unhaltbarsten Form dar.


Für mich schien es eine bessere Idee zu sein, so etwas zu tun:

 B.respond ('set', [['user'], ['currentView']], function (user, currentView) { // Update the header }); B.respond ('set', ['user'], function (user) { // Update the account section }); B.respond ('set', ['items'], function (items) { // Update the todo list });


Wenn also ein set Ereignis für user aufgerufen wird, benachrichtigt das Ereignissystem alle Ansichten, die an dieser Änderung interessiert sind (Header- und Kontobereich), während die anderen Ansichten (Aufgabenliste) unberührt bleiben. B.respond ist die Funktion, die ich verwende, um Responder zu registrieren (die normalerweise als „Ereignislistener“ oder „Reaktionen“ bezeichnet werden). Beachten Sie, dass die Responder global sind und nicht an Komponenten gebunden sind. Sie hören jedoch nur auf set Ereignisse auf bestimmten Pfaden.


Wie wird nun überhaupt ein change aufgerufen? So habe ich es gemacht:

 B.respond ('set', '*', function () { // Assume that `path` is the path on which set was called B.call ('change', path); });


Ich vereinfache es ein wenig, aber so funktioniert es im Wesentlichen in gotoB.


Was ein Ereignissystem leistungsfähiger macht als bloße Funktionsaufrufe, ist die Tatsache, dass ein Ereignisaufruf 0, 1 oder mehrere Codeteile ausführen kann, während ein Funktionsaufruf immer genau eine Funktion aufruft . Wenn Sie im obigen Beispiel B.call ('set', ['user', 'firstName'], 'Foo'); aufrufen, werden zwei Codeteile ausgeführt: der, der den Header ändert, und der, der die Kontoansicht ändert. Beachten Sie, dass es dem Aufruf zum Aktualisieren firstName „egal“ ist, wer dies abhört. Er macht einfach seine Arbeit und lässt den Antwortenden die Änderungen übernehmen.


Ereignisse sind so leistungsfähig, dass sie meiner Erfahrung nach sowohl berechnete Werte als auch Reaktionen ersetzen können. Mit anderen Worten: Sie können verwendet werden, um jede Änderung auszudrücken, die in einer Anwendung erfolgen muss.


Ein berechneter Wert kann mit einem Ereignis-Responder ausgedrückt werden. Wenn Sie beispielsweise einen fullName berechnen und diesen nicht im Store verwenden möchten, können Sie Folgendes tun:

 B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; // Do something with `fullName` here. });


Ebenso können Reaktionen mit einem Responder ausgedrückt werden. Bedenken Sie Folgendes:

 B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; document.getElementById ('header').innerHTML = '<h1>Hello, ' + fullName + '</h1>'; });


Wenn Sie für einen Moment die peinliche Verkettung von Zeichenfolgen zur Generierung von HTML außer Acht lassen, sehen Sie oben einen Responder, der einen „Nebeneffekt“ ausführt (in diesem Fall die Aktualisierung des DOM).


(Randbemerkung: Was wäre eine gute Definition einer Nebenwirkung im Kontext einer Webanwendung? Für mich läuft es auf drei Dinge hinaus: 1) eine Aktualisierung des Status der Anwendung; 2) eine Änderung des DOM; 3) das Senden eines AJAX-Aufrufs).


Ich habe festgestellt, dass es wirklich keinen Bedarf für einen separaten Lebenszyklus gibt, der das DOM aktualisiert. In gotoB gibt es einige Responder-Funktionen, die das DOM mithilfe einiger Hilfsfunktionen aktualisieren. Wenn sich also user ändert, wird jeder davon abhängige Responder (oder genauer gesagt jede Ansichtsfunktion , da ich Responder, die einen Teil des DOM aktualisieren sollen, so nenne) ausgeführt, was einen Nebeneffekt erzeugt, der letztlich das DOM aktualisiert.


Ich habe das Ereignissystem vorhersehbar gemacht, indem ich die Responder-Funktionen in derselben Reihenfolge und nacheinander ausführen ließ. Asynchrone Responder können weiterhin synchron ausgeführt werden, und die Responder, die „nach“ ihnen kommen, warten auf sie.


Anspruchsvollere Muster, bei denen Sie den Status aktualisieren müssen, ohne das DOM zu aktualisieren (normalerweise aus Leistungsgründen), können durch Hinzufügen von Stummschaltverben wie mset hinzugefügt werden, die den Store ändern, aber keine Responder auslösen. Wenn Sie nach einem Neuzeichnen etwas am DOM tun müssen, können Sie einfach sicherstellen, dass dieser Responder eine niedrige Priorität hat und nach allen anderen Respondern ausgeführt wird:

 B.respond ('set', 'date', {priority: -1000}, function () { var datePicker = document.getElementById ('datepicker'); // Do something with the date picker });


Der oben beschriebene Ansatz, ein Ereignissystem mit Verben und Pfaden und einer Reihe globaler Antwortprogramme zu verwenden, die von bestimmten Ereignisaufrufen abgeglichen (ausgeführt) werden, hat einen weiteren Vorteil: Jeder Ereignisaufruf kann in eine Liste eingetragen werden. Sie können diese Liste dann beim Debuggen Ihrer Anwendung analysieren und Änderungen am Status verfolgen.


Im Kontext eines Frontends ermöglichen Ereignisse und Responder Folgendes:

  • Um Teile des Stores mit sehr wenig Code zu aktualisieren (nur etwas ausführlicher als die bloße Variablenzuweisung).
  • Teile des DOM sollen automatisch aktualisiert werden, wenn sich die Teile des Stores ändern, von denen dieser Teil des DOM abhängt.
  • Damit kein Teil des DOM automatisch aktualisiert wird, wenn es nicht benötigt wird.
  • Um berechnete Werte und Reaktionen haben zu können, die sich nicht auf die Aktualisierung des DOM beziehen, ausgedrückt als Responder.


Auf Folgendes kann (meiner Erfahrung nach) verzichtet werden:

  • Lebenszyklusmethoden oder Hooks.
  • Observablen.
  • Unveränderlichkeit.
  • Auswendiglernen.


Es handelt sich eigentlich nur um Ereignisaufrufe und Responder, wobei einige Responder nur mit Ansichten und andere mit anderen Vorgängen befasst sind. Alle internen Komponenten des Frameworks nutzen nur den Benutzerbereich .


Wenn Sie neugierig sind, wie dies in gotoB funktioniert, können Sie sich diese ausführliche Erklärung ansehen.

Idee 4: ein Text-Diff-Algorithmus zum Aktualisieren des DOM

Die bidirektionale Datenbindung klingt jetzt ziemlich veraltet. Aber wenn Sie mit einer Zeitmaschine zurück ins Jahr 2013 reisen und das Problem der Neuzeichnung des DOM bei einer Statusänderung von Grund auf angehen, was würde dann vernünftiger klingen?

  • Wenn sich das HTML ändert, aktualisieren Sie Ihren Status in JS. Wenn sich der Status in JS ändert, aktualisieren Sie das HTML.
  • Aktualisieren Sie das HTML jedes Mal, wenn sich der Status in JS ändert. Wenn sich das HTML ändert, aktualisieren Sie den Status in JS und aktualisieren Sie das HTML dann erneut, damit es dem Status in JS entspricht.


Tatsächlich klingt Option 2, also der unidirektionale Datenfluss vom Status zum DOM, komplizierter und ineffizienter.


Lassen Sie uns das jetzt ganz konkret machen: Im Fall eines interaktiven <input> oder <textarea> , das fokussiert ist, müssen Sie Teile des DOM mit jedem Tastendruck des Benutzers neu erstellen! Wenn Sie unidirektionale Datenflüsse verwenden, löst jede Änderung der Eingabe eine Änderung des Status aus, die dann das <input> neu zeichnet, damit es genau dem entspricht, was es sein sollte.


Dies legt die Messlatte für DOM-Updates sehr hoch: Sie sollten schnell sein und die Benutzerinteraktion mit interaktiven Elementen nicht behindern. Dies ist kein leicht zu lösendes Problem.


Warum haben sich nun unidirektionale Daten vom Status zum DOM (JS zu HTML) durchgesetzt? Weil es einfacher zu begründen ist. Wenn sich der Status ändert, spielt es keine Rolle, woher diese Änderung kam (es könnte ein AJAX-Rückruf sein, der Daten vom Server bringt, es könnte eine Benutzerinteraktion sein, es könnte ein Timer sein). Der Status ändert sich (oder vielmehr wird er mutiert ) immer auf die gleiche Weise. Und die Änderungen des Status fließen immer in das DOM ein.


Wie kann man also DOM-Updates effizient durchführen, ohne die Benutzerinteraktion zu behindern? Normalerweise läuft es darauf hinaus, so wenige DOM-Updates wie möglich durchzuführen, um die Aufgabe zu erledigen. Dies wird normalerweise als „Diffing“ bezeichnet, da Sie eine Liste der Unterschiede erstellen, die Sie benötigen, um eine alte Struktur (das vorhandene DOM) in eine neue zu konvertieren (das neue DOM nach der Aktualisierung des Status).


Als ich 2016 anfing, an diesem Problem zu arbeiten, habe ich geschummelt, indem ich mir angesehen habe, was React tat. Dabei habe ich die entscheidende Erkenntnis gewonnen, dass es keinen verallgemeinerten, linearen Algorithmus zum Vergleichen zweier Bäume gab (der DOM ist ein Baum). Aber ich war stur und wollte trotzdem einen allgemeinen Algorithmus zum Vergleichen. Was mir an React (oder an fast jedem anderen Framework) besonders missfiel, war die Forderung, dass man Schlüssel für zusammenhängende Elemente verwenden muss:

 function MyList() { const items = ['Item 1', 'Item 2', 'Item 3']; return ( <ul> {items.map((item, index) => ( <li key={index}>{item}</li> ))} </ul> ); }


Für mich war die key Direktive überflüssig, da sie nichts mit dem DOM zu tun hatte, sondern nur ein Hinweis auf das Framework war.


Dann dachte ich darüber nach, einen textuellen Diff-Algorithmus an abgeflachten Versionen eines Baums auszuprobieren. Was wäre, wenn ich beide Bäume abflache (den alten DOM-Teil, den ich hatte, und den neuen DOM-Teil, durch den ich ihn ersetzen wollte) und einen diff darauf berechne (einen minimalen Satz von Änderungen), sodass ich in weniger Schritten vom alten zum neuen gelangen könnte?


Also habe ich den Myers-Algorithmus genommen, den Sie jedes Mal verwenden, wenn Sie git diff ausführen, und ihn auf meine abgeflachten Bäume angewendet. Lassen Sie uns das anhand eines Beispiels veranschaulichen:

 var oldList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ]]; var newList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]];


Wie Sie sehen, arbeite ich nicht mit dem DOM, sondern mit der Objektliteraldarstellung, die wir bei Idee 1 gesehen haben. Jetzt werden Sie feststellen, dass wir am Ende der Liste ein neues <li> hinzufügen müssen.


Die abgeflachten Bäume sehen so aus:

 var oldFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'C ul']; var newFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'O li', 'L Item 3', 'C li', 'C ul'];


Das O steht für „open tag“, das L für „literal“ (in diesem Fall ein Text) und das C für „close tag“. Beachten Sie, dass jeder Baum jetzt eine Liste von Zeichenfolgen ist und es keine verschachtelten Arrays mehr gibt. Das meine ich mit Abflachen.


Wenn ich für jedes dieser Elemente einen Diff-Test ausführe (und dabei jedes Element im Array so behandle, als wäre es eine Einheit), erhalte ich:

 var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['add', 'O li'] ['add', 'L Item 3'] ['add', 'C li'] ['keep', 'C ul'] ];


Wie Sie wahrscheinlich schon vermutet haben, behalten wir den größten Teil der Liste bei und fügen am Ende ein <li> hinzu. Dies sind die add , die Sie sehen.


Wenn wir nun den Text des dritten <li> von Item 3 zu Item 4 ändern und einen Diff-Test darauf ausführen würden, würden wir Folgendes erhalten:

 var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['keep', 'O li'] ['rem', 'L Item 3'] ['add', 'L Item 4'] ['keep', 'C li'] ['keep', 'C ul'] ];


Ich weiß nicht, wie mathematisch ineffizient dieser Ansatz ist, aber in der Praxis hat er recht gut funktioniert. Er funktioniert nur schlecht, wenn große Bäume verglichen werden, zwischen denen viele Unterschiede bestehen. Wenn das gelegentlich vorkommt, greife ich auf ein Timeout von 200 ms zurück, um das Vergleichen zu unterbrechen und den fehlerhaften Teil des DOM einfach vollständig zu ersetzen. Wenn ich kein Timeout verwenden würde, würde die gesamte Anwendung einige Zeit ins Stocken geraten, bis das Vergleichen abgeschlossen ist.


Ein glücklicher Vorteil der Verwendung des Myers-Diffs ist, dass Löschungen gegenüber Einfügungen priorisiert werden: Das bedeutet, dass der Algorithmus zuerst ein Element entfernt, wenn es eine gleich effiziente Wahl zwischen dem Entfernen und dem Hinzufügen eines Elements gibt. In der Praxis ermöglicht mir dies, alle eliminierten DOM-Elemente zu erfassen und sie wiederzuverwenden, wenn ich sie später im Diff brauche. Im letzten Beispiel wird das letzte <li> wiederverwendet, indem sein Inhalt von Item 3 in Item 4 geändert wird. Indem wir Elemente wiederverwenden (anstatt neue DOM-Elemente zu erstellen), verbessern wir die Leistung so weit, dass der Benutzer nicht merkt, dass das DOM ständig neu gezeichnet wird.


Wenn Sie sich fragen, wie komplex es ist, diesen Flattening- und Diffing-Mechanismus zu implementieren, der Änderungen am DOM vornimmt: Ich habe es mit 500 Zeilen ES5-JavaScript geschafft, und es läuft sogar in Internet Explorer 6. Aber zugegebenermaßen war es vielleicht der schwierigste Code, den ich je geschrieben habe. Sturheit hat ihren Preis.

Abschluss

Das sind die vier Ideen, die ich vorstellen wollte! Sie sind nicht ganz originell, aber ich hoffe, dass sie für manche sowohl neuartig als auch interessant sein werden. Danke fürs Lesen!