Backbone.js

Introducción

En la actualidad existen muchas librerías MVC para JavaScript: Ember.js, Angular.js, Can.js, Backbone.js, ... por eso elegir la librería adecuada para nuestra aplicación es muy importante.

MVC battle

Backbone es una librería JavaScript que permite estructurar aplicaciones web utilizando componentes básico: models, collections y views. Estos componentes serán la base para construir aplicaciones web complejas.

Su creador es Jeremy Ashkenas (NYTimes y en DocumentCloud) quien también es el creador de otras librerías importantes:

Backbone es librería bastante simple y esa es una de sus principales virtudes.

The magic of backbone, IMO is in its lack of magic. Slim, readable, non-magical source, easily extended for custom needs. #backboneconf

— Henrik Joreteg (@HenrikJoreteg) May 30, 2012

Es una librería bastante popular y es utilizada por empresas importantes como Rdio, Hulu y Foursquare entre otras.

¿Por qué utilizar Backbone?

En la sección anterior vimos cómo crear aplicaciones web utilizando jQuery. A pesar de que la aplicación era bastante sencilla el código se estaba complicando. Trabajamos con los datos directamente en el DOM y no había una clara separación de conceptos entre las diferentes capas de la aplicación. Si la aplicación es extremadamente simple, por ejemplo un landing page, esta aproximación podría ser la adecuada. El problema está cuando la complejida aumenta. Necesitamos estructuras que nos permitan manejar esa complejidad o nuestro código se convertirá en un lio imposible de mantener. Algo parecido a la mayoría de plugins jQuery, cuando leo algo de su código, unicamente veo esto:

Spaghetti code

Aquí es donde reside la gran utilidad de Backbone. La librería añade una pequeña capa de abstracción que permite estructurar la aplicación. Los principales objetivo son:

Si comparamos Backbone con otras librerías MVC más modernas como pueden ser Angular o Ember, encontramos que Backbone es una librería mucho más simple. Provee los recursos básicos para que a partir de ahí seamos nosotros mismos los que creemos nuestro propio framework. Es por han aparecido muchas librerías para hacer más sencillas las tareas más repetitivas. Normalmente Backbone no te fuerza una determinada forma de hacer las cosas, por ejemplo es totalmente agnóstico del sistema de plantillas y nos permite que seamos nosotros quienes hagamos la integración con la librería que queramos. Por ejemplo Handlebars o React.js.

Estructura

Las clases básicas que provee Backbone son

La relación con el modelo MVC tradicional sería:

MVC Backbone
Model Model / Collection
Controller View / Router
View Template

La persistencía por defecto que trae Backbone es RESTFul, pero existen plugins que permiten utilizar otros mecanismos sin necesidad de modificar nuestro código. Por ejemplo:

Models

Los modelos son las clases que van a almacenar los datos de la aplicación además de gran parte de la lógica que los rodea: converciones, validaciones, valores calculados y control de acceso. Utilizar los modelos de Backbone nos va a proporcionar una pieza clave para manejar los cambios en la aplicación, los eventos.

// constructor por defecto
var Book = Backbone.Model.extend({});

var book = new Book({
    title: "One Thousand and One Nights",
    author: "Scheherazade"
});

// extender el constructor
var Book = Backbone.Model.extend({
    initialize : function (attributes) {
        ...
    }
});

var book = new Book({
    title: "One Thousand and One Nights",
    author: "Scheherazade"
});

book.get('title'); //"One Thousand and One Nights"

book.set({title: "March 20", content: "In his eyes she eclipses..."});
book.set("title", "A Scandal in Bohemia");

Eventos

Los eventos nos va a permitir detectar los cambios que se producen en los modelos. Esto es muy importante para mantener la sincronización entre los datos y las vistas.

book.set('title', '...'); // Se lanza evento "change:title" y "change"

book.set({title : '', content : ''}); // Se lanza un evento change
// por cada atributo y un único "change"

book.set({title : ''}, {silent : true}); // No lanza eventos

Attributes

Con defaults podemos asignar valores por defecto a otroso atributos.

var Meal = Backbone.Model.extend({
    defaults: {
        "appetizer": "caesar salad",
        "entree": "ravioli",
        "dessert": "cheesecake"
    }
});
var meal = new Meal({dessert : 'ice cream'});

meal.get('dessert'); // ice cream
meal.get('entree'); // ravioli

Los atributos se almacenan internamente dentro de attributes

Backbone internal model object

No deberíamos acceder directamente a este objeto, porque estaríamos perdiendo el mecanismo de detección de cambios. En el caso de que necesitemos acceso a todos los atributos, por ejemplo para pasárselos a la vista para que se rendericen, podemos utilizar el método toJSON. Este método devuelve una copia de los atributos.

