
Backbone es una herramienta que nos da bastante libertad en cuanto a como queremos hacer las cosas y de lo que ofrece podemos usar solo lo que queramos. En algunos casos podemos considerar que Backbone ya es de por si una solución suficiente pero pero en otros podemos necesitar algo que nos facilite la tarea un poco más, de hecho hay muchas herramientas de terceros que proporciona multitud de funcionalidades adicionales sobre Backbone.
Usando Backbone por si solo a medida que vayamos haciendo varias aplicaciones nos daremos cuenta que debemos escribir código que empezaremos a considerar repetitivo, tampoco nos ayudará en desasociar los modelos de las vistas cuando eliminemos estas de la aplicación y si no lo hacemos correctamente tendremos fugas de memoria o vistas zombies que siguen escuchando eventos de los modelos cuando esperamos que se hubiesen destruido, también debemos encargarnos de gestionar donde deben visualizarse las vistas. Marionette es una de esas herramientas que nos puede ayudar a facilitarnos las tareas anteriores que en Backbone debemos hacer manualmente, además de ofrecernos algunas otras funcionalidades adicionales, pero también sin obligarnos a usar lo que no queramos siguiendo la misma filosofía de Backbone.
En esta entrada implementaré el mismo ejemplo de la lista de tareas que hice con Backbone pero esta vez implementado con Marionette aunque revisándolo incluiré un par de funcionalidades adicionales como externalizar del html las plantillas de las vistas y como internacionalizar los textos que aparecen en ellas si necesitamos soportar múltiples idiomas. Para ello utilizaré una de las versiones «bundled» que ofrece Marionette y un par de dependencias que necesita (wreq y babysiter).
El modelo y la colección de tareas es muy parecido aunque se han simplificado un poco al moverse el moverse el código que convertía el modelo a json a la vista, donde Marionette ofrece y llama al método serializeData cuando necesita y posteriormente el método render con los datos obtenidos, en el método render en definitiva se usa Mustache para producir el html de la vista. En realidad, el convertir el modelo al json que necesita a la vista está mejor colocado en la vista, ya que la vista es la que conoce los datos que necesita mostrar del modelo. Otra cosa muy útil que nos ofrece Marionette es la sección con los elementos ui de las vistas, esto evitará que tengamos los sectores de jquery repartidos por varios sitios de la vista y hará el código más simple y legible. Las secciones de las vistas modelEvents y collectionEvents sirven para los mismo que hacíamos en el método initialize con .on o .listenTo de Backbone.
Los objetos Marionette.ItemView sirven para visuaslizar un modelo (una tarea) y Marionette.CollectionView sirve para mostrar una colección de elementos (una lista de tareas). El objeto Marionette.Layout se utiliza para componer una vista con varias secciones (tareas y estado) donde podremos colocar las vistas de la aplicación, aparte de estas secciones un Layout es como una vista Marionette.ItemView con su plantilla. Con Marionette.Controller construiremos una clase que podemos utilizar para ofrecer la interfaz de la aplicación al exterior del módulo con únicamente los métodos necesarios (sin initialize, render y los métodos onXxx del mismo ejemplo con Backbone). Y hasta aquí, son los cambios más importantes en cuanto al cambio de Backbone a Marionette.
A continuación el código del módulo tareas que contiene la mayor parte del código javascript de la aplicación y el módulo main que no cambia en nada.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
define('tareas', [ 'jquery', 'underscore', 'backbone', 'backbone.marionette', 'mustache', 'plantillas', 'i18n!i18n/nls/mensajes' ], function($, _, Backbone, Marionette, Mustache, plantillas, mensajes) { | |
function trim(string) { | |
return string.replace(/^\s+|\s+$/gi, ''); | |
} | |
function render(plantilla, datos, mensajes) { | |
var d = datos || {}; | |
var m = mensajes || {}; | |
var vista = _.extend(d, { | |
message: m | |
}); | |
var f = plantillas[plantilla]; | |
var p = f(); | |
return p(vista); | |
} | |
var Tarea = Backbone.Model.extend({ | |
urlRoot : 'rest/tareas/tarea', | |
defaults : { | |
id : null, | |
descripcion : '', | |
completada : false | |
}, | |
toJSON : function() { | |
return { | |
id : this.get('id'), | |
descripcion : this.get('descripcion'), | |
completada : this.get('completada') | |
}; | |
} | |
}); | |
var Tareas = Backbone.Collection.extend({ | |
url: 'rest/tareas', | |
model: Tarea, | |
findCompletadas: function() { | |
return this.models.filter(function(tarea) { | |
return tarea.get('completada'); | |
}); | |
}, | |
removeCompletadas: function() { | |
_.each(this.findCompletadas(), function(tarea) { | |
tarea.destroy(); | |
}); | |
} | |
}); | |
var TareaView = Marionette.ItemView.extend({ | |
tagName: 'li', | |
ui: { | |
checkbox: "input[name='completada']" | |
}, | |
modelEvents: { | |
'change': 'render' | |
}, | |
events: { | |
"change input[name='completada']" : 'onChangeCompletada' | |
}, | |
serializeData: function() { | |
var completada = this.model.get('completada'); | |
var data = this.model.toJSON(); | |
_.extend(data, { | |
attrs: { | |
checked : (completada) ? 'checked="checked"' : null, | |
completada : (completada) ? 'completada' : null | |
} | |
}); | |
return data; | |
}, | |
template: function(data) { | |
return render('tarea', data); | |
}, | |
// Eventos | |
onChangeCompletada: function() { | |
var completada = this.ui.checkbox.is(':checked'); | |
this.model.set('completada', completada); | |
this.model.save(); | |
} | |
}); | |
var TareasView = Marionette.CollectionView.extend({ | |
tagName: 'ul', | |
className: 'tareas', | |
itemView: TareaView | |
}); | |
var EstadoView = Marionette.ItemView.extend({ | |
collectionEvents : { | |
'all': 'render' | |
}, | |
serializeData: function() { | |
var completadas = this.options.collection.findCompletadas().length; | |
var total = this.collection.length; | |
return { | |
completadas : completadas, | |
total : total | |
}; | |
}, | |
template: function(data) { | |
var m = { | |
'COMPLETADAS_tareas_de_TOTAL_completadas': Mustache.render(mensajes.COMPLETADAS_tareas_de_TOTAL_completadas, data), | |
'Muy_bien_has_completado_todas_las_tareas': mensajes.Muy_bien_has_completado_todas_las_tareas, | |
}; | |
return render('estado', data, m); | |
}, | |
render: function() { | |
this.$el.html(this.template(this.serializeData())); | |
} | |
}); | |
var AppLayout = Marionette.Layout.extend({ | |
ui: { | |
tareaInput: "input[name='nuevaTarea']", | |
limpiar: "input[name='limpiar']" | |
}, | |
events: { | |
"keypress input[name='nuevaTarea']": 'onKeypressNuevaTarea', | |
"click input[name='limpiar']": 'onClickLimpiar' | |
}, | |
collectionEvents : { | |
'all': 'update' | |
}, | |
regions: { | |
tareas: "#tareas", | |
estado: "#estado" | |
}, | |
template: function() { | |
var m = { | |
'Lista_de_tareas': mensajes.Lista_de_tareas, | |
'Introduce_una_nueva_tarea': mensajes.Introduce_una_nueva_tarea, | |
'Limpiar': mensajes.Limpiar | |
}; | |
return render('tareas', null, m); | |
}, | |
update: function() { | |
var completadas = this.options.collection.findCompletadas().length; | |
// Habilitar/deshabilitar el botón de limpiar tareas completadas | |
if (completadas == 0) { | |
this.ui.limpiar.attr('disabled', 'disabled'); | |
} else { | |
this.ui.limpiar.removeAttr('disabled'); | |
} | |
}, | |
// Eventos | |
onKeypressNuevaTarea: function(event) { | |
// Comprobar si la tecla pulsada es el return | |
if (event.which == 13) { | |
var descripcion = this.ui.tareaInput.val(); | |
descripcion = trim(descripcion); | |
// Comprobar si se ha introducido descripción de la tarea | |
if (descripcion == '') { | |
return; | |
} | |
// Añadir la tarea y limpiar el input | |
var tarea = new Tarea({ | |
descripcion : descripcion, | |
completada : false | |
}); | |
this.options.controller.addTarea(tarea); | |
this.ui.tareaInput.val(''); | |
} | |
}, | |
onClickLimpiar: function() { | |
this.options.controller.removeTareasCompletadas(); | |
} | |
}); | |
var TareasApp = Marionette.Controller.extend({ | |
initialize: function() { | |
// Construir el modelo | |
this.tareas = new Tareas(); | |
// Construir las vistas | |
this.layout = new AppLayout({ | |
el: this.options.el, | |
collection: this.tareas, | |
controller: this | |
}); | |
this.layout.render(); | |
this.tareasView = new TareasView({ | |
collection: this.tareas | |
}); | |
this.estadoView = new EstadoView({ | |
collection: this.tareas | |
}); | |
this.layout.tareas.show(this.tareasView); | |
this.layout.estado.show(this.estadoView); | |
this.layout.update(); | |
}, | |
// Métodos | |
addTarea : function(tarea) { | |
this.tareas.add(tarea); | |
tarea.save(); | |
}, | |
removeTareasCompletadas : function() { | |
this.tareas.removeCompletadas(); | |
}, | |
resetTareas : function(tareas) { | |
this.tareas.reset(tareas); | |
}, | |
fetch : function() { | |
this.tareas.fetch(); | |
} | |
}); | |
return { | |
Tarea: Tarea, | |
Tareas: Tareas, | |
TareaView: TareaView, | |
TareasApp: TareasApp | |
}; | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
define(['tareas'], function(tareas) { | |
var tareasApp = new tareas.TareasApp({ el: "#tareas" }); | |
// Cargar los datos iniciales de la lista de tareas | |
// Usar los datos precargados en la página, para evitar una petición | |
// al servidor, los datos se incluyen en la página html de la aplicación. | |
//tareasApp.resetTareas(tareas); | |
// Aunque en la documentación de backbone recomiendan precargar los datos en la | |
// página, esto impide cachearla, dependiendo | |
// de la página tal vez sea mejor cachear la página y pedir los datos | |
// en una petición AJAX. | |
tareasApp.fetch(); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<h2>{{message.Lista_de_tareas}}</h2> | |
<input type="text" name="nuevaTarea" value="" class="input-xxlarge" placeholder="{{message.Introduce_una_nueva_tarea}}" /> | |
<div id="tareas"></div> | |
<div id="estado"></div> | |
<input type="button" name="limpiar" value="{{message.Limpiar}}" class="btn" /> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{{#total}} | |
{{message.COMPLETADAS_tareas_de_TOTAL_completadas}} | |
{{/total}} | |
{{^total}} | |
{{message.Muy_bien_has_completado_todas_las_tareas}} | |
{{/total}} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
define({ | |
'root': { | |
'Lista_de_tareas': 'Lista de tareas', | |
'COMPLETADAS_tareas_de_TOTAL_completadas': '{{completadas}} tareas de {{total}} completadas', | |
'Muy_bien_has_completado_todas_las_tareas': '¡Muy bien! has completado todas las tareas', | |
'Limpiar': 'Limpiar', | |
'Introduce_una_nueva_tarea': 'Introduce una nueva tarea' | |
}, | |
'en': true | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script type="text/javascript"> | |
requirejs.config({ | |
shim: { | |
'underscore': { | |
exports: '_' | |
}, | |
'json2': { | |
exports: 'JSON' | |
}, | |
'backbone': { | |
deps: ['jquery', 'underscore', 'json2'], | |
exports: 'Backbone' | |
}, | |
'backbone.marionette' : { | |
deps : [ 'backbone' ], | |
exports : 'Marionette' | |
} | |
}, | |
locale: '${locale}' | |
}); | |
</script> |
Para terminar y como prólogo de la siguiente entrada un detalle que comentaré es la cantidad de archivos que se cargan para este ejemplo y eso que no es excesivamente complejo, en total al cargar el ejemplo se hacen 31 peticiones, unas cuantas son de los estilos, fuentes e imágenes pero la mitad son de archivos js y plantillas mustache. En la siguiente entrada explicaré como optimizarlo para conseguir reducir esas 31 peticiones a 13, con una diferencia importante de tiempo de carga y esto en local (probablemente puesto el ejemplo en internet la latencia y la diferencia sería mayor), también conseguiremos reducir algunos kilobytes de peso a la página. La optimización será similar a lo que explique en la Optimización de módulos RequireJS y archivos javascript del ejemplo más sencillo de la Introducción a Backbone pero aplicado a este ejemplo de Marionette que es bastante más complejo. En el código fuente puede verse también como ejecutar pruebas unitarias usando Grunt, Jasmine, Sinon y como integrarlo con Gradle pero la expicación de esto será tema para otra entrada.
Como en el resto de entradas el código fuente completo lo puedes encontrar en mi repositorio de GitHub. Si quieres probarlo en tu equipo lo puedes hacer de forma muy sencilla con los siguientes comandos y sin instalar nada. Si no dispones de git para clonar mi repositorio de GitHub puedes obtener el código fuente del repositorio en un archivo zip con el anterior enlace.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
$ git clone git://github.com/picodotdev/elblogdepicodev.git | |
$ cd elblogdepicodev/MarionetteREST | |
$ ./gradlew tomcatRun | |
# Abrir en el navegador http://localhost:8080/MarionetteREST/ |
Introducción y ejemplo de RequireJS
Introducción y ejemplo de Mustache
Logging en Javascript con log4javascript
Capturar errores de Javascript
Optimizar módulos de RequireJS y archivos Javascript
Patrón de diseño MVC del lado cliente con Backbone.js
Introducción y ejemplo de Backbone.js
Ejemplo de pruebas unitarias en javascript con Jasmine y Sinon
Backbone
Marionette