Rolling Your Own Framework

Een compleet nieuw raamwerk bouwen, is niet iets waar we specifiek op uit waren. Je zou wel gek moeten zijn, toch? Met de overvloed aan JavaScript-frameworks die er zijn, welke mogelijke motivatie zouden we kunnen hebben om onze eigen frameworks te ontwikkelen? 

We waren oorspronkelijk op zoek naar een kader voor het bouwen van het nieuwe inhoudbeheersysteem voor de website van The Daily Mail. Het belangrijkste doel was om het bewerkingsproces veel interactiever te maken, waarbij alle elementen van een artikel (afbeeldingen, insluitingen, call-out boxes, enzovoort) dragbaar, modulair en zelfsturend zijn.

Alle frameworks die we konden gebruiken waren ontworpen voor min of meer statische UI gedefinieerd door ontwikkelaars. We moesten een artikel maken met zowel bewerkbare tekst als dynamisch weergegeven UI-elementen.

Backbone was te laag niveau. Het deed weinig meer dan het bieden van basisstructuren en berichten. We zouden veel abstractie moeten opbouwen boven de basis van Backbone, dus we hebben besloten dat we dit fundament liever zelf bouwen.

AngularJS werd ons voorkeursraamwerk voor het bouwen van kleine tot middelgrote browsertoepassingen met relatief statische gebruikersinterfaces. Helaas is AngularJS een black box - het biedt geen handige API om de objecten die je ermee maakt uit te breiden en te manipuleren - richtlijnen, controllers, services. Hoewel AngularJS reactieve verbindingen biedt tussen weergaven en scope-expressies, kunnen er geen reactieve verbindingen tussen modellen worden gedefinieerd, dus elke toepassing van gemiddelde grootte lijkt erg op een jQuery-toepassing met de spaghetti van gebeurtenislisteners en callbacks, met het enige verschil dat in plaats van gebeurtenislisteners heeft een hoekige toepassing waarnemers en in plaats van DOM te manipuleren manipuleer je scopes.

Wat we altijd al wilden, was een raamwerk dat zou toelaten;

  • Applicaties op een declaratieve manier ontwikkelen met reactieve koppelingen van modellen naar views.
  • Het creëren van reactieve databindingen tussen verschillende modellen in de applicatie om gegevenspropagatie te beheren in een declaratieve in plaats van in een imperatieve stijl.
  • Validators en vertalers invoegen in deze bindingen, zodat we weergaven aan gegevensmodellen kunnen binden in plaats van modellen zoals in AngularJS te bekijken.
  • Nauwkeurige controle over componenten gekoppeld aan DOM-elementen.
  • Flexibiliteit van weergavenbeheer waarmee u DOM-wijzigingen automatisch kunt manipuleren en sommige secties opnieuw kunt renderen met behulp van een templating-engine in gevallen waarin rendering efficiënter is dan DOM-manipulatie.
  • Mogelijkheid om dynamisch gebruikersinterfaces te maken.
  • In staat zijn om mechanismen achter data-reactiviteit aan te haken en om beeldupdates en datastromen nauwkeurig te beheren.
  • De functionaliteit van door het framework geleverde componenten kunnen uitbreiden en nieuwe componenten kunnen creëren.

We konden niet vinden wat we nodig hadden in bestaande oplossingen, dus zijn we begonnen met het ontwikkelen van Milo parallel aan de applicatie die het gebruikt.

Waarom Milo?

Milo werd gekozen als de naam vanwege Milo Minderbinder, een oorlogswinnaar uit Vangst 22 door Joseph Heller. Hij is begonnen met het beheren van rommeloperaties en heeft ze uitgebreid tot een winstgevende handelsonderneming die iedereen met alles heeft verbonden, en dat Milo en alle anderen "een deel hebben".

Milo het raamwerk heeft de modulebinder, die DOM-elementen aan componenten bindt (via special ml-bind attribuut) en de module minder waarmee live reactieve verbindingen tussen verschillende gegevensbronnen tot stand kunnen worden gebracht (model en gegevensfacet van componenten zijn dergelijke gegevensbronnen).

Toevallig kan Milo worden gelezen als een afkorting van MaIL Online en zonder de unieke werkomgeving bij Mail Online zouden we het nooit hebben kunnen bouwen.

Beheer van weergaven

binder

