Reaktive Programmierung

Im ersten Teil der Serie haben wir über Komponenten gesprochen, mit denen Sie unterschiedliche Verhaltensweisen mithilfe von Facetten verwalten können und wie Milo das Messaging verwaltet.

In diesem Artikel betrachten wir ein anderes häufiges Problem beim Entwickeln von Browseranwendungen: Das Verbinden von Modellen mit Ansichten. Wir werden einiges von der "Magie" aufdecken, die eine bidirektionale Datenbindung in Milo ermöglicht. Um es zusammenzufassen, bauen wir eine voll funktionsfähige To-Do-Anwendung in weniger als 50 Zeilen Code auf.

Modelle (oder Eval ist nicht böse)

Es gibt mehrere Mythen über JavaScript. Viele Entwickler glauben, dass eval böse ist und niemals verwendet werden sollte. Diese Überzeugung führt dazu, dass viele Entwickler nicht sagen können, wann eval verwendet werden kann und sollte.

Mantras wie “eval ist böse “kann nur dann schädlich sein, wenn es sich um etwas handelt, das im Wesentlichen ein Werkzeug ist. Ein Werkzeug ist nur dann "gut" oder "schlecht", wenn ein Kontext gegeben wird. Sie würden nicht sagen, dass ein Hammer böse ist, richtig? Es hängt wirklich davon ab, wie Sie es verwenden. Bei Verwendung mit einem Nagel und einigen Möbeln ist „Hammer gut“. Wenn das Brot gebuttert wird, ist "Hammer schlecht".

Wir stimmen dem definitiv zu eval hat seine Einschränkungen (z. B. Leistung) und Risiken (insbesondere wenn vom Benutzer eingegebener Code ausgewertet wird), gibt es einige Situationen, in denen die Bewertung die einzige Möglichkeit ist, die gewünschte Funktionalität zu erreichen.

Zum Beispiel verwenden viele Template-Engines eval im Rahmen von with operator (ein weiteres großes Nein unter Entwicklern), um Vorlagen zu JavaScript-Funktionen zu kompilieren.

Als wir überlegten, was wir von unseren Modellen wollten, haben wir verschiedene Ansätze in Betracht gezogen. Man sollte flache Modelle wie Backbone mit Nachrichten haben, die bei Modelländerungen ausgegeben wurden. Diese Modelle sind zwar einfach zu implementieren, jedoch nur von begrenztem Nutzen - die meisten realen Modelle sind tiefgreifend.

Wir haben in Betracht gezogen, einfache JavaScript-Objekte mit der Object.observe API (was die Implementierung von Modellen überflüssig machen würde). Während unsere Anwendung nur mit Chrome arbeiten musste, Object.observe Erst kürzlich wurde diese Option standardmäßig aktiviert - zuvor musste das Chrome-Flag aktiviert werden. Dies hätte sowohl die Bereitstellung als auch die Unterstützung erschwert.

Wir wollten Modelle, die wir mit Ansichten verbinden könnten, aber so, dass wir die Struktur der Ansicht ändern können, ohne eine einzelne Codezeile zu ändern, ohne die Struktur des Modells zu ändern und ohne die Konvertierung des Ansichtsmodells in die Datenmodell.

Wir wollten auch Modelle miteinander verbinden können (siehe reaktive Programmierung) und Modelländerungen abonnieren. Angular implementiert Uhren durch Vergleichen der Zustände von Modellen. Dies wird bei großen, tiefen Modellen sehr ineffizient.

Nach einiger Diskussion haben wir beschlossen, unsere Modellklasse zu implementieren, die eine einfache get / set-API unterstützt, mit der sie manipuliert werden können, und die das Abonnieren von Änderungen innerhalb dieser Klassen ermöglicht:

var m = neues Modell; m ('. info.name'). set ('angle'); console.log (m ('. info'). get ()); // logs: name: 'angle' m.on ('. info.name', onNameChange); Funktion onNameChange (msg, data) console.log ('Name geändert von', data.oldValue, 'to', data.newValue);  m ('. info.name'). set ('milo'); // logs: Name wurde von eckig in milo console.log geändert (m.get ()); // logs: info: name: 'milo' console.log (m ('. info'). get ()); // logs: name: 'milo'

Diese API ähnelt dem normalen Eigenschaftszugriff und sollte einen sicheren Zugriff auf die Eigenschaften bieten - wann erhalten wird für nicht vorhandene Eigenschaftspfade aufgerufen, die er zurückgibt nicht definiert, und wann einstellen aufgerufen wird, wird bei Bedarf der fehlende Objekt- / Array-Baum erstellt.

