Rolling Your Own Framework

Costruire un framework da zero non è qualcosa che abbiamo specificamente intenzione di fare. Dovresti essere pazzo, giusto? Con la pletora di framework JavaScript là fuori, quale possibile motivazione potremmo avere per il nostro? 

Inizialmente cercavamo un framework per creare il nuovo sistema di gestione dei contenuti per il sito web The Daily Mail. L'obiettivo principale era rendere il processo di editing molto più interattivo con tutti gli elementi di un articolo (immagini, incorporamenti, riquadri di call-out e così via) trascurabili, modulari e autogestiti.

Tutti i framework su cui potremmo mettere le mani sono stati progettati per un'interfaccia utente più o meno statica definita dagli sviluppatori. Dovevamo creare un articolo con testo modificabile e elementi dell'interfaccia utente resi dinamicamente.

Backbone era troppo basso livello. Ha fatto poco più che fornire la struttura degli oggetti di base e la messaggistica. Dovremmo costruire molta astrazione sopra la base Backbone, quindi abbiamo deciso che preferiremmo costruire questa fondazione da soli.

AngularJS è diventato il nostro framework di scelta per la creazione di applicazioni browser di piccole e medie dimensioni con interfacce utente statiche relativamente statiche. Sfortunatamente, AngularJS è molto una scatola nera - non espone alcuna API conveniente per estendere e manipolare gli oggetti creati con esso - direttive, controller, servizi. Inoltre, mentre AngularJS fornisce connessioni reattive tra viste ed espressioni di scope, non consente di definire connessioni reattive tra i modelli, quindi qualsiasi applicazione di medie dimensioni diventa molto simile a un'applicazione jQuery con gli spaghetti di listener di eventi e callback, con la sola differenza che invece degli ascoltatori di eventi, un'applicazione angolare ha degli osservatori e invece di manipolare il DOM si manipolano gli ambiti.

Quello che abbiamo sempre desiderato era una struttura che consentisse;

  • Sviluppo di applicazioni in modo dichiarativo con associazioni reattive di modelli a viste.
  • Creare associazioni di dati reattivi tra diversi modelli nell'applicazione per gestire la propagazione dei dati in uno stile dichiarativo piuttosto che in uno stile imperativo.
  • Inserimento di validatori e traduttori in questi collegamenti, in modo da poter associare viste a modelli di dati anziché visualizzare modelli come in AngularJS.
  • Controllo preciso sui componenti collegati agli elementi DOM.
  • Flessibilità nella gestione delle viste che consente di manipolare automaticamente le modifiche del DOM e di ri-renderizzare alcune sezioni utilizzando qualsiasi motore di template nei casi in cui il rendering è più efficiente della manipolazione del DOM.
  • Capacità di creare dinamicamente interfacce utente.
  • Essere in grado di agganciare i meccanismi alla base della reattività dei dati e controllare con precisione gli aggiornamenti e il flusso di dati.
  • Essere in grado di estendere la funzionalità dei componenti forniti dal framework e creare nuovi componenti.

Non siamo riusciti a trovare ciò di cui avevamo bisogno nelle soluzioni esistenti, quindi abbiamo iniziato a sviluppare Milo in parallelo con l'applicazione che lo utilizza.

Perché Milo?

Milo è stato scelto come nome a causa di Milo Minderbinder, un profittatore di guerra di Prendi il 22 di Joseph Heller. Avendo iniziato a gestire le operazioni di mess, le ha espanse in un'impresa commerciale redditizia che collegava tutti con tutto, e in quel Milo e tutti gli altri "ha una quota".

Milo il framework ha il modulo binder, che lega gli elementi DOM ai componenti (tramite speciali ml-bind attributo) e il modulo minder che consente di stabilire connessioni reattive dal vivo tra diverse fonti di dati (il modello e il facet di dati dei componenti sono tali fonti di dati).

Per coincidenza, Milo può essere letto come un acronimo di MaIL Online, e senza l'ambiente di lavoro unico al Mail Online, non saremmo mai stati in grado di costruirlo.