Weergaven in Milo worden beheerd door componenten, in feite gevallen van JavaScript-klassen die verantwoordelijk zijn voor het beheer van een DOM-element. Veel frameworks gebruiken componenten als een concept om UI-elementen te beheren, maar de meest voor de hand liggende die in je opkomt is Ext JS. We hadden veel samengewerkt met Ext JS (de oude applicatie die we aan het vervangen waren, werd ermee gebouwd) en we wilden vermijden wat we als twee nadelen zagen van de aanpak.

De eerste is dat Ext JS het u niet gemakkelijk maakt om uw markup te beheren. De enige manier om een ​​UI te bouwen, is door geneste hiërarchieën van componentconfiguraties samen te stellen. Dit leidt tot nodeloos complexe, gerenderde markeringen en haalt controle uit de handen van de ontwikkelaar. We hadden een methode nodig om inline componenten te maken, in onze eigen, met de hand gemaakte HTML-markup. Dit is waar bindmiddel binnenkomt.

Binder scant onze opmaak op zoek naar de ml-bind attribuut zodat het componenten kan instantiëren en binden aan het element. Het attribuut bevat informatie over de componenten; dit kan de componentklasse, facetten omvatten en moet de componentnaam bevatten.

Onze milocomponent

We zullen het zo meteen hebben over facetten, maar laten we nu eens kijken hoe we deze attribuutwaarde kunnen nemen en de configuratie eruit kunnen halen met een reguliere expressie.

var bindAttrRegex = / ^ ([^ \: \ [\]] *) (?: \ [([^ \: \ [\]] *) \])? \ :? ([^:] *) $ / ; var result = value.match (bindAttrRegex); // result is een array met // result [0] = 'ComponentClass [facet1, facet2]: componentName'; // result [1] = 'ComponentClass'; // result [2] = 'facet1, facet2'; // result [3] = 'componentName';

Met die informatie hoeven we alleen maar de hele iter te herhalen ml-bind kenmerken, extraheer deze waarden en maak exemplaren om elk element te beheren.

var bindAttrRegex = / ^ ([^ \: \ [\]] *) (?: \ [([^ \: \ [\]] *) \])? \ :? ([^:] *) $ / ; functiebinder (callback) var scope = ; // we krijgen alle elementen met het ml-bind attribuut var els = document.querySelectorAll ('[ml-bind]'); Array.prototype.forEach.call (els, function (el) var attrText = el.getAttribute ('ml-bind'); var result = attrText.match (bindAttrRegex); var className = result [1] || 'Component '; var facets = resultaat [2] .split (', '); var compName = resultaten [3]; // ervan uitgaande dat we een registerobject hebben van al onze klassen var comp = new classRegistry [className] (el); comp .addFacets (facetten); comp.name = compName; scope [compName] = comp; // we houden een verwijzing naar de component op het element el .___ milo_component = comp;); callback (scope);  bindmiddel (functie (bereik) console.log (bereik););

Dus met slechts een klein beetje regex en sommige DOM-traversalen, kunt u uw eigen mini-framework met aangepaste syntaxis maken dat past bij uw specifieke bedrijfslogica en -context. In zeer kleine code hebben we een architectuur opgezet die modulaire, zelfsturende componenten mogelijk maakt, die u kunt gebruiken zoals u maar wilt. We kunnen handige en declaratieve syntaxis maken voor het instantiëren en configureren van componenten in onze HTML, maar anders dan hoekig kunnen we deze componenten beheren, maar we houden ervan.

Verantwoordelijkheidsgestuurd ontwerp

Het tweede wat we niet leuk vonden aan Ext JS was dat het een zeer steile en rigide klassehiërarchie heeft, waardoor het moeilijk zou zijn om onze componentklassen te organiseren. We hebben geprobeerd een lijst te maken van alle gedragingen die een bepaald onderdeel van een artikel kan hebben. Een onderdeel kan bijvoorbeeld bewerkbaar zijn, het kan naar gebeurtenissen luisteren, het kan een doelwit zijn of zelf dragbaar zijn. Dit zijn slechts enkele van de benodigde gedragingen. Een voorlopige lijst die we opschreven, bevatte ongeveer 15 verschillende soorten functionaliteit die van een bepaald onderdeel kunnen worden verlangd.

