Reaktiv programmering

I første del av serien snakket vi om komponenter som tillater deg å håndtere ulike oppføringer ved hjelp av fasetter, og hvordan Milo styrer meldinger.

I denne artikkelen ser vi på et annet vanlig problem i utviklingen av nettleserprogrammer: Tilkobling av modeller til visninger. Vi vil unravel noen av "magien" som gjør det mulig å gi toveisbinding i Milo, og for å pakke opp ting, vil vi bygge et fullt funksjonelt To Do-program på mindre enn 50 kodelinjer.

Modeller (eller Eval er ikke ondt)

Det er flere myter om JavaScript. Mange utviklere tror at eval er ondskap og aldri skal brukes. Den troen fører til at mange utviklere ikke kan si når eval kan og skal brukes.

Mantra som "eval er ondt "kan bare være skadelig når vi har å gjøre med noe som egentlig er et verktøy. Et verktøy er bare "godt" eller "dårlig" når det er gitt en kontekst. Du ville ikke si at en hammer er ond, ikke sant? Det avhenger virkelig hvordan du bruker det. Når det brukes med spiker og noen møbler, er "hammer bra". Når du smør brødet ditt, er "hammer dårlig".

Mens vi definitivt er enige om at eval har begrensninger (for eksempel ytelse) og risiko (spesielt hvis vi bruker eval kode), er det ganske mange situasjoner når eval er den eneste måten å oppnå ønsket funksjonalitet på.

For eksempel bruker mange templerende motorer eval innenfor rekkevidden av med operatør (en annen stor ikke-nei blant utviklere) for å kompilere maler til JavaScript-funksjoner.

Da vi tenkte hva vi ønsket fra våre modeller, vurderte vi flere tilnærminger. En var å ha grunne modeller som Backbone gjør med meldinger utstilt på modellendringer. Selv om det er enkelt å implementere, ville disse modellene ha begrenset nytte - de fleste virkelige modeller er dype.

Vi vurderte å bruke enkle JavaScriptobjekter med Object.observe API (som ville eliminere behovet for å implementere noen modeller). Selv om søknaden vår bare trengte å fungere med Chrome, Object.observe Bare nylig ble aktivert som standard - tidligere måtte det slås på Chrome-flagg, noe som ville ha gjort både distribusjon og støtte vanskelig.

Vi ønsket modeller som vi kunne koble til visninger, men på en slik måte at vi kunne endre visningsstrukturen uten å endre en enkelt linje med kode uten å endre strukturen til modellen og uten å eksplisitt administrere konverteringen av visningsmodellen til datamodell.

Vi ønsket også å kunne koble modeller til hverandre (se reaktiv programmering) og abonnere på modellendringer. Vinkel utfører klokker ved å sammenligne tilstandene av modellene, og dette blir svært ineffektivt med store, dype modeller.

Etter en diskusjon bestemte vi oss for at vi ville implementere vår modellklasse som ville støtte en enkel få / sett API for å manipulere dem, og det ville tillate abonnement på endringer i dem:

var m = ny modell; m (. 'info.name ') innstilles (' vinkel.'); console.log (m ( 'info') får ().); // logger: navn: 'vinkel' m.on ('. info.name', onNameChange); Funksjon onNameChange (msg, data) console.log ('Navn endret fra', data.oldValue, 'to', data.newValue);  m ('. info.name') sett ('milo'); // logger: Navn endret fra vinkel til milo console.log (m.get ()); // logger: info: name: 'milo' console.log (m ('info'). get ()); // logger: navn: 'milo'

Denne API-en ligner på normal tilgang til eiendommen og skal gi sikker dyp tilgang til egenskaper - når kalles på ikke-eksisterende eiendomsbaner den returnerer udefinert, og når sett kalles, det skaper manglende objekt / array tre etter behov.

Denne API-en ble opprettet før den ble implementert, og det viktigste ukjente vi møtte var hvordan du oppretter objekter som også var tilgjengelige for å ringe. Det viser seg at for å opprette en konstruktør som returnerer objekter som kan kalles, må du returnere denne funksjonen fra konstruktøren og sette sin prototype for å gjøre det til en forekomst av Modell klassen samtidig:

funksjonsmodell (data) // modelPath skal returnere et ModelPath-objekt // med metoder for å få / angi modellegenskaper, // for å abonnere på eiendomsendringer, etc. varmodell = funksjonsmodellPath (vei) returnere ny ModelPath (modell, sti);  modell .__ proto__ = Model.prototype; model._data = data; model._messenger = ny Messenger (modell, Messenger.defaultMethods); retur modell;  Model.prototype .__ proto__ = Modell .__ proto__;