Gestire le viste

Legante

Le viste in Milo sono gestite da componenti, che sono fondamentalmente istanze di classi JavaScript, responsabili della gestione di un elemento DOM. Molti framework usano i componenti come un concetto per gestire gli elementi dell'interfaccia utente, ma il più ovvio che viene in mente è Ext JS. Avevamo lavorato a lungo con Ext JS (l'applicazione legacy che stavamo sostituendo era costruita con esso) e volevamo evitare ciò che ritenevamo essere due inconvenienti del suo approccio.

Il primo è che Ext JS non rende facile la gestione del markup. L'unico modo per costruire un'interfaccia utente consiste nel mettere insieme gerarchie nidificate di configurazioni di componenti. Questo porta a un markup reso inutilmente complesso e prende il controllo dalle mani dello sviluppatore. Avevamo bisogno di un metodo per creare componenti in linea, nel nostro codice HTML personalizzato. È qui che entra in gioco il raccoglitore.

Binder analizza il nostro markup cercando il ml-bind attributo in modo che possa istanziare componenti e associarli all'elemento. L'attributo contiene informazioni sui componenti; questo può includere la classe del componente, i facet e deve includere il nome del componente.

La nostra componente milo

Parleremo di aspetti in un minuto, ma per ora diamo un'occhiata a come possiamo prendere questo valore di attributo ed estrarre la configurazione da esso utilizzando un'espressione regolare.

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

Con queste informazioni, tutto ciò che dobbiamo fare è iterare su tutto il ml-bind attributi, estrai questi valori e crea istanze per gestire ciascun elemento.

var bindAttrRegex = / ^ ([^ \: \ [\]] *) (?: \ [([^ \: \ [\]] *) \])? \ :? ([^:] *) $ / ; function binder (callback) var scope = ; // otteniamo tutti gli elementi con l'attributo ml-bind 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 = result [2] .split (', '); var compName = results [3]; // assumendo che abbiamo un oggetto di registro di tutte le nostre classi var comp = new classRegistry [className] (el); comp .addFacets (facet); comp.name = compName; ambito [compName] = comp; // manteniamo un riferimento al componente sull'elemento el .___ milo_component = comp;); callback (portata);  binder (function (scope) console.log (scope););

Quindi, con solo un po 'di regex e qualche traversal DOM, puoi creare il tuo mini-framework con la sintassi personalizzata in base alla tua particolare logica e contesto di business. In un codice molto piccolo, abbiamo installato un'architettura che consente componenti modulari e autogestiti, che possono essere utilizzati come preferisci. Possiamo creare sintassi conveniente e dichiarativa per l'istanziazione e la configurazione dei componenti nel nostro codice HTML, ma a differenza di quelli angolari, possiamo gestire questi componenti come preferiamo.

Design guidato dalla responsabilità

La seconda cosa che non ci è piaciuta di Ext JS è che ha una gerarchia di classi molto ripida e rigida, che avrebbe reso difficile organizzare le nostre classi di componenti. Abbiamo provato a scrivere un elenco di tutti i comportamenti che potrebbero avere un determinato componente all'interno di un articolo. Ad esempio, un componente potrebbe essere modificabile, potrebbe essere in ascolto di eventi, potrebbe essere un drop target o essere trascinabile. Questi sono solo alcuni dei comportamenti necessari. Un elenco preliminare che abbiamo scritto conteneva circa 15 diversi tipi di funzionalità che potrebbero essere richiesti per qualsiasi particolare componente.

Cercare di organizzare questi comportamenti in una sorta di struttura gerarchica sarebbe stato non solo un grosso mal di testa, ma anche molto limitante se avessimo mai voluto cambiare la funzionalità di una determinata classe di componenti (qualcosa che abbiamo finito per fare molto). Abbiamo deciso di implementare un modello di progettazione orientato agli oggetti più flessibile.