Proberen om dit gedrag in een soort hiërarchische structuur te organiseren, zou niet alleen een grote hoofdpijn geweest zijn, maar ook zeer beperkend als we ooit de functionaliteit van een bepaalde componentklasse zouden willen veranderen (iets waar we uiteindelijk veel aan hebben gedaan). We hebben besloten om een ​​meer flexibel objectgericht ontwerppatroon te implementeren.

We hadden Responsibility-Driven Design gelezen, wat in tegenstelling tot het meer gebruikelijke model van het definiëren van het gedrag van een klasse, samen met de gegevens die het bevat, meer bezig is met de acties waarvoor een object verantwoordelijk is. Dit paste ons goed omdat we te maken hadden met een complex en onvoorspelbaar gegevensmodel, en deze aanpak zou ons in staat stellen om de implementatie van deze details later over te laten. 

Het belangrijkste dat we wegnamen van RDD was het concept van Rollen. Een rol is een reeks verwante verantwoordelijkheden. In het geval van ons project hebben we rollen geïdentificeerd zoals bewerken, slepen, dropzone, selecteerbaar of evenementen uit vele andere. Maar hoe representeer je deze rollen in code? Daarvoor hebben we geleend van het patroon van de decorateur.

Met het patroon van de decorateur kan gedrag worden toegevoegd aan een individueel object, hetzij statisch of dynamisch, zonder het gedrag van andere objecten uit dezelfde klasse te beïnvloeden. Hoewel de run-time manipulatie van klassengedrag niet bijzonder noodzakelijk was in dit project, waren we erg geïnteresseerd in het type inkapseling dat dit idee biedt. De implementatie van Milo is een soort hybride waarbij objecten worden gebruikt die facetten worden genoemd en die als eigenschappen aan de componentinstantie zijn gekoppeld. Het facet krijgt een verwijzing naar het onderdeel, het is 'eigenaar', en een configuratieobject, waarmee we facetten voor elke componentklasse kunnen aanpassen. 

U kunt facetten facetten zien als geavanceerde, configureerbare mixins die hun eigen naamruimte krijgen op hun eigen object en zelfs op hun eigen object in het methode, die moet worden overschreven door de facetsubklasse.

function Facet (eigenaar, config) this.name = this.constructor.name.toLowerCase (); this.owner = eigenaar; this.config = config || ; this.init.apply (this, arguments);  Facet.prototype.init = function Facet $ init () ;

Dus we kunnen dit eenvoudig subclasseren Facet klasse en maak specifieke facetten voor elk type gedrag dat we willen. Milo is vooraf gebouwd met een verscheidenheid aan facetten, zoals de DOM facet, dat een verzameling DOM-hulpprogramma's biedt die werken op het element van de eigenaarcomponent, en de Lijst en Item facetten, die samenwerken om lijsten met herhalende componenten te maken.

Deze facetten worden dan samengebracht door wat we a FacetedObject, wat een abstracte klasse is waarvan alle componenten erven. De FacetedObject heeft een class-methode genoemd createFacetedClass dat zichzelf simpelweg subklassen, en alle facetten aan een facetten eigendom van de klas. Op die manier, wanneer de FacetedObject wordt geïnstantieerd, heeft toegang tot al zijn facetklassen en kan deze herhalen om de component te booten.

functie FacetedObject (facetsOptions / *, andere init args * /) facetsOptions = facetsOptions? _.clone (facetsOptions): ; var thisClass = this.constructor, facets = ; if (! thisClass.prototype.facets) gooit een nieuwe fout ('Geen facetten gedefinieerd'); _.eachKey (this.facets, instantiateFacet, this, true); Object.defineProperties (dit, facetten); if (this.init) this.init.apply (this, arguments); function instantiateFacet (facetClass, fct) var facetOpts = facetsOptions [fct]; verwijder facetsOptions [fct]; facetten [fct] = op te noemen: false, waarde: nieuw facetClass (this, facetOpts);  FacetedObject.createFacetedClass = function (name, facetsClass) var FacetedClass = _.createSubclass (this, name, true); _.extendProto (FacetedClass, facets: facetsClasses); keer terug FacetedClass; ;

