Reactief programmeren

In het eerste deel van de serie hebben we het gehad over componenten waarmee je verschillende gedragingen kunt beheren met behulp van facetten en hoe Milo berichtenuitwisseling kan beheren.

In dit artikel zullen we kijken naar een ander veel voorkomend probleem bij het ontwikkelen van browsertoepassingen: het verbinden van modellen met weergaven. We zullen een deel van de "magie" die bidirectionele databinding mogelijk maakt, ontrafelen in Milo, en om de zaken af ​​te ronden, zullen we een volledig functionele To Do-applicatie bouwen in minder dan 50 regels code.

Modellen (of Eval is niet slecht)

Er zijn verschillende mythen over JavaScript. Veel ontwikkelaars geloven dat eval slecht is en nooit mag worden gebruikt. Die overtuiging leidt ertoe dat veel ontwikkelaars niet kunnen zeggen wanneer EVAL kan en moet worden gebruikt.

Mantra's als "eval is slecht "kan alleen schadelijk zijn als we te maken hebben met iets dat in wezen een hulpmiddel is. Een tool is alleen "goed" of "slecht" wanneer een context wordt gegeven. Je zou niet zeggen dat een hamer slecht is, toch? Het hangt er echt van af hoe je het gebruikt. Bij gebruik met een spijker en wat meubels is "hamer goed". Wanneer gebruikt om je brood te boter, "hamer is slecht".

Hoewel we het daar zeker mee eens zijn eval heeft zijn beperkingen (bijvoorbeeld prestaties) en risico's (vooral als we de code van de gebruiker invoeren), zijn er nogal wat situaties waarin eval de enige manier is om de gewenste functionaliteit te bereiken.

Veel sjablerende engines gebruiken bijvoorbeeld eval binnen het bereik van met operator (nog een grote no-no onder ontwikkelaars) om sjablonen te compileren naar JavaScript-functies.

Toen we dachten wat we wilden van onze modellen, hebben we verschillende benaderingen overwogen. Een daarvan was om ondiepe modellen zoals Backbone doet met berichten uitgestuurd op modelwijzigingen. Hoewel ze gemakkelijk te implementeren zijn, zouden deze modellen een beperkt nut hebben - de meeste echte modellen zijn diep.

We hebben overwogen om eenvoudige JavaScript-objecten te gebruiken met de Object.observe API (wat de noodzaak om modellen te implementeren zou elimineren). Hoewel onze applicatie alleen met Chrome hoefde te werken, Object.observe is pas sinds kort standaard ingeschakeld. Vroeger moest hiervoor de Chrome-vlag worden ingeschakeld, waardoor zowel implementatie als ondersteuning moeilijk zouden zijn.

We wilden modellen die we konden verbinden met weergaven, maar op zo'n manier dat we de structuur van de weergave konden veranderen zonder een enkele coderegel te veranderen, zonder de structuur van het model te veranderen en zonder de conversie van het weergavemodel expliciet naar de gegevensmodel.

We wilden ook modellen met elkaar kunnen verbinden (zie reactieve programmering) en zich abonneren op modelwijzigingen. Angular implementeert horloges door de staat van modellen te vergelijken en dit wordt erg inefficiënt met grote, diepe modellen.

Na enige discussie hebben we besloten dat we onze modelklasse zouden implementeren die een eenvoudige get / set API zou ondersteunen om ze te manipuleren en die het mogelijk zou maken om zich te abonneren op veranderingen daarin:

var m = nieuw model; m ( 'info.name ') vastgesteld (' hoekig').; console.log (m ( 'info') te krijgen ().); // logs: name: 'angular' m.on ('. info.name', onNameChange); function onNameChange (msg, data) console.log ('Naam gewijzigd van', data.oldValue, 'to', data.newValue);  m ('. info.name'). set ('milo'); // logs: naam veranderd van hoekig naar milo console.log (m.get ()); // logs: info: name: 'milo' console.log (m ('. info'). get ()); // logs: name: 'milo'