Avevamo letto su Responsibility-Driven Design, che contrariamente al modello più comune di definire il comportamento di una classe insieme ai dati che contiene, è più interessato alle azioni di cui è responsabile un oggetto. Questo ci andava bene perché avevamo a che fare con un modello di dati complesso e imprevedibile, e questo approccio ci permetterebbe di lasciare l'implementazione di questi dettagli in un secondo momento. 

La cosa fondamentale che abbiamo tolto a RDD era il concetto di ruoli. Un ruolo è un insieme di responsabilità correlate. Nel caso del nostro progetto, abbiamo identificato ruoli come modifica, trascinamento, drop zone, selezionabili o eventi tra molti altri. Ma come rappresenti questi ruoli nel codice? Per questo, abbiamo preso in prestito dal modello decoratore.

Il pattern decoratore consente di aggiungere il comportamento a un singolo oggetto, staticamente o dinamicamente, senza influire sul comportamento di altri oggetti della stessa classe. Ora, mentre la manipolazione in run-time del comportamento di classe non è stata particolarmente necessaria in questo progetto, eravamo molto interessati al tipo di incapsulamento che questa idea fornisce. L'implementazione di Milo è una sorta di ibrido che coinvolge oggetti chiamati sfaccettature, collegati come proprietà all'istanza del componente. Il facet riceve un riferimento al componente, è "proprietario" e un oggetto di configurazione, che ci consente di personalizzare le faccette per ogni classe di componenti. 

Puoi pensare agli sfaccettature come mixin avanzati e configurabili che ottengono il proprio spazio dei nomi sull'oggetto proprietario e persino sul proprio dentro metodo, che deve essere sovrascritto dalla sottoclasse facet.

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

Quindi possiamo sottoclassi questo semplice Sfaccettatura classe e creare sfaccettature specifiche per ogni tipo di comportamento che vogliamo. Milo viene fornito con una varietà di sfaccettature, come ad esempio DOM facet, che fornisce una raccolta di utilità DOM che operano sull'elemento del componente proprietario e il Elenco e Articolo faccette, che lavorano insieme per creare liste di componenti ricorrenti.

Queste sfaccettature vengono quindi riunite da ciò che abbiamo chiamato a FacetedObject, che è una classe astratta da cui ereditano tutti i componenti. Il FacetedObject ha un metodo di classe chiamato createFacetedClass che semplicemente sottoclasse se stesso e allega tutte le faccette a a sfaccettature proprietà sulla classe. In questo modo, quando il FacetedObject viene istanziato, ha accesso a tutte le sue classi di facet e può iterarle per avviare il componente.

function FacetedObject (facetsOptions / *, other init args * /) facetsOptions = facetsOptions? _.clone (facetsOptions): ; var thisClass = this.constructor, facets = ; se (! thisClass.prototype.facets) lancia un nuovo errore ('Nessuna faccia definita'); _.eachKey (this.facets, instantiateFacet, this, true); Object.defineProperties (questo, facet); se (this.init) this.init.apply (this, argomenti); function instantiateFacet (facetClass, fct) var facetOpts = facetsOptions [fct]; elimina facetsOptions [fct]; facets [fct] = enumerable: false, value: new facetClass (this, facetOpts);  FacetedObject.createFacetedClass = function (name, facetsClasses) var FacetedClass = _.createSubclass (this, name, true); _.extendProto (FacetedClass, facets: facetsClasses); return FacetedClass; ;

In Milo, abbiamo astratto un po 'oltre creando una base Componente classe con un abbinamento createComponentClass metodo di classe, ma il principio di base è lo stesso. Con comportamenti chiave gestiti da sfaccettature configurabili, possiamo creare molte classi di componenti diverse in uno stile dichiarativo senza dover scrivere troppo codice personalizzato. Ecco un esempio che utilizza alcune delle sfaccettature out-of-the-box fornite con Milo.

var Panel = Component.createComponentClass ('Panel', dom: cls: 'my-panel', tagName: 'div', eventi: messaggi: 'click': onPanelClick, trascina: messaggi:  ..., rilascia: messages: ..., container: undefined);