In Milo hebben we een beetje verder geabstraheerd door een basis te maken bestanddeel les met een matching createComponentClass klassemethode, maar het basisprincipe is hetzelfde. Omdat sleutelgedrag wordt beheerd door configureerbare facetten, kunnen we veel verschillende componentklassen maken in een declaratieve stijl zonder te veel aangepaste code te hoeven schrijven. Hier is een voorbeeld met enkele van de kant-en-klare facetten die bij Milo horen.

var Panel = Component.createComponentClass ('Panel', dom: cls: 'my-panel', tagName: 'div', events: messages: 'click': onPanelClick, sleep: messages:  ..., drop: messages: ..., container: undefined);

Hier hebben we een componentklasse gemaakt genaamd Paneel, die toegang heeft tot DOM-gebruiksmethoden, stelt automatisch de CSS-klasse in in het, het kan luisteren naar DOM-gebeurtenissen en een klikhandler instellen in het, het kan rondgesleept worden en ook fungeren als een druppel doelwit. Het laatste facet daar, houder zorgt ervoor dat deze component zijn eigen bereik instelt en in feite kindcomponenten kan hebben.

strekking

We hadden een tijdje besproken of alle onderdelen die aan het document waren gehecht een platte structuur zouden moeten vormen of dat ze hun eigen boom zouden moeten vormen, waar kinderen alleen toegankelijk zijn vanaf hun ouder.

We hadden zeker scopes nodig voor sommige situaties, maar het had op implementatieniveau kunnen worden afgehandeld in plaats van op een raamwerkniveau. We hebben bijvoorbeeld afbeeldingsgroepen die afbeeldingen bevatten. Het zou voor deze groepen eenvoudig zijn geweest om hun kinderfoto's bij te houden zonder een generiek bereik.

We hebben uiteindelijk besloten om een ​​scopestructuur van componenten in het document te maken. Het hebben van scopes maakt veel dingen eenvoudiger en stelt ons in staat om meer generieke naamgeving van componenten te hebben, maar ze moeten natuurlijk beheerd worden. Als u een component vernietigt, moet u deze uit de bovenliggende scope verwijderen. Als u een component verplaatst, moet deze van de ene worden verwijderd en aan een andere worden toegevoegd.

Het bereik is een speciaal hash- of kaartobject waarbij elk van de kinderen zich in het bereik bevindt als eigenschappen van het object. Het bereik, in Milo, is te vinden op het containerfacet, dat zelf zeer weinig functionaliteit heeft. Het scope-object heeft echter verschillende methoden om zichzelf te manipuleren en te itereren, maar om naamruimteconflicten te voorkomen, worden al deze methoden in het begin met een onderstrepingsteken benoemd.