Deze API lijkt op de normale toegang tot onroerend goed en moet een veilige en diepgaande toegang bieden tot eigenschappen - wanneer krijgen wordt aangeroepen op niet-bestaande eigenschappaden die worden geretourneerd onbepaald, en wanneer reeks wordt aangeroepen, wordt zo nodig een object / array-structuur gemaakt.

Deze API is gemaakt voordat deze was geïmplementeerd en de belangrijkste onbekende waarmee we te maken kregen, was het maken van objecten die ook opvraagbare functies waren. Het is gebleken dat om een ​​constructor te maken die objecten retourneert die kunnen worden aangeroepen, deze functie moet worden teruggezet van de constructor en dat het prototype zodanig moet worden ingesteld dat het een instantie van de constructor wordt. Model les op hetzelfde moment:

functie Model (data) // modelPath zou een ModelPath-object moeten terugbrengen // met methoden om modeleigenschappen te verkrijgen / instellen, // om zich te abonneren op eigenschapswijzigingen, enz. var model = functie modelPath (pad) retourneer nieuw ModelPath (model, pad);  model .__ proto__ = Model.prototype; model._data = data; model._messenger = nieuwe Messenger (model, Messenger.defaultMethods); retourmodel;  Model.prototype .__ proto__ = Model .__ proto__;

Terwijl de __proto__ eigenschap van het object is meestal beter te vermijden, het is nog steeds de enige manier om het prototype van de objectinstantie en het prototype van de constructor te wijzigen.