Qui abbiamo creato una classe di componenti chiamata Pannello, che ha accesso ai metodi di utilità DOM, imposterà automaticamente la sua classe CSS dentro, può ascoltare eventi DOM e configurerà un gestore di clic dentro, può essere trascinato in giro e agire anche come bersaglio di rilascio. L'ultima sfaccettatura lì, contenitore assicura che questo componente configuri il proprio ambito e possa, in effetti, avere componenti figlio.

Scopo

Avevamo discusso per un po 'se tutti i componenti collegati al documento dovessero formare una struttura piatta o dovessero formare il proprio albero, dove i bambini sono accessibili solo dai loro genitori.

Avremmo sicuramente bisogno di ambiti per alcune situazioni, ma avrebbe potuto essere gestito a livello di implementazione, piuttosto che a livello di struttura. Ad esempio, abbiamo gruppi di immagini che contengono immagini. Sarebbe stato semplice per questi gruppi tenere traccia delle immagini dei loro figli senza la necessità di un ambito generico.

Alla fine abbiamo deciso di creare un albero di scope di componenti nel documento. Avere degli scope rende molte cose più facili e ci permette di avere nomi più generici dei componenti, ma ovviamente devono essere gestiti. Se si distrugge un componente, è necessario rimuoverlo dall'ambito principale. Se sposti un componente, deve essere rimosso da uno e aggiunto a un altro.

L'ambito è un hash speciale, o oggetto della mappa, con ciascuno dei bambini contenuti nell'ambito come proprietà dell'oggetto. Lo scope, in Milo, si trova sul facet del contenitore, che a sua volta ha pochissime funzionalità. L'oggetto scope, tuttavia, ha una varietà di metodi per manipolare e iterare se stesso, ma per evitare conflitti nello spazio dei nomi, tutti questi metodi sono nominati con un carattere di sottolineatura all'inizio.