Diese API wurde erstellt, bevor sie implementiert wurde. Die wichtigste Unbekannte, mit der wir konfrontiert waren, war das Erstellen von Objekten, die auch aufrufbare Funktionen waren. Es stellt sich heraus, dass Sie zum Erstellen eines Konstruktors, der Objekte enthält, die aufgerufen werden können, diese Funktion aus dem Konstruktor zurückgeben und ihren Prototyp so festlegen müssen, dass er zu einer Instanz von Modell Unterricht gleichzeitig:

function Model (data) // modelPath sollte ein ModelPath-Objekt // mit Methoden zum Abrufen / Setzen von Modelleigenschaften // zurückgeben, // um Eigenschaftsänderungen zu abonnieren usw. var model = Funktion modelPath (path) return new ModelPath (model, Pfad);  model .__ proto__ = Model.prototype; model._data = Daten; model._messenger = neuer Messenger (Modell, Messenger.defaultMethods); Rückkehrmodell;  Model.prototype .__ proto__ = Modell .__ proto__;

Während __proto__ Die Eigenschaft des Objekts ist normalerweise besser zu vermeiden. Es ist immer noch die einzige Möglichkeit, den Prototyp der Objektinstanz und des Konstruktorprototyps zu ändern.

Die Instanz von ModelPath das sollte zurückgegeben werden, wenn das Modell aufgerufen wird (z. m ('. info.name') oben) stellte eine weitere Implementierungsherausforderung dar. ModelPath Instanzen sollten über Methoden verfügen, die die Eigenschaften von Modellen festlegen, die beim Aufruf an das Modell übergeben wurden (.info.name in diesem Fall). Wir haben uns überlegt, diese Eigenschaften zu implementieren, indem Eigenschaften, die bei jedem Zugriff auf diese Eigenschaften als Zeichenfolgen übergeben werden, einfach analysiert werden.

Stattdessen entschieden wir uns, sie so umzusetzen m ('. info.name'), gibt beispielsweise ein Objekt zurück (eine Instanz von ModelPath “Klasse”), die alle Zugriffsmethoden (erhalten, einstellen, del und spleißen) als JavaScript - Code synthetisiert und mit Hilfe von eval.

Wir haben auch alle diese synthetisierten Methoden in einem Cache erstellt, sobald ein Modell verwendet wurde .info.name Alle Zugriffsmethoden für diesen "Eigenschaftspfad" werden zwischengespeichert und können für jedes andere Modell wiederverwendet werden.

Die erste Implementierung der Get-Methode sah folgendermaßen aus:

function synthesizeGetter (path, parsedPath) var getter; var getterCode = 'getter = function value ()' + '\ n var m =' + modelAccessPrefix + '; \ n return'; var modelDataProperty = 'm'; für (var i = 0, count = parsedPath.length-1; i < count; i++)  modelDataProperty += parsedPath[i].property; getterCode += modelDataProperty + ' && ';  getterCode += modelDataProperty + parsedPath[count].property + ';\n ;'; try  eval(getterCode);  catch (e)  throw ModelError('ModelPath getter error; path: ' + path + ', code: ' + getterCode);  return getter; 

Aber die einstellen Die Methode sah viel schlechter aus und war sehr schwer zu folgen, zu lesen und zu pflegen, da der Code der erstellten Methode stark mit dem Code durchsetzte, der die Methode generierte. Aus diesem Grund haben wir zur Erstellung des Codes für Accessormethoden auf doT Templating Engine umgestellt.

Dies war der Getter nach dem Wechsel zu Vorlagen:

var dotDef = modelAccessPrefix: 'this._model._data',; var getterTemplate = 'method = function value () \ var m = # def.modelAccessPrefix; \ var modelDataProperty = "m";  \ return für (var i = 0, count = it.parsedPath.length-1; \ i < count; i++)  \ modelDataProperty+=it.parsedPath[i].property; \  =modelDataProperty &&  \  \  =modelDataProperty=it.parsedPath[count].property; \ '; var getterSynthesizer = dot.compile(getterTemplate, dotDef); function synthesizeMethod(synthesizer, path, parsedPath)  var method , methodCode = synthesizer( parsedPath: parsedPath ); try  eval(methodCode);  catch (e)  throw Error('ModelPath method compilation error; path: ' + path + ', code: ' + methodCode);  return method;  function synthesizeGetter(path, parsedPath)  return synthesizeMethod(getterSynthesizer, path, parsedPath); 

Dies erwies sich als guter Ansatz. Es erlaubte uns, den Code für alle unsere Zugriffsmethoden zu erstellen (erhalten, einstellen, del und spleißen) sehr modular und wartbar.