De instantie van ModelPath die moet worden geretourneerd wanneer het model wordt genoemd (bijv. m ( '. info.name) hierboven) presenteerde een andere implementatie-uitdaging. ModelPath instanties moeten beschikken over methoden die de eigenschappen van modellen die aan het model zijn doorgegeven correct hebben ingesteld (.info.name in dit geval). We hebben overwogen ze te implementeren door eenvoudig eigenschappen die als tekenreeks zijn doorgegeven, te parseren wanneer die eigenschappen worden geopend, maar we realiseerden ons dat dit tot inefficiënte prestaties zou hebben geleid.

In plaats daarvan hebben we besloten om ze zo te implementeren dat m ( '. info.name), retourneert bijvoorbeeld een object (een instantie van ModelPath "Klasse") die alle accessormethoden heeft (krijgen, reeks, del en verbinding) gesynthetiseerd als JavaScript-code en geconverteerd naar JavaScript-functies met eval.

We hebben ook al deze gesynthetiseerde methoden in de cache opgeslagen, dus zodra een model is gebruikt .info.name alle accessormethoden voor dit "eigenschapspad" worden in de cache opgeslagen en kunnen voor elk ander model opnieuw worden gebruikt.

De eerste implementatie van get-methode zag er als volgt uit:

functie synthesizeGetter (path, parsedPath) var getter; var getterCode = 'getter = functiewaarde ()' + '\ n var m =' + modelAccessPrefix + '; \ n return'; var modelDataProperty = 'm'; for (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; 

Maar de reeks methode zag er veel slechter uit en was erg moeilijk te volgen, te lezen en te onderhouden, omdat de code van de gemaakte methode zwaar werd afgewisseld met de code die de methode genereerde. Daarom zijn we overgestapt op het gebruik van de doT templating-engine om de code voor accessormethoden te genereren.

Dit was de vangstof na het overschakelen naar het gebruik van sjablonen:

var dotDef = modelAccessPrefix: 'this._model._data',; var getterTemplate = 'methode = functiewaarde () \ var m = # def.modelAccessPrefix; \ var modelDataProperty = "m";  \ return \ for (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); 

Dit bleek een goede aanpak te zijn. Hierdoor konden we de code maken voor alle toegangsmethoden die we hebben (krijgen, reeks, del en verbinding) zeer modulair en onderhoudbaar.

De model-API die we ontwikkelden, bleek behoorlijk bruikbaar en performant te zijn. Het is geëvolueerd om de syntaxis van arrayelementen te ondersteunen, verbinding methode voor arrays (en afgeleide methoden, zoals Duwen, knal, etc.) en interpolatie van eigenschappen / items.

De laatste is geïntroduceerd om te voorkomen dat er accessor-methoden worden gesynthetiseerd (wat een veel langzamere bewerking is voor toegang tot eigendom of item) wanneer het enige dat verandert, een eigenschap of itemindex is. Het zou gebeuren als arrayelementen binnen het model in de lus moeten worden bijgewerkt.

Beschouw dit voorbeeld:

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

In elke iteratie, a ModelPath exemplaar wordt gemaakt om de naamseigenschap van het arrayelement in het model te openen en bij te werken. Alle instanties hebben verschillende eigenschappaden en hiervoor zijn vier accessormethoden nodig voor elk van de 100 gebruikte elementen eval. Het zal een aanzienlijk trage operatie zijn.

Met interpolatie van eigenschapstoegang kan de tweede regel in dit voorbeeld worden gewijzigd in:

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

Het ziet er niet alleen leesbaarder uit, het is ook veel sneller. Terwijl we er nog steeds 100 creëren ModelPath In deze lus delen ze allemaal dezelfde accessor-methoden, dus in plaats van 400 maken we slechts vier methoden.

U bent van harte welkom om het prestatieverschil tussen deze monsters te schatten.

Reactief programmeren

Milo heeft reactieve programmering geïmplementeerd met waarneembare modellen die meldingen over zichzelf uitzenden wanneer een van hun eigenschappen verandert. Hierdoor hebben we reactieve gegevensverbindingen kunnen implementeren met behulp van de volgende API:

var connector = minder (m1, '<<<->>> ', m2 ('. info ')); // creëert bidirectionele reactieve verbinding // tussen model m1 en property ".info" van model m2 // met de diepte van 2 (eigenschappen en sub-eigenschappen // van modellen zijn verbonden).

Zoals je kunt zien van boven lijn, ModelPath teruggegeven door m2 ( 'info') zou dezelfde API moeten hebben als het model, wat betekent dat het dezelfde berichten-API heeft als het model en ook een functie is:

var mPath = m ('. info); mPath ('. name'). set ("); // stelt poperty '.info.name' in m mPath.on ('. name', onNameChange); // hetzelfde in als m ('. info.name') .on (", onNameChange) // same als m.on ('. info.name', onNameChange);

Op een vergelijkbare manier kunnen we modellen verbinden met weergaven. De componenten (zie het eerste deel van de serie) kunnen een gegevensfacet hebben dat dienst doet als een API om DOM te manipuleren alsof het een model is. Het heeft dezelfde API als model en kan worden gebruikt in reactieve verbindingen.

Dus deze code verbindt bijvoorbeeld een DOM-weergave met een model:

var connector = minder (m, '<<<->>> ', comp.data);

Het zal hieronder in meer detail worden gedemonstreerd in de voorbeeldtaaktoepassing.

Hoe werkt deze connector? Onder de motorkap onderschrijft de connector eenvoudigweg de wijzigingen in de gegevensbronnen aan beide zijden van de verbinding en geeft deze de ontvangen wijzigingen van de ene gegevensbron door aan een andere gegevensbron. Een gegevensbron kan een model, een modelpad, een gegevensfacet van de component of een ander object zijn dat dezelfde berichten-API implementeert als het model..

De eerste implementatie van de connector was vrij simpel:

// ds1 en ds2 - verbonden gegevensbronnen // modus bepaalt de richting en de diepte van verbindingsfunctie Connector (ds1, modus, ds2) var parsedMode = mode.match (/ ^ (\<*)\-+(\>*) $ /); _.extend (this, ds1: ds1, ds2: ds2, mode: mode, depth1: parsedMode [1] .length, depth2: parsedMode [2] .length, isOn: false); deze op();  _.extendProto (Connector, on: on, off: off); function on () var subscriptionPath = this._subscriptionPath = new Array (this.depth1 || this.depth2) .join ('*'); var self = this; 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, subscriptionPath) var onData = function onData (pad, gegevens) // voorkomt eindeloze berichtlus // voor bidirectionele verbindingen als (onData .__ stopLink) terugkeert; var dsPath = linkToDS.path (pad); if (dsPath) self [stopLink] .__ stopLink = true; dsPath.set (data.newValue); verwijder zelf [stopLink] .__ stopLink; linkedDS.on (subscriptionPath, onData); self [linkName] = onData; return onData;  function off () var self = this; unlinkDataSource (this.ds1, '_link2'); unlinkDataSource (this.ds2, '_link1'); this.isOn = false; function unlinkDataSource (linkedDS, linkName) if (self [linkName]) linkedDS.off (self._subscriptionPath, self [linkName]); verwijder zelf [linknaam]; 

Inmiddels zijn de reactieve verbindingen in milo aanzienlijk geëvolueerd - ze kunnen datastructuren veranderen, de gegevens zelf veranderen en ook datavalidaties uitvoeren. Hierdoor hebben we een zeer krachtige UI / form-generator kunnen maken die we ook van plan zijn om open source te maken.

Een Taken-app bouwen

Velen van jullie zullen op de hoogte zijn van het TodoMVC-project: een verzameling To Do-app-implementaties gemaakt met behulp van een verscheidenheid aan verschillende MV * -structuren. De To Do-app is een perfecte test voor elk framework, omdat het vrij eenvoudig te bouwen en te vergelijken is, maar toch een redelijk breed scala aan functies vereist, inclusief CRUD-bewerkingen (creëren, lezen, bijwerken en verwijderen), DOM-interactie en weergave / model bindend om er maar een paar te noemen.

In verschillende stadia van de ontwikkeling van Milo hebben we geprobeerd eenvoudige To Do-toepassingen te bouwen en zonder uitzondering hebben we framefouten of tekortkomingen aan het licht gebracht. Zelfs diep in ons hoofdproject, toen Milo werd gebruikt om een ​​veel complexere toepassing te ondersteunen, hebben we op deze manier kleine bugs gevonden. Inmiddels bestrijkt het framework de meeste gebieden die nodig zijn voor de ontwikkeling van webtoepassingen en we vinden de code die nodig is om de To-Do-app te bouwen vrij beknopt en declaratief..

Ten eerste hebben we de HTML-markup. Het is een standaard HTML boilerplate met een beetje styling om gecontroleerde items te beheren. In het lichaam hebben we een ml-bind attribuut om de takenlijst te declareren, en dit is slechts een eenvoudig onderdeel van de lijst facet toegevoegd. Als we meerdere lijsten zouden willen hebben, zouden we waarschijnlijk een componentklasse moeten definiëren voor deze lijst.

In de lijst staat ons voorbeelditem dat is gedeclareerd met behulp van een aangepast gebruik Te doen klasse. Hoewel het niet nodig is om een ​​klasse te declareren, maakt dit het beheren van de kinderen van de component veel eenvoudiger en modulair.

            

To-Do's

Model

Om ons te laten rennen milo.binder () nu moeten we eerst het Te doen klasse. Deze klasse moet de hebben item facet, en zal in principe verantwoordelijk zijn voor het beheren van de verwijderknop en het selectievakje dat op elk van deze knoppen staat Te doen.

Voordat een component op zijn kinderen kan werken, moet het eerst wachten op de childrenbound evenement om erop te schieten. Raadpleeg de documentatie (link naar componentdocumenten) voor meer informatie over de levenscyclus van componenten..

// Een nieuwe gefacetteerde componentklasse maken met het 'item'-facet. // Dit zou meestal worden gedefinieerd in zijn eigen bestand. // Opmerking: het itemfacet zal 'vereisen' in // de 'container', 'data' en 'dom' facetten var Todo = _.createSubclass (milo.Component, 'Todo'); milo.registry.components.add (Todo); // Het toevoegen van onze eigen aangepaste init-methode _.extendProto (Todo, init: Todo $ init); function Todo $ init () // De overgeërfde init-methode aanroepen. milo.Component.prototype.init.apply (this, arguments); // Luisteren naar 'kinderengebonden' dat wordt afgevuurd nadat het binder // is geëindigd met alle kinderen van dit onderdeel. this.on ('childrenbound', function () // We krijgen het bereik (de onderliggende componenten leven hier) var scope = this.container.scope; // En stel twee abonnementen in, één voor de gegevens van het selectievakje // Met de syntaxis van het abonnement kunt u de context doorgeven scope.checked.data.on (", subscriber: checkTodo, context: this); // en een naar de 'klik'-gebeurtenis van de delete-knop. Scope.deleteBtn.events.on ('klik', subscriber: removeTodo, context: this);); // Als het selectievakje wordt gewijzigd, stellen we de klasse van de Todo dienovereenkomstig functie checkTodo (pad, gegevens) this.el.classList.toggle ('todo-item-checked', data.newValue); // Om het item te verwijderen, gebruiken we de 'removeItem'-methode van de facetfunctie' item 'removeTodo (eventType, event) this.item.removeItem () ;

Nu we die configuratie hebben, kunnen we de binder oproepen om componenten aan DOM-elementen te koppelen, een nieuw model met tweewegverbinding met de lijst te maken via zijn gegevensfacet.

// Milo-ready-functie, werkt als de klaar-functie van jQuery. milo (function () // Oproepbinder van het document. // Het koppelt componenten aan DOM-elementen met ml-bindattribuut var scope = milo.binder (); // Krijg toegang tot onze componenten via het scope-object var todos = scope.todos // Todos-lijst, newTodo = scope.newTodo // Nieuwe todo-invoer, addBtn = scope.addBtn // Knop Toevoegen, modelView = scope.modelView; // Waar we het model afdrukken // Stel ons model in, dit zal bezit de array van todos var m = new milo.Model; // Dit abonnement toont ons altijd de inhoud van het // -model onder de todos m.on (/.*/, function showModel (msg, data)  modelView.data.set (JSON.stringify (m.get ()));); // Maak een diepe tweerichtingsbinding tussen ons model en het todos-lijstgegevensfacet // De binnenste punthaken tonen verbindingsrichting (kan ook een manier), // de rest definieert verbindingsdiepte - in dit geval 2 niveaus, om // de eigenschappen van array-items op te nemen. milo.minder (m, '<<<->>> ', todos.data); // Abonnement op gebeurtenis van add-knop addBtn.events.on ('click', addTodo); // Klik handler van add-knop functie addTodo () // We verpakken de 'newTodo' invoer als een object // De eigenschap 'tekst' komt overeen met de item-markup. var itemData = text: newTodo.data.get (); // We duwen die gegevens in het model. // De weergave wordt automatisch bijgewerkt! m.push (itemData); // En ten slotte stelt u de invoer opnieuw in op leeg. newTodo.data.set (");); 

Dit voorbeeld is beschikbaar in jsfiddle.

Conclusie

To-Do-voorbeeld is heel eenvoudig en het toont een heel klein deel van de ontzagwekkende kracht van Milo. Milo heeft veel functies die niet in deze en de vorige artikelen zijn opgenomen, zoals slepen en neerzetten, lokale opslag, http en websockets-hulpprogramma's, geavanceerde DOM-hulpprogramma's, enz..

Tegenwoordig levert milo het nieuwe CMS van dailymail.co.uk (dit CMS heeft tienduizenden front-end javascript-code en wordt gebruikt om elke dag meer dan 500 artikelen te maken).

Milo is open source en nog steeds in een bètasefase, dus het is een goed moment om ermee te experimenteren en misschien zelfs een bijdrage te leveren. We zijn dol op je feedback.


Merk op dat dit artikel zowel door Jason Green als door Evgeny Poberezkin is geschreven.