var scope = myComponent.container.scope; scope._each (function (childComp) // iterate ogni componente child); // accede a un componente specifico sull'ambito var testComp = scope.testComp; // ottiene il numero totale di componenti figlio var total = scope._length (); // aggiungi un nuovo componente dell'ambito scope._add (newComp);

Messaggistica - sincrona contro asincrona

Volevamo avere un accoppiamento lento tra i componenti, quindi abbiamo deciso di avere funzionalità di messaggistica collegate a tutti i componenti e le faccette.

La prima implementazione del messenger era solo una raccolta di metodi che gestivano array di abbonati. Sia i metodi che l'array sono stati combinati direttamente nell'oggetto che ha implementato la messaggistica.

Una versione semplificata della prima implementazione di messenger è simile a questa:

var messengerMixin = initMessenger: initMessenger, on: on, off: off, postMessage: postMessage; function initMessenger () this._subscribers = ;  function on (message, subscriber) var msgSubscribers = this._subscribers [messaggio] = this._subscribers [messaggio] || []; if (msgSubscribers.indexOf (subscriber) == -1) msgSubscribers.push (subscriber);  function off (message, subscriber) var msgSubscribers = this._subscribers [messaggio]; if (msgSubscribers) if (subscriber) _.spliceItem (msgSubscribers, subscriber); altrimenti cancella this._subscribers [messaggio];  funzione postMessage (message, data) var msgSubscribers = this._subscribers [messaggio]; if (msgSubscribers) msgSubscribers.forEach (function (subscriber) subscriber.call (this, message, data););  

Qualsiasi oggetto che ha utilizzato questo mix-in può avere messaggi emessi su di esso (dall'oggetto stesso o da qualsiasi altro codice) con postMessage il metodo e gli abbonamenti a questo codice possono essere attivati ​​e disattivati ​​con metodi che hanno lo stesso nome.

Oggigiorno, i messaggeri si sono sostanzialmente evoluti per consentire: 

  • Allegare fonti esterne di messaggi (messaggi DOM, messaggio finestra, modifiche dati, altro messenger, ecc.) - ad es. eventi facet lo usa per esporre eventi DOM tramite Milo messenger. Questa funzionalità è implementata tramite una classe separata MessageSource e le sue sottoclassi.
  • Definizione di API di messaggistica personalizzate che traducono messaggi interni e dati di messaggi esterni in messaggi interni. Per esempio. Dati facet lo usa per tradurre il cambiamento e inserire eventi DOM in eventi di modifica dati (vedi Modelli sotto). Questa funzionalità è implementata tramite una classe separata MessengerAPI e le sue sottoclassi.
  • Abbonamenti di pattern (usando le espressioni regolari). Per esempio. i modelli (vedi sotto) utilizzano internamente gli abbonamenti di pattern per consentire abbonamenti con modifiche al modello profondo.
  • Definizione di qualsiasi contesto (il valore di questo in subscriber) come parte della sottoscrizione con questa sintassi:
component.on ('stateready', subscriber: func, context: context);
  • Creazione di un abbonamento che viene inviato solo una volta con il una volta metodo
  • Passando callback come terzo parametro in postMessage (abbiamo considerato il numero variabile di argomenti in postMessage, ma volevamo un'API di messaggistica più coerente di quella che avremmo con gli argomenti variabili)
  • eccetera.

L'errore di progettazione principale che abbiamo fatto durante lo sviluppo di messenger era che tutti i messaggi venivano inviati in modo sincrono. Poiché JavaScript è a thread singolo, lunghe sequenze di messaggi con operazioni complesse eseguite bloccano facilmente l'interfaccia utente. Cambiare Milo per rendere la spedizione dei messaggi asincrona era facile (tutti i sottoscrittori sono chiamati con i propri blocchi di esecuzione setTimeout (subscriber, 0), cambiare il resto del framework e l'applicazione era più difficile - mentre la maggior parte dei messaggi possono essere inviati in modo asincrono, molti devono ancora essere inviati in modo sincrono (molti eventi DOM che contengono dati o luoghi dove preventDefault è chiamato). Per impostazione predefinita, i messaggi vengono ora inviati in modo asincrono e c'è un modo per renderli sincroni sia quando il messaggio viene inviato:

component.postMessageSync ('mymessage', data);

o quando viene creato l'abbonamento:

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

Un'altra decisione progettuale che abbiamo preso è stata il modo in cui abbiamo esposto i metodi di messaggistica sugli oggetti che li utilizzano. Inizialmente, i metodi venivano semplicemente mescolati nell'oggetto, ma non ci piaceva che tutti i metodi fossero esposti e non potremmo avere messenger indipendenti. Quindi i messenger sono stati reimplementati come una classe separata basata su una classe astratta Mixin. 

La classe Mixin consente di esporre i metodi di una classe su un oggetto host in modo tale che quando vengono chiamati i metodi, il contesto sarà comunque Mixin piuttosto che l'oggetto host.

Si è dimostrato un meccanismo molto conveniente: possiamo avere il pieno controllo su quali metodi sono esposti e modificare i nomi secondo necessità. Ci ha anche permesso di avere due messenger su un oggetto, che è usato per i modelli.

In generale, Milo messenger si è rivelato un software molto solido che può essere utilizzato da solo, sia nel browser che in Node.js. È stato rafforzato dall'uso nel nostro sistema di gestione dei contenuti di produzione che ha decine di migliaia di linee di codice.

La prossima volta

Nel prossimo articolo, vedremo probabilmente la parte più utile e complessa di Milo. I modelli Milo non consentono solo un accesso sicuro e approfondito alle proprietà, ma anche la sottoscrizione di eventi alle modifiche a qualsiasi livello. 

Esploreremo anche la nostra implementazione di minder e il modo in cui utilizziamo gli oggetti connettore per eseguire il bind o bidirezionale delle origini dati.

Nota che questo articolo è stato scritto da Jason Green e Evgeny Poberezkin.