Die von uns entwickelte Modell-API erwies sich als durchaus brauchbar und leistungsfähig. Es wurde entwickelt, um die Array-Element-Syntax zu unterstützen, spleißen Methode für Arrays (und abgeleitete Methoden wie drücken, Pop, usw.) und die Interpolation von Eigenschaften / Objekten.

Letzteres wurde eingeführt, um zu vermeiden, dass Zugriffsmethoden synthetisiert werden (was eine wesentlich langsamere Operation ist als der Zugriff auf eine Eigenschaft oder ein Element), wenn sich nur eine Eigenschaft oder ein Elementindex ändert. Es würde passieren, wenn Array-Elemente innerhalb des Modells in der Schleife aktualisiert werden müssen.

Betrachten Sie dieses Beispiel:

für (var i = 0; i < 100; i++)  var mPath = m('.list[' + i + '].name'); var name = mPath.get(); mPath.set(capitalize(name)); 

Bei jeder Wiederholung a ModelPath Eine Instanz wird erstellt, um auf die name -Eigenschaft des Array-Elements im Modell zuzugreifen und diese zu aktualisieren. Alle Instanzen haben unterschiedliche Eigenschaftspfade, und für jedes der 100 Elemente müssen vier Zugriffsmethoden synthetisiert werden eval. Dies wird ein sehr langsamer Vorgang sein.

Bei der Eigenschaftszugriffsinterpolation kann die zweite Zeile in diesem Beispiel folgendermaßen geändert werden:

var mPath = m ('. list [$ 1] .name', i);

Es sieht nicht nur lesbarer aus, es ist auch viel schneller. Während wir noch 100 erstellen ModelPath In dieser Schleife werden alle die gleichen Zugriffsmethoden verwenden. Statt 400 synthetisieren wir nur vier Methoden.

Sie können den Leistungsunterschied zwischen diesen Beispielen schätzen.

Reaktive Programmierung

Milo hat die reaktive Programmierung mit beobachtbaren Modellen implementiert, die bei jeder Änderung ihrer Eigenschaften Benachrichtigungen an sich ausgeben. Dies hat es uns ermöglicht, reaktive Datenverbindungen mithilfe der folgenden API zu implementieren:

var-anschluss = minder (m1, '<<<->>> ', m2 ('. info ')); // erstellt eine bidirektionale reaktive Verbindung // zwischen Modell m1 und der Eigenschaft „.info“ von Modell m2 // mit der Tiefe 2 (Eigenschaften und Untereigenschaften // der Modelle sind verbunden).

Wie Sie von oben sehen können, ModelPath zurückgegeben von m2 ('. info') sollte dieselbe API wie das Modell haben, was bedeutet, dass dieselbe Messaging-API wie das Modell verfügt und auch eine Funktion ist:

var mPath = m ('. info); mPath ('. name'). set ("); // Setzt die Poperty '.info.name' in mPath.on ('. name', onNameChange); // wie m ('. info.name') .on (", onNameChange) // wie m.on ('. info.name', onNameChange);

Auf ähnliche Weise können wir Modelle mit Ansichten verbinden. Die Komponenten (siehe erster Teil der Serie) können eine Datenfacette haben, die als API dient, um DOM so zu bearbeiten, als wäre es ein Modell. Es hat dieselbe API wie das Modell und kann in reaktiven Verbindungen verwendet werden.

So verbindet dieser Code beispielsweise eine DOM-Ansicht mit einem Modell:

Var-Anschluss = Manager (m, '<<<->>> ', comp.data);

Dies wird unten in der Beispielanwendung ausführlicher beschrieben.

Wie funktioniert dieser Connector? Unter der Haube abonniert der Connector einfach die Änderungen in den Datenquellen auf beiden Seiten der Verbindung und leitet die von einer Datenquelle erhaltenen Änderungen an eine andere Datenquelle weiter. Eine Datenquelle kann ein Modell, ein Modellpfad, eine Datenfacette der Komponente oder ein beliebiges anderes Objekt sein, das dieselbe Messaging-API wie das Modell implementiert.

Die erste Implementierung des Connectors war ziemlich einfach:

// ds1 und ds2 - verbundene Datenquellen // mode definiert die Richtung und die Tiefe der Verbindungsfunktion. Connector (ds1, mode, ds2) var parsedMode = mode.match (/ ^ (\<*)\-+(\>*) $ /); _.extend (this, ds1: ds1, ds2: ds2, Modus: Modus, Tiefe1: ParsedMode [1] .length, Tiefe2: ParsedMode [2] .length, isOn: false); Dies auf();  _.extendProto (Connector, ein: ein, aus: aus); function on () varsubscriptionPath = this._subscriptionPath = neues Array (this.depth1 || this.depth2) .join ('*'); var self = dies; if (this.depth1) linkDataSource ('_ link1', '_link2', this.ds1, this.ds2, subscriptionPath); if (this.depth2) linkDataSource ('_ link2', '_link1', this.ds2, this.ds1, subscriptionPath); this.isOn = true; function linkDataSource (linkName, stopLink, linkToDS, linkedDS, SubscribePath) var onData = function onData (Pfad, Daten) // verhindert endlose Nachrichtenschleife // für bidirektionale Verbindungen if (onData .__ stopLink) return; var dsPath = linkToDS.path (path); if (dsPath) self [stopLink] .__ stopLink = true; dsPath.set (data.newValue); selbst löschen [stopLink] .__ stopLink; linkedDS.on (subscriptionPath, onData); self [linkName] = onData; return onData;  Funktion aus () var self = this; unlinkDataSource (this.ds1, '_link2'); unlinkDataSource (this.ds2, '_link1'); this.isOn = false; Funktion unlinkDataSource (linkedDS, linkName) if (self [linkName]) linkedDS.off (self._subscriptionPath, self [linkName]); lösche selbst [linkName]; 

Mittlerweile haben sich die reaktiven Verbindungen in milo wesentlich weiterentwickelt - sie können Datenstrukturen ändern, die Daten selbst ändern und auch Datenvalidierungen durchführen. Dies hat uns erlaubt, einen sehr leistungsfähigen UI / Formular-Generator zu erstellen, den wir auch für Open Source planen.

Erstellen einer To-Do-App

Viele von Ihnen werden das TodoMVC-Projekt kennen: Eine Sammlung von To-Do-App-Implementierungen, die mit einer Vielzahl verschiedener MV * -Frameworks erstellt wurden. Die To-Do-App ist ein perfekter Test für jedes Framework, da es relativ einfach zu erstellen und zu vergleichen ist und dennoch einen relativ breiten Funktionsumfang erfordert, einschließlich CRUD-Operationen (Erstellen, Lesen, Aktualisieren und Löschen), DOM-Interaktion und Ansicht / Modell um nur einige zu nennen.

In verschiedenen Entwicklungsstadien von Milo haben wir versucht, einfache To-Do-Anwendungen zu erstellen, und es wurden fehlerhafte Rahmenbedingungen oder Mängel hervorgehoben. Selbst in unserem Hauptprojekt, als Milo verwendet wurde, um eine viel komplexere Anwendung zu unterstützen, haben wir auf diese Weise kleine Fehler gefunden. Inzwischen deckt das Framework die meisten Bereiche ab, die für die Entwicklung von Webanwendungen erforderlich sind, und wir finden, dass der für die Erstellung der To-Do-App erforderliche Code recht knapp und deklarativ ist.

Zunächst einmal haben wir das HTML-Markup. Es handelt sich um eine Standard-HTML-Zwischenablage mit ein wenig Stil, um die ausgewählten Elemente zu verwalten. Im Körper haben wir eine ml-bind Attribut zum Deklarieren der To-Do-Liste, und dies ist nur eine einfache Komponente mit der Liste Facette hinzugefügt. Wenn wir mehrere Listen haben wollten, sollten wir wahrscheinlich eine Komponentenklasse für diese Liste definieren.

In der Liste befindet sich unser Beispielartikel, der mit einem benutzerdefinierten Wert deklariert wurde Machen Klasse. Das Deklarieren einer Klasse ist zwar nicht erforderlich, macht die Verwaltung der untergeordneten Elemente der Komponente jedoch wesentlich einfacher und modularer.

            

Aufgaben

Modell

Damit wir laufen können milo.binder () jetzt müssen wir zuerst die definieren Machen Klasse. Diese Klasse muss das haben Artikel Facet und ist im Wesentlichen für die Verwaltung der Löschschaltfläche und des Kontrollkästchens verantwortlich, das sich auf jeder befindet Machen.

Bevor eine Komponente mit ihren untergeordneten Elementen arbeiten kann, muss sie zunächst auf die Komponente warten Kindergebunden Ereignis darauf gefeuert werden. Weitere Informationen zum Komponentenlebenszyklus finden Sie in der Dokumentation (Link zu Komponentendokumenten)..

// Erstellen einer neuen facettierten Komponentenklasse mit der Facette "item". // Dies wird normalerweise in einer eigenen Datei definiert. // Anmerkung: Die Elementfacette 'benötigt' // die Facetten 'container', 'data' und 'dom' var Todo = _.createSubclass (milo.Component, 'Todo'); milo.registry.components.add (Todo); // Hinzufügen unserer eigenen benutzerdefinierten init-Methode _.extendProto (Todo, init: Todo $ init); function Todo $ init () // Aufruf der geerbten Init-Methode. milo.Component.prototype.init.apply (Argumente); // Auf 'childrenbound' warten, das ausgelöst wird, nachdem der Ordner // mit allen untergeordneten Elementen dieser Komponente beendet wurde. this.on ('childrenbound', function () // Wir erhalten den Gültigkeitsbereich (die untergeordneten Komponenten leben hier) var scope = this.container.scope; // und erstellt zwei Subskriptionen, eines für die Daten der Checkbox // Die Subskriptionssyntax ermöglicht die Übergabe des Kontexts scope.checked.data.on (", subscriber: checkTodo, context: this); // und eines an das" click "-Ereignis der Delete-Schaltfläche. Scope.deleteBtn.events.on ('click', subscriber: removeTodo, context: this);); // Wenn sich das Ankreuzfeld ändert, setzen wir die Klasse der Todo-Funktion checkTodo (Pfad, Daten) this.el.classList.toggle ('todo-item-checked', data.newValue); // Um ​​das Element zu entfernen, verwenden wir die 'removeItem'-Methode der' item '-Facettenfunktion removeTodo (eventType, event) this.item.removeItem () 

Nun, da wir diese Konfiguration haben, können wir den Binder aufrufen, um Komponenten an DOM-Elemente anzuhängen, und ein neues Modell mit bidirektionaler Verbindung zur Liste über seine Datenfacette erstellen.

// Milo-Ready-Funktion, funktioniert wie die Ready-Funktion von jQuery. milo (function () // Binder im Dokument aufrufen. // Es bindet Komponenten an DOM-Elemente mit dem ml-bind-Attribut var. bereich = milo.binder (); //) Erhalten Sie Zugriff auf unsere Komponenten über das Bereichsobjekt var todos = scope.todos // ToDo-Liste, newTodo = scope.newTodo // Neue ToDo-Eingabe, addBtn = scope.addBtn // Schaltfläche "Hinzufügen", modelView = scope.modelView; // Wo wir das Modell ausdrucken // Hier unser Modell ausdrucken halten Sie das Array der todos var m = new milo.Model; // Dieses Abonnement zeigt uns den Inhalt des // Modells zu jeder Zeit unter den todos m.on (/.*/, Funktion showModel (msg, data)  modelView.data.set (JSON.stringify (m.get ()));;) // Erstellen Sie eine tiefe bidirektionale Bindung zwischen unserem Modell und der Datenfacette der ToDos-Liste. // Die innersten Winkel zeigen die Verbindungsrichtung (can auch ein Weg sein), // der Rest definiert Verbindungstiefe - 2 Ebenen in diesem Fall, um // die Eigenschaften von Feldelementen zu berücksichtigen. milo.minder (m, '<<<->>> ', todos.data); // Abonnement für Klickereignis der Schaltfläche "add" AddBtn.events.on ('click', addTodo); // Click-Handler der Add-Button-Funktion addTodo () // Wir packen die Eingabe 'newTodo' als Objekt auf // // Die Eigenschaft 'Text' entspricht der Artikel-Markierung. var itemData = text: newTodo.data.get (); // Wir geben diese Daten in das Modell ein. // Die Ansicht wird automatisch aktualisiert! m.push (itemData); // Und zum Schluss die Eingabe wieder auf leer setzen. newTodo.data.set (");); 

Dieses Beispiel ist in jsfiddle verfügbar.

Fazit

Das To-Do-Sample ist sehr einfach und zeigt einen sehr kleinen Teil der unglaublichen Kraft von Milo. Milo bietet viele Funktionen, die in diesem und den vorherigen Artikeln nicht behandelt werden, darunter Drag & Drop, lokaler Speicher, HTTP- und Websockets-Dienstprogramme, erweiterte DOM-Dienstprogramme usw..

Heutzutage betreibt milo das neue CMS von dailymail.co.uk (dieses CMS enthält Zehntausende von Front-End-JavaScript-Code und wird täglich für die Erstellung von mehr als 500 Artikeln verwendet.).

Milo ist Open Source und befindet sich noch in einer Beta-Phase. Es ist also ein guter Zeitpunkt, um damit zu experimentieren und vielleicht sogar beizutragen. Wir würden uns über Ihr Feedback freuen.


Beachten Sie, dass dieser Artikel sowohl von Jason Green als auch von Evgeny Poberezkin geschrieben wurde.