var artist = new Backbone.Model({
    firstName: "Wassily",
    lastName: "Kandinsky"
});
artist.set({birthday: "December 16, 1866"});

artist.toJSON(); // { firstName: "Wassily", lastName: "Kandinsky", birthday: "December 16, 1866" }

Las validaciones se realizan en método validate.

var Chapter = Backbone.Model.extend({
    validate: function(attrs) {
        if (attrs.end < attrs.start) {
            return "can't end before it starts";
        }
    }
});

Sincronización

Mediante la sincronización vamos a persistir los cambios que hagamos en cliente con los datos que tenemos en el servidor. Lo más normal en el desarrollo de aplicaciones single page es tener una API rest con la que se comunique el cliente web. Es por eso que el mecanismo de sincronización por defecto es RESTful.

var Book = Backbone.Model.extend({
    urlRoot : '/books'
});

var book = new Book({
    title: "The Rough Riders",
    author: "Theodore Roosevelt"
});

book.save(); // HTTP POST /books
book.set('title', '...');
book.save({author: "Teddy"}); //HTTP PUT /books/3
book.fetch(); // HTTP GET /books/3
book.destroy(); //HTTP DELETE /books/3

Collections

Las colecciones almacenan listas de modelos.


var Library = Backbone.Collection.extend({
    model: Book
});
var library = new Library([book1, book2, book3]);


Collection / modificar
var ship1 = Backbone.Model({name: "Flying Dutchman"});
var ship2 = Backbone.Model({name: "Black Pearl"});

var ships = new Backbone.Collection;

//trigger "add", "remove" events
ships.add([ship1, ship2]);
ships.remove(ship1);
ships.get(ship2.id);
ships.push(ship1);
ships.pop(ship1);
ships.unshift(ship1);
ships.shift(ship1);

Además de estos métodos básico, las colleciones integran los métodos de underscore.js para trabajar con arrays.

Sincronización

var Notes = Backbone.Collection.extend({
    url: '/notes'
});

var notes = new Notes();
notes.fetch(); // HTTP GET /notes
notes.fetch({data: {page: 3}})  //HTTP GET /notes?page=3

Router

El router es una parte muy importante de una aplicación. Permite almacenar el estado de una aplicación. Cuando un usuario copia o comparte un enlace, espera que la web sea capaz de recuperar el estado.

Existen dos mecanismos que nos permiten modificar la url desde JavaScript:

Los hashFragments están soportados por todos los navegadores mientras que la History API únicamente está soportada por los navegadores modernos. El router de Backbone es capaz de utilizar un mecanismo u otro dependendiendo del soporte del navegador.

El funcionamiento del router consiste en hacer un mapeo entre patrones de url y métodos que se invocarán cuando un patrón coincida.

var Router = Backbone.Router.extend({
    routes: {
        "help":                 "help",    // #help
        "search/:query":        "search",  // #search/kiwis
        "search/:query/p:page": "search"   // #search/kiwis/p7
    },

    help: function() {...},
    search: function(query, page) {...}

});
var router = new Router();
Backbone.history.start();
Backbone.history.start({pushState : true});

router.navigate("help", {trigger: true});

View

La vistas son la única parte de nuestra aplicación que esté en contacto con el DOM. Se encargan de renderizar los datos cuando se produzcan cambios en los modelos y de responder a los eventos que se produzcan en el DOM por la interacción del usuario.

var DocumentView = Backbone.View.extend({
    render: function() {
        var html = this.template(model.toJSON());
        this.$el.html(html);
    }
});

var document = new Backbone.Model({title : 'title'});
var documentView = new DocumentView({el : '#document', model : document});
documentView.render();

Templates

Underscore cuenta con la función _.template que funciona como un sistema de plantillas muy básico.

    var DocumentView = Backbone.View.extend({
        template : _.template("<strong>Title<strong> : <%= title %>");
        render : function () {
            var html = this.template(model.toJSON());
            this.$el.html(html);
        }
    });

El principal inconveniente de las plantillas de undescore es el código inline necesario para hacer cosas tan sencillas como iterar por una lista de elementos.

var list = "<% _.each(people, function(name) { %> <li><%= name %><li> <% }); %>";
_.template(list, {people : ['moe', 'curly', 'larry']});

Es por eso que recomiendo utilizar otro sistema de plantillas. Por ejemplo uno que está bastante extendido, además de ser el que utiliza Ember, es Handlebars

Handlebars logo

Este sería el mismo código que el ejemplo anterior utilizando Handlebars.

