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;
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.
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.
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.
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.
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);
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:
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.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.component.on ('stateready', subscriber: func, context: context);
een keer
methodepostMessage
(we overwogen variabel aantal argumenten in postMessage
, maar we wilden een consistentere bericht-API dan we zouden hebben met variabele argumenten)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.
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.