Mens __proto__ egenskapen til objektet er vanligvis bedre å unngås, det er fortsatt den eneste måten å endre prototypen på objektet forekomst og konstruktør prototypen.

Forekomsten av ModelPath som skal returneres når modellen heter (f.eks. m (. 'info.name') ovenfor) presenterte en annen implementasjonsutfordring. ModelPath forekomster bør ha metoder som riktig angir egenskapene til modellene som sendes til modell når den ble kalt (.info.name i dette tilfellet). Vi vurderte å implementere dem ved bare å analysere egenskaper som er gått som strenger når disse egenskapene er tilgjengelige, men vi skjønte at det ville ha resultert i ineffektiv ytelse.

I stedet bestemte vi oss for å implementere dem på en slik måte at m (. 'info.name'), for eksempel returnerer et objekt (en forekomst av ModelPath "Klasse") som har alle tilgangsmetoder (, sett, del og skjøt) syntetisert som JavaScript-kode og konvertert til JavaScript-funksjoner ved hjelp av eval.

Vi har også gjort alle disse syntetiserte metodene bufret så snart en hvilken som helst modell som er brukt .info.name Alle tilgangsmetoder for denne "eiendomsveien" er bufret og kan gjenbrukes for enhver annen modell.

Den første implementeringen av få-metoden så slik ut:

funksjon synthesizeGetter (bane, parsedPath) var getter; var getterCode = 'getter = funksjonsverdi ()' + '\ n var m =' + modelAccessPrefix + '; \ n retur'; 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; 

Men sett Metoden så mye verre ut og var veldig vanskelig å følge, for å lese og vedlikeholde, fordi koden til den opprettede metoden var tungt spredt med koden som genererte metoden. På grunn av det, byttet vi til bruk av doT-templeringsmotoren for å generere koden for tilgangsmetoder.

Dette var getter etter å ha byttet til bruk av maler:

var dotDef = modelAccessPrefix: 'this._model._data',; var getterTemplate = 'metode = funksjonsverdi () \ 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); 

Dette viste seg å være en god tilnærming. Det tillot oss å lage koden for alle tilgangsmetoder vi har (, sett, del og skjøt) veldig modulær og vedlikeholdsbar.

Modellen API vi utviklet viste seg å være ganske brukbar og effektiv. Det utviklet seg til å støtte arrayelementsyntaxen, skjøt metode for arrays (og avledede metoder, for eksempel trykk, pop, etc.), og interpolering av eiendom / elementtilgang.

Sistnevnte ble introdusert for å unngå å syntetisere tilgangsmetoder (som er mye langsommere operasjon som åpner for eiendom eller gjenstand) når det eneste som endres, er en del eiendom eller vareindeks. Det ville skje hvis arrayelementer inne i modellen må oppdateres i løkken.

Vurder dette eksempelet:

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

I hver iterasjon, a ModelPath forekomst er opprettet for å få tilgang til og oppdatere navnegenskapen til arrayelementet i modellen. Alle forekomster har forskjellige egenskapsstier, og det vil kreve syntetisering av fire tilgangsmetoder for hver av de 100 elementene som brukes eval. Det vil være en betydelig sakte drift.

Med tilgangsinterpolering kan den andre linjen i dette eksemplet endres til:

var mPath = m ('. liste [$ 1] .navn', i);

Ikke bare ser det ut til å være lesbar, det er mye raskere. Mens vi fortsatt lager 100 ModelPath forekomster i denne sløyfen, vil alle dele de samme tilgangsmetoder, så i stedet for 400 syntetiserer vi bare fire metoder.

Du er velkommen til å estimere ytelsesforskjellen mellom disse prøvene.

Reaktiv programmering

Milo har implementert reaktiv programmering med observerbare modeller som avgir meldinger om seg selv når noen av deres egenskaper endres. Dette har gitt oss mulighet til å implementere reaktive dataforbindelser ved hjelp av følgende API:

var-kontakt = minder (m1, '<<<->>> ', m2 ('. info ')); // skaper toveis reaktiv forbindelse // mellom modell m1 og eiendom ".info" av modell m2 // med dybden på 2 (egenskaper og underegenskaper // av modellene er tilkoblet).

Som du kan se fra ovenstående linje, ModelPath returnert av m2 ( 'info') bør ha samme API som modellen, som betyr at samme messaging API som modell og også er en funksjon:

var mPath = m ('. info); mPath ('.navn'). set ("); // sett poperty '.info.name' i m mPath.on ('.navn', onNameChange); // samme som m ('.info.name') .on (", onNameChange) // samme som m.on ('. info.name', onNameChange);

På samme måte kan vi koble modeller til visninger. Komponentene (se første del av serien) kan ha en datafasett som fungerer som en API for å manipulere DOM som om det var en modell. Den har samme API som modell og kan brukes i reaktive tilkoblinger.

Så kobler denne koden for eksempel en DOM-visning til en modell:

var-kontakt = mindre (m, '<<<->>> ', comp.data);

Det vil bli demonstrert mer detaljert nedenfor i prøven To-Do-applikasjon.

Hvordan fungerer denne kontakten? Under hetten kobler kontakten bare til endringene i datakildene på begge sider av tilkoblingen og sender endringene mottatt fra en datakilde til en annen datakilde. En datakilde kan være en modell, modellbane, datafasett av komponenten eller noe annet objekt som implementerer samme messaging API som modell gjør.

Den første implementeringen av kontakten var ganske enkel:

// ds1 og ds2 - tilkoblede datakilder // modus definerer retningen og dybden på tilkoblingsfunksjonen Kobling (ds1, modus, ds2) var parsedMode = mode.match (/ ^ (\<*)\-+(\>*) $ /); _.extend (dette, ds1: ds1, ds2: ds2, modus: modus, dybde1: parsedMode [1] .length, deep2: parsedMode [2] .length, isOn: false); this.on ();  _.extendProto (Connector, on: on, off: off); fungere på () var subscriptionPath = this._subscriptionPath = new Array (this.depth1 || this.depth2) .join ('*'); var selv = dette; hvis (this.depth1) linkDataSource ('_link1', '_link2', this.ds1, this.ds2, subscriptionPath); hvis (this.depth2) linkDataSource ('_link2', '_link1', this.ds2, this.ds1, subscriptionPath); this.isOn = true; funksjon linkDataSource (linkName, stopLink, linkToDS, linkedDS, subscriptionPath) var onData = funksjon onData (sti, data) // forhindrer endeløs meldingsløype // for toveis forbindelser hvis (onData .__ stopLink) returnerer; var dsPath = linkToDS.path (bane); hvis (dsPath) selv [stopLink] .__ stopLink = true; dsPath.set (data.newValue); slett selv [stopLink] .__ stopLink; linkedDS.on (abonnementPath, onData); selv [linkName] = onData; returnere onData;  funksjon av () var selv = dette; unlinkDataSource (this.ds1, '_link2'); unlinkDataSource (this.ds2, '_link1'); this.isOn = false; funksjon unlinkDataSource (linkedDS, linkName) if (selv [linkName]) linkedDS.off (self._subscriptionPath, self [linkName]); slett selv [linkName]; 

Ved nå har de reaktive tilkoblingene i milo utviklet seg vesentlig - de kan endre datastrukturer, endre dataene selv og utføre data valideringer. Dette har gitt oss mulighet til å lage en meget kraftig UI / form generator som vi planlegger å lage åpen kildekode også.

Bygg en til-gjør-app

Mange av dere vil være oppmerksomme på TodoMVC-prosjektet: En samling av To-Do-appimplementeringer gjort ved hjelp av en rekke forskjellige MV * -rammer. Tildeling-appen er en perfekt test av et hvilket som helst rammeverk som det er ganske enkelt å bygge og sammenligne, men krever et ganske bredt spekter av funksjonalitet, inkludert CRUD (opprette, lese, oppdatere og slette) operasjoner, DOM-interaksjon og visning / modell bindende for å nevne noen få.

På ulike stadier av utviklingen av Milo, forsøkte vi å bygge enkle To-Do applikasjoner, og uten å lykkes, fremhevet det rammebetingelser eller mangler. Selv dypt inn i vårt hovedprosjekt, da Milo ble brukt til å støtte et mye mer komplekst program, har vi funnet små feil på denne måten. Rammen dekker nå de fleste områder som kreves for webapplikasjonsutvikling, og vi finner koden som kreves for å bygge Applikasjonen til å være ganske kortfattet og deklarativ.

Først har vi HTML-oppslaget. Det er en standard HTML boilerplate med litt styling for å administrere kontrollerte elementer. I kroppen har vi en ml-bind attributt til å erklære To-Do-listen, og dette er bare en enkel komponent med liste fasett lagt til. Hvis vi ønsket å ha flere lister, bør vi sannsynligvis definere en komponentklasse for denne listen.

På innsiden av listen er vår eksempelpost, som har blitt erklært ved bruk av en egendefinert Å gjøre klasse. Selv om det ikke er nødvendig å erklære en klasse, gjør det at forvaltningen av komponentens barn er mye enklere og modulær.

            

To-Do er

Modell

For at vi skal kunne løpe milo.binder () nå må vi først definere Å gjøre klasse. Denne klassen må ha punkt fasett, og vil i utgangspunktet være ansvarlig for å administrere sletteknappen og avmerkingsboksen som finnes på hver Å gjøre.

Før en komponent kan operere på sine barn, må den først vente på childrenbound Event å bli sparket på den. For mer informasjon om komponentens livssyklus, sjekk ut dokumentasjonen (lenke til komponentdokumenter).

// Opprette en ny fasettkomponentklasse med elementets fasett. // Dette vil vanligvis bli definert i sin egen fil. // Merk: Objektets fasett vil 'kreve' i // 'container', 'data' og 'dom' fasetter var Todo = _.createSubclass (milo.Component, 'Todo'); milo.registry.components.add (Todo); // Legge til vår egen tilpassede init metode _.extendProto (Todo, init: Todo $ init); funksjon Todo $ init () // Kaller den arvelige init-metoden. milo.Component.prototype.init.apply (dette, argumenter); // Lytt til 'barnbundet' som er sparket etter bindemiddel // har fullført alle barn av denne komponenten. this.on ('childrenbound', function () // Vi får omfanget (barnkomponentene bor her) var scope = this.container.scope; // Og oppsett to abonnementer, en til dataene i avkrysningsboksen // Abonnementssyntaxen tillater kontekst å passere scope.checked.data.on (", abonnent: checkTodo, kontekst: dette); // og en til sletteknappens" klikk "-hendelse. Scope.deleteBtn.events.on ("klikk", abonnent: removeTodo, context: this);; // Når avkrysningsboks endres, setter vi klassen av Todo tilsvarende funksjon checkTodo (sti, data) this.el.classList.toggle ('todo-item-checked', data.newValue); // For å fjerne elementet bruker vi 'removeItem'-metoden for elementelementfunksjonen removeTodo (eventType, event) this.item.removeItem ;

Nå som vi har det oppsettet, kan vi ringe bindemidlet for å feste komponenter til DOM-elementer, lage en ny modell med toveisforbindelse til listen via datasiden.

// Milo klar funksjon, fungerer som jQuery er klar funksjon. milo (funksjon () // Ring bindemiddel på dokumentet. // Det fester komponenter til DOM elementer med ml-bind attributt var scope = milo.binder (); // Få tilgang til våre komponenter via objektivet objekt var todos = scope.todos // Todos list, newTodo = scope.newTodo // Ny todo input, addBtn = scope.addBtn // Legg til knapp, modelView = scope.modelView; // Hvor vi skriver ut modell // Oppsett vår modell, dette vil hold rekkevidden av todos var m = ny milo.Model; // Dette abonnementet viser oss innholdet i // -modellen til enhver tid under todos m.on (/.*/, funksjon showModel (msg, data)  modelView.data.set (JSON.stringify (m.get ());); // Lag en dyp toveisbinding mellom vår modell og todos listefaktoren. // De innerste chevrons viser tilkoblingsretning (kan også være en måte), // resten definerer tilkoblingsdybde - 2 nivåer i dette tilfellet, for å inkludere // egenskapene til arrayelementer. milo.minder (m, '<<<->>> ', todos.data); // Abonnement for å klikke hendelsen på add-knappen addBtn.events.on ('klikk', addTodo); // Klikk håndterer av add-button-funksjonen addTodo () // Vi pakker inn 'newTodo' -inngangen som et objekt // Egenskapen 'tekst' tilsvarer elementoppslaget. var itemData = text: newTodo.data.get (); // Vi skyver dataene inn i modellen. // Utsikten vil bli oppdatert automatisk! m.push (itemData); // Og endelig sett innspillet til tomt igjen. newTodo.data.set (");); 

Denne prøven er tilgjengelig i jsfiddle.

Konklusjon

Å-gjøre-prøven er veldig enkel, og det viser en svært liten del av Milos fantastiske makt. Milo har mange funksjoner som ikke er dekket i dette og de forrige artiklene, inkludert dra og slipp, lokal lagring, http og websocketsverktøy, avanserte DOM-verktøy, osv..

I dag driver milo det nye CMS av dailymail.co.uk (dette CMS har titusenvis av frontend-javascript-kode og brukes til å lage mer enn 500 artikler hver dag).

Milo er åpen kildekode og fortsatt i en beta-fase, så det er en god tid å eksperimentere med det og kanskje til og med bidra. Vi vil gjerne ha tilbakemelding.


Merk at denne artikkelen ble skrevet av både Jason Green og Evgeny Poberezkin.