var template = Handlebars.compile(
   "{{#each people}}<li>{{ this }}<li>{{/each}}"
);
template({people : ['moe', 'curly', 'larry']});

Para utilizar handlebars desde nuestras vistas tenemos varias opciones:

Podemos compilarlas directamente en la vista. Escribiendo la plantilla inline:

var DocumentView = Backbone.View.extend({
    template : Handlebars.compile(
        "<strong>Title<strong> : {{title}}");

    render : function () {
        var html = this.template(model.toJSON());
        this.$el.html(html);
    }
});

O escribiendo la plantilla en un tag script para poder escribir texto multilinea.

<script type="text/html" id="#documentView">
    <ul>
    {{#each people}}
        <li>{{ name }}</li>
    {{/each}}
    </ul>
</script>
<script>
var DocumentView = Backbone.View.extend({
    template : Handlebars.compile($('#documentView').html()),
    render : function() {
        this.$el.html(this.template(this.collection.toJSON()));
    }
});
</script>

Pero la opción más recomendada es precompilar las plantillas. Por ejemplo utilizando este plugin gulp-handlebars.

Events

El atributo events permite asociar los eventos de DOM que se producen en las vistas con métodos que se van a ejecutar cuando se lance el evento.

var DocumentRow = Backbone.View.extend({

    events: {
        "click .icon":          "open",
        "click .button.edit":   "openEditDialog",
        "click .button.delete": "destroy"
    },

    render: function() {...},
    open: function() {...},
    openEditDialog : function() {...},

});

La vista es la encargada de responder tanto a los eventos del DOM como a los eventos que se produzcan en los modelos y para los que sea necesario actualizar las vistas.

model.on("change", view.render);
view.on("addUser", function (user) {
    this.collection.add(user)
});

Testing

Aquí tienen un artículo muy bueno que explica cómo testear cada uno de los componentes de Backbone.

http://tinnedfruit.com/2011/03/03/testing-backbone-apps-with-jasmine-sinon.html

El caso que puede ser más complejo de probar es testear la vistas. La forma más sencillas sería comprobar que el HTML generador al renderizar la vista sea el correcto.

describe("TodoView", function() {
    beforeEach(function() {
        this.model = new Backbone.Model({title: "My Todo", id: 1});
        this.view = new TodoView({model:this.model});
    });
    it("produces the correct HTML", function() {
        this.view.render();
        expect(this.view.el.innerHTML)
            .toEqual('<a href="#todo/1"><h2>My Todo</h2></a>');
    });
});

Ejercicio: backbone-todo

Escribe la aplicación de Todos hicimos con jQuery utilizando Backbone.

Aplicación

Como ejercicio vamos a crear una aplicación de chat utilizando Backbone. Esta aplicación cliente se conectará a un servidor que nos permitirá realizar comunicarnos en tiempo real.

Un consejo cuando empiezas a desarrollar una aplicación es analizar los modelos que tendrá la aplicación. Esto nos va ayudar tanto a planificar el código del front como a planificar el código de backend.

Los modelos que vamos a definir son un modelo User que va a mantener la información de los usuarios que estan conectados y otro modelo Message que representará cada uno de los mensaje que se manden.

Una vez tenemos definido nuestro modelo de datos podemos empezar a hacer un mockup de nuestra aplicación para identificar las partes de las que se compone.

Chat views

Una forma de identificar las diferentes vistas de la aplicación es analizar las necesidades de actualización. En nuestro caso tendremos una vista que lista los usuarios, que se va a renderizar cada vez que un usuario se conecte o se desconecte. Tendremos otra vista para mostrar los mensajes que se va a renderizar cada vez que se añada un mensaje nuevo. Por último la vista InputView se actualizará con la interacción del usuario.

Un diagrama interesante que podemos realizar es un diagrama de eventos que represente los eventos que van a conectar los modelos y las vistas.

Events diagram

La primera vista que vamos a definir es la vista de los usuarios. Esta vista es bastante sencilla y se limita únicamente a renderizar la collections de usuarios.

App.UsersView = Backbone.View.extend({

    template : Handlebars.compile($('#usersTmpl').html()),

    render : function () {
        var context = {
            users : this.collection.toJSON()
        };
        this.$el.html(this.template(context));
    }
});

Para probar que el código funciona correctamente prueba este código desde la consola o añádelo en el código de la aplicación (recuerda borrarlo después).

App.users.reset([{name : 'user1'}, {name : 'user2'}]);
App.usersView.render();

Ahora debemos conectar los eventos para mantener la sincronización entre la vista y el modelo.

App.users.on('add reset', function () {
    App.usersView.render();
});
App.users.reset([{name : 'user1'}, {name : 'user2'}]);

En otros casos en los que estemos trabajando con collecciones y vistas, suele ser necesario conectar el evento "remove". En nuestro caso no va a ser necesario porque el servidor va a devolver siempre la lista completa de usuarios. El único evento que se va a lanzar es el reset.

Para la vista de los mensajes vamos a utilizar otra forma de hacer las cosas. En lugar de renderizar el listado completo, nuestra plantilla va a representar un único mensaje.

App.MessagesView = Backbone.View.extend({
    msgTemplate : Handlebars.compile($('#msgTmpl').html()),
    render : function () {
        var view = this;
        this.collection.each(function(message) {
            view.appendMsg(message);
        });
    },
    appendMsg : function (msg) {
        var context = msg.toJSON();
        this.$el.append(this.msgTemplate(context));
    }
});

El servidor nos notificará cada vez que se añada un mensaje nuevo, por lo tanto esta implementación nos será más útil porque no tendremos que renderizar la lista completa de nuevo.

Prueba la vista añadiendo algunos mensajes.

App.messages.reset([
    {username : 'username', timestamp: '01-01-2012', msg : 'hola'},
    {username : 'username2', timestamp: '01-01-2012', msg : 'mundo'}
]);
App.messagesView.render();

Conectamos los eventos de la nueva vista

App.messages.on('add', function (message) {
    App.messagesView.appendMsg(message);
});
App.messages.on('reset', function () {
    App.messagesView.render();
});
var msg1 = new App.Message(
  {username : 'username', timestamp: '01-01-2012', msg : 'hola'});
var msg2 = new App.Message(
  {username : 'username2', timestamp: '01-01-2012', msg : 'mundo'});
App.messages.reset([msg1]);
App.messages.add(msg2);

En este caso tenemos dos eventos distintos porque se llamará a "reset" cuando se conecte al servidor y a "add" cuando llegue un mensaje nuevo

Ejercicios

Pistas

var smiles = [":)", ":'(", ":P", ";)", ":*", ":D", ":@", "¬¬", "8|"];
var codes  = ["85", "358", "348", "344", "363", "86", "361", "353", "352"];

InputView

Esta vista va a gestionar la interacción del usuario

App.InputView = Backbone.View.extend({

    initialize : function (options) {
        this.$input = this.$el.find('input');
    },

    events : {
        "keypress" : "keypress"
    },

    keypress : function (e) {
        if (e.which == 13) { // enter
            var val = this.$input.val();
            this.$input.val('');

            var msg = new App.Message({msg : val});
            this.trigger("newMessage", msg);
        }
    }
});

Conectamos los eventos

App.inputView.on("newMessage", function (msg) {
    App.messages.add(msg);
});

Podríamos definir algún tipo de validación, como que los mensajes no deberían estar vacios.

App.Message = Backbone.Model.extend({
    validate : function (attributes) {
        if(_.isUndefined(attributes.msg) || attributes.msg.length === 0) {
            return "el mensaje debe tener contenido";
        }
    }
});

Únicamente lanzaremos el evento en el caso de que mensaje sea válido.

keypress : function (e) {
    if (e.which == 13) { // enter
        var val = this.$input.val();

        var msg = new App.Message({msg : val});
        if(msg.isValid()){
            this.$input.val('');
            this.trigger("newMessage", msg);
        }
    }
}

Servidor

El código del backend utiliza Node.js y socket.io. Todavía no hemos entrado en detalle con Node.js pero lo haremos más adelante. Por ahora podemos echarle un vistazo al código.

https://github.com/axelhzf/chat/blob/master/server/app.js

Sincronización de cliente y servidor

var socket = io.connect('http://chat-tlp2k12.herokuapp.com');
socket.on('connect', function () {
    var username = prompt("What's your name?");
    App.config.set('username', username);
    socket.emit('adduser', username);
});
socket.on("initchat", function (response) {
    App.messages.reset(response);
});
socket.on('updatechat', function (message) {
    App.messages.add(message);
});
socket.on('updateusers', function (data) {
    var users = _.map(_.keys(data), function (name) {
        return {name : name};
    });
    App.users.reset(users);
});
App.inputView.on("newMessage", function (message) {
    socket.emit('sendchat', message.get('msg'));
});

Al intentar conectarte a un servidor externo el navegador te bloqueará por seguridad. Lo que podemos hacer en estos casos para probar la aplicación es deshabilitar esa medida de seguridad.

OS X : `open -a /Applications/Google\ Chrome.app --args --disable-web-security`
Windows : `chrome.exe --disable-web-security`

Recursos