Chiunque si sia trovato a dover realizzare un'applicazione Web di una certa importanza avrà constatato quanto sia difficile garantirne scalabilità e manutenibilità senza un'architettura adeguata alle spalle.
L'esperienza in altri linguaggi ha anche insegnato che una buona architettura non si deve per forza basare su un framework complesso e pieno di features, ma possono bastare dei semplici componenti in grado di indicarci la giusta direzione da far prendere al nostro codice. Un'esempio tipico è il confronto fra Zend e Codeigniter per la applicazioni PHP.
Nel campo delle applicazioni lato client, la crescente necessità di applicazioni reattive e desktop-like ha realizzato le premesse per l'emergere di una serie di librerie in grado di fornire alle applicazioni JavaScript più complesse un alto grado di solidità e scalabilità.
La maggior parte delle librerie in questione ha replicato i paradigmi già utilizzato in altri linguaggi, come MVC (JavaScriptMVC) o MVVM (Knockout). In questo articolo introdurremo Backbone.js una libreria che ha riscosso molto successo fra gli sviluppatori per il suo approccio duttile ed immediato allo sviluppo web.
Backbone e il MV*
Backbone.js nasce come libreria di sviluppo per DocumentCloud, un servizio di condivisione e analisi di documenti, e viene rilasciata come progetto standalone verso la fine del 2010.
Fin da subito gli obiettivi principali di Jeremy Ashkenas, lo sviluppatore principale e, fra le altre cose, creatore di CoffeeScript, sono di realizzare una libreria minimale in grado di fornire degli strumenti di base per organizzare le proprie applicazioni e di poterla usare sia in ambiente browser che server (principalmente con Node.js).
Per la sua architettura, Backbone.js rientra nella categoria delle librerie MV*, in quanto implementa Model e View, ma non ha un componente Controller tradizionale, delegandone i compiti alle View e ad un componente di routing. Questo approccio è abbastanza diffuso in ambito JavaScript, dove la diversa e più complessa gestione dell'interazione utente e dello stato dell'applicazione non si adattano bene ai compiti di un controller.
I componenti base di Backbone.js sono:
Backbone.Model
: modelliBackbone.Collection
: liste di modelliBackbone.View
: viewBackbone.Router
: routing e gestione centralizzata dello stato dell'applicazione
È inoltre disponibile un'interfaccia PubSub con Backbone.Events
nonché un sistema basilare di templating.
Installazione e primi passi
L'unica dipendenza di Backbone.js è Underscore.js, una libreria che implementa varie funzionalità di basso livello per array e oggetti.
In base al progetto ed all'ambiente di sviluppo potrebbe essere necessario includere altre librerie. Ad esempio, volendo interagire con il DOM ed eventualmente abilitare il caricamento asincrono dei dati dal server, sarà necessario includere anche json2 (per quei browser che non supportano nativamente JSON) e jQuery o Zepto.
Poiché il framework prevede che la maggior parte del markup venga generata lato client, le funzioni di base fornite da Backbone/Underscore potrebbe non essere adatte a layout complessi. In tal caso è possibile utilizzare un template engine esterno come Handlebars.
Data la rigida integrazione dei metodi AJAX in Backbone.js, non è al momento possibile utilizzare facilmente altre librerie DOM.
Il primo passo per utilizzare Backbone.js è quello di caricarlo nella pagina insieme agli altri script di cui avremo bisogno:
<script src="js/json2.js"></script>
<script src="js/jquery-1.7.1.js"></script>
<script src="js/underscore.js"></script>
<script src="js/backbone.js"></script>
L'insieme di queste librerie, minificato e compresso si aggira intorno ai 40KB, un peso paragonabile ad altre soluzioni dello stesso tipo.
In questo articolo realizzeremo una todo list, un'esempio classico di applicazione Backbone.js, semplificandolo un po' per questioni di spazio. Ecco il risultato finale.
Realizzare il Model
Il Model di Backbone.js rappresenta un oggetto discreto contenente una serie di dati sotto forma di attributi.
In una comune applicazione non strutturata, le interazioni dell'utente (ad esempio la digitazione e l'invio dei dati) di solito coinvolgono direttamente l'interfaccia e quindi, solo quando necessario, chiamano in causa un qualche layer di validazione e salvataggio dei dati.
In un'applicazione Backbone.js, invece, l'interazione modifica prima lo stato di un model scatenando quindi la reazione della view che si aggiorna di conseguenza.
La sintassi per realizzare un modello di base è la seguente:
var Todo = Backbone.Model.extend();
Il metodo .extend()
accetta un oggetto di proprietà e funzioni con il quale sovrascrivere le funzionalità di base. Ad esempio potremmo impostare degli attributi di default, la funzione di inizializzazione, e un paio di metodi personalizzati:
var Todo = Backbone.Model.extend({
//attributi di default del model
defaults: {
content: "todo vuoto...",
done: false
},
initialize: function() {
if (!this.get("content")) {
//assicuriamoci che il todo abbia un contenuto
this.set({"content": this.defaults.content});
}
},
toggle: function() {
var currentDone = this.get("done");
this.set({done: !currentDone});
},
clear: function() {
//distrugge il model
this.destroy();
}
});
Il metodo .extend()
funziona per molti versi come $.extend()
in jQuery, permettendo di aggiungere proprietà e metodi personalizzati al costrutture del modello. Alcune chiavi sono tuttavia speciali e vengono distinte dalle altre. Ad esempio .defaults
, è un oggetto con la lista degli attributi accettati e i rispettivi valori predefiniti.
A questo punto possiamo inizializzare un'istanza del modello:
var todo = new Todo({
content : 'Fare la spesa'
});
Una delle caratteristiche importanti dei model di Backbone.js, è il fatto che vengano automaticamente dotati di un'interfaccia ad eventi emessi ad ogni modifica dei dati iniziali:
//quando cambia l'attributo done
todo.on('change:done', function (todo, newValue) {
console.log('done changed', newValue);
});
todo.toggle(); //done changed, true
Da notare, infine, che un model può essere usato come base per altri model:
var SpecialTodo = Todo.extend({
defaults : {
content: "todo vuoto...",
done: false,
description: ''
}
});
Collezioni di Model
Una collection è un oggetto contenente una raccolta di modelli dello stesso tipo, attraverso il quale è possibile, ordinare, filtrare e manipolare i modelli contenuti.
Come per Backbone.Model
, il costruttore può essere esteso con metodi e proprietà personalizzati. Solitamente basta indicare il model di riferimento e l'URL con la quale Backbone.js comunicherà per le operazioni di CRUD con il server. Nel nostro caso abbiamo usato '#'
per disabilitare completamente la funzionalità.
var TodoList = Backbone.Collection.extend({
//model di riferimento
model: Todo,
url: '#',
});
Questo basta per poter inizializzare una nuova lista di todo con un paio di modelli:
var todoSpesa = new Todo({ content : 'Fare la spesa' });
var todoJS = new Todo({ content : 'Imparare Backbone' });
//inizializzaziamo un'istanza
var lista = new TodoList([todoSpesa, todoJS]);
Per permettere una gestione completa della raccolta, gli eventi emessi dai singoli modelli vengono intercettati anche dalle collection le quali, inoltre, emettono gli eventi 'add'
e 'remove'
quando un modello viene aggiunto o eliminato:
lista.on('change:done', function (model, newValue) {
console.log(
model.get('content'), //modello modificato
newValue //nuovo valore
);
});
lista.on('add', function (model) {
console.log(
model.get('content') //testo del modello aggiunto
);
});
todoSpesa.toggle(); //'Fare la spesa', true
lista.add({ content: 'Completare i todo'}); //'Completare i todo'
Oltre a queste funzionalità, le collection ereditano molte delle funzioni offerte da Underscore.js particolarmente utili per filtrare e manipolare i modelli nella raccolta.
Ecco come potremmo estrarre tutti i todo completati appoggiandoci all'integrazione di _.filter()
:
var completati = lista.filter(function (todo) {
return todo.get('done');
});
È possibile conoscere tutti i metodi di Underscore.js supportati visitando la documentazione ufficiale.
Le viste
A differenza di quanto potremmo pensare, le view in Backbone.js non contengono markup HTML, bensì fungono da tramite fra l'interfaccia ed i modelli, definendone la logica di interazione. La parte di templating vero e proprio è demandata ad un sistema esterno.
Nel caso di Backbone.js, per template abbastanza semplici è possibile utilizzare il metodo _.template()
fornito da Underscore.js. Questo metodo ricorda molto la sintassi ERB e restituisce un template precompilato al quale passare i dati come oggetti JSON:
var template = _.template('<p>Ciao <%= nome %> <%= cognome %></p>');
var html = template({ nome: 'Mario', cognome: 'Rossi'});
//html === 'Ciao Mario Rossi'
Poiché il template engine è completamente slegato da Backbone.js, è possibile utilizzare senza problemi un'altra libreria come Mustache o Handlebars.
Per realizzare la view di un singolo todo, estenderemo Backbone.View
. Ecco il codice necessario:
var TodoView = Backbone.View.extend({
//il tag creato dalla vista
tagName: "li",
// ID dello script che contiene la vista
template: '#item-template',
// Gli eventi e gli elementi collegati.
events: {
"click .check" : "toggleDone",
"click span.todo-destroy" : "clear"
},
initialize: function() {
//precompilo il template
this.template = _.template($(this.template).html());
//forzo il contesto di questi metodi della view
_.bindAll(this, 'render', 'remove');
//resto in ascolto per cambiamenti dell'attributo done
//se cambia, ristampo il template del modello
this.model.bind('change:done', this.render);
//resto in ascolto nel caso il modello sia cancellato
//e rimuovo anche la view dal DOM
//.remove() è un metodo di default in Backbone.View
this.model.bind('destroy', this.remove);
},
//metodo che stampa l'HTML della view
render: function() {
//estraggo gli attributi del modello
var data = this.model.toJSON();
//li inietto nell'elemento della view
this.$el.html(this.template());
//restituisco this per permettere
//la concatenazione dei metodi
return this;
},
//metodo lanciato quando clicco
//sul checkbox con classe .check
toggleDone: function() {
this.model.toggle();
},
//metodo lanciato quando clicco
//sul pulsante con classe .todo-destroy
clear: function() {
this.model.clear();
}
});
//inizilizzo la vista
var todoVista = new TodoView({
model : todoSpesa
});
Lo script è abbastanza corposo, vediamone alcuni passaggi nel dettaglio.
Anzitutto abbiamo definito un tag contenitore per il template con .tagName
. Quindi abbiamo definito una serie di eventi DOM con .events
: l'oggetto ha come chiave l'evento da ascoltare ed eventualmente il selettore CSS sul quale dev'essere applicato, mentre come valore ha un metodo della view. Da notare che questi eventi vengono delegati sul contenitore, non diversamente da come faremmo con .delegate()
di jQuery.
La maggior parte del lavoro avviene nel metodo .initialize()
. Qui anzitutto precompiliamo il template, andando a leggere il contenuto di un tag script
presente nel documento ma non interpretato dal browser avevndo un attributo type
impostato su 'text/template'
:
<script type="text/template" id="item-template">
<div class="todo checkbox clearfix <%= done ? 'done' : '' %>">
<input class="check" type="checkbox" <%= done ? 'checked="checked"' : '' %> />
<label class="todo-content"><%= content %> </label>
<span class="todo-destroy icon-trash"></span>
</div>
</script>
A questo punto utilizziamo _.bindAll()
di Underscore, per assicurarci che il contesto dei metodi .render()
e .remove()
della vista non vengano persi. I contesti dei metodi delegati con .events
sono forzati automaticamente.
Il metodo .render()
è responsabile della conversione in HTML dei dati del modello. Per fare questo, esportiamo gli attributi in un oggetto JSON, li passiamo al template precompilato in precedenza ed iniettiamo il risultato nel tag contenitore.
Gli altri due metodi, .toggleDone()
e .clear()
servono da tramite fra gli eventi dell'interfaccia e il modello. Da notare che, contrariamente a quanto potremmo pensare, non modificano direttamente la view.
Una volta inizializzata la view non sarà visibile nel documento, ma potrà essere manipolata e aggiunta con jQuery:
$('body').append( todoVista.render().el );
Nella parte successiva dell'articolo vedremo come creare contenitori per i modelli, viste e collezioni. Infine vedremo come creare una applicazione "Single page".
Un container per l'applicazione
Un pattern abbastanza diffuso prevede la realizzazione di una vista che funga da contenitore per l'applicazione e che gestisca l'insieme dei modelli, delle collezioni e delle singole viste. Nel nostro caso faremo affidamento ad un markup di base prestampato da utilizzare come fondamenta:
<div id="todoapp">
<h1>Todo</h1>
<form id="create-todo">
<label for="new-todo">Titolo
<input id="new-todo" placeholder="Cosa devi fare?" type="text" />
</label>
<button id="new-todo-save" type="button">Salva</button>
</form>
<ul id="todo-list">
<!-- qui verranno inseriti i nuovi todo -->
</ul>
</div>
Il codice necessario non presenta grosse differenze logiche rispetto al precedente:
var AppView = Backbone.View.extend({
// Evento delegato
events: {
"click #new-todo-save": "create"
},
initialize: function() {
var todos = this.collection;
_.bindAll(this, 'addOne', 'addAll');
//identifico il campo di testo
//con il titolo del todo
//per usarlo più avanti
this.$input = this.$("#new-todo");
//resto in ascolto per l'aggiunta
//di uno o più todo
todos.bind('add', this.addOne);
todos.bind('reset', this.addAll);
},
//aggiunta di un todo
addOne: function(todo) {
//todo è il nuovo modello
//appena aggiunto
var view = new TodoView({model: todo});
//appendo alla lista
//this.$ è l'equivalente di .find() in jQuery
this.$("#todo-list").append( view.render().el );
},
addAll: function() {
this.collection.each(this.addOne);
},
//aggiungo un nuovo todo alla lista
create: function() {
this.collection.create({
content: this.$input.val(),
done: false
});
this.$input.val('');
}
});
A questo punto non resta che inizializzare l'applicazione al DOM ready e passargli, eventualmente, alcuni todo di default:
jQuery(function ($) {
//creo una nuova collezione
var todos = new TodoList();
//creo una nuova vista per
//l'applicazione
var app = new AppView({
collection : todos,
//usa questo elemento come contenitore
el: $("#todoapp")
});
//aggiungo dei todo di default
todos.reset([
{content: 'fare la spesa', done : true},
{content: 'impare Backbone.js'}
]);
});
Ecco, ancora una volta, l'applicazione completa in azione.
Nel caso in cui volessimo caricare dei todo precedentemente salvati su un database, potremmo sostituire la chiamata a .reset()
con questa:
todos.fetch();
In questo caso verrà istanziata una chiamata AJAX all'URL indicato in TodoList.url
ed il risultato sarà trattato come un JSON contententi i dati dei modelli.
Single page app con routing
Con il termine single page app si intende un'applicazione che risiede completamente in una singola pagina, ma che tuttavia da la possibilità di navigare fra le proprie viste come si farebbe con un'applicazione web tradizionale.
Oltre all'indubbio vantaggio di eliminare i tempi di attesa durante il caricamento della pagina e di dare più scorrevolezza all'interazione, un'applicazione di questo tipo simula anche il cambiamento di pagina aggiornando l'URL della pagina nella barra degli indirizzi. Solitamente questa caratteristica è resa possibile aggiungendo un cancelletto (#
o #!
) all'inizio del percorso dinamico (Twitter è l'esempio più famoso).
Con l'introduzione di HTML5 è anche possibile, nei browser che lo supportano, utilizzare l'History API, che ci permette di eliminare il cancelletto e simulare in tutto e per tutto una normale sessione di navigazione con tanto di cronologia e friubilità dei tasti avanti e indietro.
In Backbone.js è possibile realizzare single page app a partire dal componente Backbone.Router
. La sintassi, anche in questo caso, non differisce dai componenti precedenti, l'unica proprietà di rilievo è l'oggetto .routes
, che accetta come chiave un percorso, anche parametrico, e come valore la funzione da eseguire:
var TodoRoute = Backbone.Router.extend({
routes : {
//questa è la route corrispondente all'home dell'applicazione
'' : 'defaultPage',
//una route parametrica
'todo/:id' : aontherPage(id) {
//id può essere utilizzato
//per ricavare un todo particolare dal database
}
},
defaultPage : function () {
//qui la funzione di
//inizializzazione dell'applicazione
}
});
jQuery(function ($) {
var todoRoute = new TodoRoute();
Backbone.history.start({ pushState : true});
});
Con il codice qui sopra abbiamo realizzato un router con due percorsi: la home con la lista completa dei todo, ed una pagina in cui mostrare un singolo record, identificato dal parametro id
.
Dopo aver inizializzato il router, abbiamo abilitato la gestione delle routes e della navigazione con Backbone.history.start()
che ci permette di navigare fra le pagine dell'applicazione. Il parametro {pushStart : true}
, infine, abilita HTML5 History nei browser che lo supportano.
Da notare che i due comandi vengono eseguiti dopo il DOM ready, in quanto solo in questo modo saremo sicuri che la funzionalità di navigazione sia completamente funzionante su tutti i browser.
A questo punto per navigare nell'applicazione potremo sia utilizzare i normali attributi href
dei link, oppure navigare sfruttare il metodo .navigate()
del router:
todoRoute.navigate('todo/1');
Riferimenti e Credits
La popolarità di Backbone.js ha contribuito alla realizzazione di un gran numero di tutorial e guide con vario livello di complessità.
Anzitutto è necessario citare il progetto TodoMVC, che è stato d'ispirazione per questo articolo ed è un ottimo punto di partenza per studiare buona parte degli app framework in circolazione.
Altri interessanti fonti di approfondimento su Backbone.js sono:
- La documentazione ufficiale di Backbone.js e Underscore.js
- Backbone Fundamentals, un manuale digitale di Addy Osmani
- BackboneFU, tutorial e articoli su Backbone.js