var scope = myComponent.container.scope; scope._each (function (childComp) // iterate each child component); // toegang tot een specifieke component op de scope var testComp = scope.testComp; // haal het totale aantal onderliggende componenten var total = scope._length (); // voeg een nieuwe component toe van de scope scope._add (newComp);

Berichten - synchroon versus asynchroon

We wilden een losse koppeling tussen componenten, dus we besloten om berichtenfunctionaliteit aan alle componenten en facetten te koppelen.

De eerste implementatie van de messenger was slechts een verzameling methoden die arrays van abonnees beheren. Zowel de methoden als de array zijn direct in het object geïntegreerd dat berichten heeft geïmplementeerd.

Een vereenvoudigde versie van de eerste messenger-implementatie ziet er ongeveer zo uit:

var messengerMixin = initMessenger: initMessenger, on: on, off: off, postMessage: postMessage; function initMessenger () this._subscribers = ;  function on (message, subscriber) var msgSubscribers = this._subscribers [message] = this._subscribers [bericht] || []; if (msgSubscribers.indexOf (subscriber) == -1) msgSubscribers.push (abonnee);  function off (message, subscriber) var msgSubscribers = this._subscribers [message]; if (msgSubscribers) if (subscriber) _.spliceItem (msgSubscribers, abonnee); verwijder anders this._subscribers [message];  functie postMessage (bericht, data) var msgSubscribers = this._subscribers [message]; if (msgSubscribers) msgSubscribers.forEach (function (subscriber) subscriber.call (this, message, data););  

Voor elk object dat deze mix-in heeft gebruikt, kunnen berichten worden verzonden (op object zelf of met een andere code) postMessage methode en abonnementen op deze code kunnen worden in- en uitgeschakeld met methoden die dezelfde naam hebben.

Tegenwoordig zijn boodschappers substantieel geëvolueerd om: 

  • Externe berichtenbronnen koppelen (DOM-berichten, vensterbericht, gegevenswijzigingen, een andere messenger, enz.) - bijv. Evenementen facet gebruikt het om DOM-gebeurtenissen via Milo messenger te tonen. Deze functionaliteit wordt geïmplementeerd via een afzonderlijke klasse MessageSource en zijn subklassen.
  • Aangepaste berichten-API's definiëren die zowel berichten als gegevens van externe berichten vertalen naar interne berichten. bijv. Gegevens facet gebruikt het om verandering te vertalen en DOM-gebeurtenissen in gegevensveranderingsgebeurtenissen in te voeren (zie onderstaande modellen). Deze functionaliteit wordt geïmplementeerd via een afzonderlijke klasse MessengerAPI en zijn subklassen.
  • Patroonabonnementen (met behulp van reguliere expressies). Bijv. modellen (zie hieronder) gebruiken intern patroonabonnementen om abonnementen voor diepe modelwijzigingen mogelijk te maken.
  • Definiëren van elke context (de waarde hiervan in de abonnee) als onderdeel van het abonnement met deze syntaxis:
component.on ('stateready', subscriber: func, context: context);
  • Abonnement maken dat maar één keer wordt verzonden met de een keer methode
  • Callback doorgeven als derde parameter in postMessage (we overwogen variabel aantal argumenten in postMessage, maar we wilden een consistentere bericht-API dan we zouden hebben met variabele argumenten)
  • enz.

De belangrijkste ontwerpfout bij het ontwikkelen van Messenger was dat alle berichten synchroon werden verzonden. Omdat JavaScript single-threaded is, zouden lange reeksen berichten met complexe bewerkingen worden uitgevoerd, waardoor de gebruikersinterface eenvoudig kan worden vergrendeld. Het wijzigen van Milo om het verzenden van berichten asynchroon te maken, was eenvoudig (alle abonnees worden aangeroepen om hun eigen uitvoeringsblokken te gebruiken setTimeout (abonnee, 0), het wijzigen van de rest van het framework en de applicatie was moeilijker - terwijl de meeste berichten asynchroon kunnen worden verzonden, zijn er veel die nog steeds synchroon moeten worden verzonden (veel DOM-events die data bevatten of plaatsen waar voorkom standaard wordt genoemd). Berichten worden standaard asynchroon verzonden en er is een manier om ze synchroon te maken wanneer het bericht wordt verzonden:

component.postMessageSync ('mijn bericht', gegevens);

of wanneer een abonnement wordt aangemaakt:

component.onSync ('mymessage', functie (msg, data) // ...); 

Een andere ontwerpbeslissing die we hebben gemaakt, was de manier waarop we de methoden van Messenger op de objecten die deze gebruiken, aan het licht brachten. Oorspronkelijk werden methoden simpelweg in het object gemengd, maar we vonden het niet leuk dat alle methoden werden blootgesteld en we konden geen standalone boodschappers hebben. Dus boodschappers werden opnieuw geïmplementeerd als een afzonderlijke klasse op basis van een abstracte klasse Mixin. 

Met de klasse Mixin kunnen methoden van een klasse op een hostobject zodanig worden onthuld dat wanneer er methoden worden aangeroepen, de context nog steeds Mixin is in plaats van het host-object.

Het bleek een heel handig mechanisme - we kunnen volledige controle hebben over welke methoden worden blootgesteld en de namen veranderen als dat nodig is. Het stelde ons ook in staat om twee boodschappers op één object te hebben, die voor modellen wordt gebruikt.

In het algemeen bleek Milo Messenger een zeer solide stuk software te zijn dat alleen gebruikt kan worden, zowel in de browser als in Node.js. Het is gehard door gebruik in ons productie-inhoudbeheersysteem met tienduizenden coderegels.

De volgende keer

In het volgende artikel zullen we kijken naar mogelijk het meest nuttige en complexe deel van Milo. De Milo-modellen bieden niet alleen veilige, diepe toegang tot eigenschappen, maar ook een evenementabonnement op wijzigingen op elk niveau. 

We zullen ook onze implementatie van minder onderzoeken, en hoe we connectorobjecten gebruiken om databronnen een- of tweewegs te binden.

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