Apprendre BackboneJS

Ce tutoriel est la deuxième partie de la formation "FullStack JS" et fait suite au tutoriel sur Gulp. Si l'utilisation de cet outil n'est pas claire pour vous, nous vous conseillons de refaire les étapes du tutoriel précédent.

L'objectif ici est de présenter la librairie BackboneJS et de réaliser une application complète à l'aide de ce framework.

Comme dans le tutoriel précédent, nous allons tout au long des étapes créer des versions du code sur le dépôt Github du projet, de manière à ce qu'à la fin de chaque étape vous puissiez vous référer au code tel qu'il devrait être et suivre ses évolutions. En cas de problèmes vous pourrez toujours revenir en arrière juste en reprenant le code de l'étape précédente.

Les tags sur le dépôt Github sont accessibles à cette adresse : https://github.com/Mnemotix/backbone-tuto/tags

Sommaire :

Présentation

Backbone est ce qu'on appelle communément un Web Framework ou Framework MVC, c'est à dire qu'il permet de séparer clairement les couches liées au modèle de données (M) et celles liées à la présentation des données, à savoir lesinterfaces utilisateur (V). Le deux couches précédentes étant reliées par la couche contrôleur (C).

Backbone est un framework très léger ("unopinionated") qui laisse une grande liberté à l'utilisateur quant à la manière d'organiser les éléments de son application. C'est ce qui fait sa force et il est très apprécié de la communauté JS, néanmoins cette souplesse peut être également la source d'erreurs et se transformer en faiblesse...

Step 0 : Installation

Nous allons repartir de la même structure de projet que celle que nous avions à la fin du tutoriel précédent :

git clone https://github.com/Mnemotix/gulp-tuto.git backbone-tuto
cd backbone-tuto
npm install
gulp

Nous allons installer ensuite quelques dépendances qui nous permettrons de construire une application Backbone complète :

npm install --save backbone underscore bootstrap

Nous allons ensuite construire la structure modulaire de notre application qui va nous permettre d'utiliser Browserify :

mkdir ./client/app/node_modules
cd ./client/app/node_modules
ln -sf ../views
ln -sf ../models
ln -sf ../collections

Ces liens symboliques nous permettrons d'importer nos modules de la même manière que n'importe quel module Node, par un simple require("models/models") par exemple.

Dans Backbone, d'une manière générale, les vues utilisent les collections qui se basent sur les modèles. La résolution des imports devrait donc être la suivante :

application => vue => collection => modèle

Commençons donc par importer les modèles dans les collections. Editez le fichier collections.js :

var models = require("models/models");
console.log("Collections have been loaded.");

Puis le fichier views.js :

var collections = require("collections/collections");
console.log("Views have been loaded.");

Et enfin dans le fichier app.js :

var $ = require('jquery');
var _ = require('underscore');
var Backbone = require('backbone');
Backbone.$ = window.$ = window.jQuery = $;  // nécessaire pour bootstrap
var bootstrap = require('bootstrap');

var views = require('views/views');

console.log("Application has started...");
$('img').hide().fadeIn(3000);

Nous voyons ici que les

Pour que le tout fonctionne il reste à importer la feuille de style Bootstrap. Editez le fichier style.less :

@import "../../../node_modules/bootstrap/less/bootstrap";
body{
    padding-top : 60px; // espacement pour la navbar
}

Et enfin, on ajoute un peu de code bootstrap dans le fichier index.html pour s'assurer que tout fonctionne :

<!DOCTYPE html>
<html>
<head lang="en">
    <meta charset="UTF-8">
    <title>My App</title>
    <link rel="shortcut icon" href="img/favicon.ico">
    <link rel="stylesheet" type="text/css" href="css/style.css">
</head>
<body>

<nav class="navbar navbar-default navbar-fixed-top">
    <div class="container">
        <div class="navbar-header">
            <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar"
                    aria-expanded="false" aria-controls="navbar">
                <span class="sr-only">Toggle navigation</span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
            </button>
            <a class="navbar-brand" href="#">My App</a>
        </div>
        <div id="navbar" class="collapse navbar-collapse">
            <ul class="nav navbar-nav">
                <li class="active"><a href="#">Home</a></li>
            </ul>
        </div>
        <!--/.nav-collapse -->
    </div>
</nav>

<div class="container">
    <h1>That works!</h1>
    <img src="img/shlurp.png">
</div>
<script src="bundle.js"></script>
</body>
</html>

Le résultat devrait ressembler à ça :

capture1

Comme indiqué dans la console JS, les modules sont chargés dans le bon ordre. Tout est prêt pour bien démarrer avec backbone.

Code source complet de cette étape

Step 1 : Les modèles

Un modèle Backbone est un mélange entre un bean et un DAO. Non seulement il contient les données, mais il dispose également de toutes les méthodes qui vont permettre de les manipuler très finement.

Prenons par exemple le cas d'une application de type "Annuaire d'entreprise", un modèle simpliste serait constitué très simplement des champs suivants :

Commençons par créer ce modèle dans le fichier models/models.js.

// models/models.js
var Backbone = require("backbone");

var Employee = Backbone.Model.extend({
    initialize: function(){
        console.log('This model has been initialized.');
    },
    defaults : {
        id : -1,
        firstName : "",
        lastName : "",
        title : "Not available",
        email : "Not available" 
    }
});

module.exports = {
    Employee : Employee
};

Après avoir importé le module Backbone, nous déclarons une classe Employee qui hérite du modèle Backbone et qui implémente un constructeur et nous définissons quelques valeurs par défaut. Dans le fichier app.js, nous pouvons importer le modèle et l'instancier en lui passant quelques valeurs :

// app.js

var $ = require('jquery');
var _ = require('underscore');
var Backbone = require('backbone');
Backbone.$ = window.$ = window.jQuery = $;  // nécessaire pour bootstrap
var bootstrap = require('bootstrap');

var models = require('models/models');

var me = new models.Employee({
    firstName : "Nicolas",
    lastName : "Delaforge",
    title : "CEO",
    email : "nicolas.delaforge@mnemotix"
});

Nous pouvons également utiliser les accesseurs et fonctions utilitaires fournies par Backbone :

//...récupérer la valeur d'un champ
console.log( me.get("firstName"), me.get("lastName") );

// ...mettre à jour la valeur d'un autre
me.set( "email", "nicolas.delaforge@mnemotix.com" );

// ...ajouter de nouveaux champs d'un coup
me.set({
    "cellPhone": "+33654321000",
    "officePhone": "+33496543210"
});

// ...supprimer un champ
me.unset("title");

// ...dumper le contenu du modèle en JSON
console.log( me.toJSON());

Ecouter les évènements du modèle

Les modèles Backbone sont faits de telle manière que chaque modification sur les valeurs du modèle déclenche un évènement qu'il est possible d'écouter en déclarant des listeners dans le constructeur :

var Employee = Backbone.Model.extend({
    initialize: function(){
        console.log('This model has been initialized.');
        this.on('change', function(){
            console.log('- Values for this model have changed.');
        });
    },
    // valeurs par défauts, etc...

Il est également possible d'écouter des changements sur des attributs précis du modèle :

this.on('change:email', function(){
    console.log('- Email value for this model has changed.');
});

Il est néanmoins possible de faire mettre à jour les valeurs d'un modèle sans déclencher d'évènements. Il faut dans ce cas passer la commande {silent: true} au setter :

me.set({
    "cellPhone": "+33654321000",
    "officePhone": "+33496543210"
}, {silent: true});

Validation

Il peut être utile de valider la conformité d'un modèle avant de l'injecter en base ou de l'afficher. Backbone propose une manière très simple de valider le modèle :

var Employee = Backbone.Model.extend({
    initialize: function(){ /* coupé pour la lisibilité */ },
    defaults : { /* coupé pour la lisibilité */ },
    validate : function(attrs) {
        if (!attrs.firstName) { return 'First name is required'; }
        if (!attrs.lastName)  { return 'Last name is required';  }
        if (!attrs.email)     { return 'Email is required';      }
    }
});

La méthode validate()est invoquée avant chaque appel de la méthode save() qui va ordonner au modèle d'être persisté. Il est également possible de forcer la validation en appelant tout simplement la méthode validate() sur le modèle ou bien en passant l'option {validate: true} à la fonction set ou unset. Par exemple, l'appel suivant va échouer car l'attribut firstName est obligatoire :

me.unset("firstName", {validate: true});    // fail

Dans le cas où le modèle n'est pas validé, un évènement invalid sera levé et le modèle aura alors la propriété validationError qui aura été affectée avec la valeur de retour de la fonction validate().

Il est possible d'écouter cet évènement de la même manière que les précédents, en ajoutant un listener dans la fonction initialize. L'erreur est également passée en paramètre :

this.on("invalid", function(model, error){
    console.error(error);
});

À la fin de cette étape, la console de votre application devrait ressembler à ça au démarrage de l'application :

Code source complet de cette étape

Step 2 : Les collections

Les collections Backbone contiennent les instances d'un type de modèle prédéfini. Pour créer une collection il suffit d'étendre la classe Backbone.Collection.

2.a - Ajouter et supprimer des modèles

Dans le fichier collections.js, remplacez le contenu actuel par le contenu suivant :

// collections/collections.js

var Backbone = require("backbone");
var models = require("models/models");

var Employees = Backbone.Collection.extend({
    model : models.Employee
});

module.exports = {
    Employees : Employees
};

Comme pour les modèles, les collections disposent d'une fonction initialize où il est possible d'écouter certains évènements :

var Employees = Backbone.Collection.extend({
    model : models.Employee,
    initialize: function(){
        // we can listen for add/change/remove events
        this.on("add", function(model) {
            console.log("Added " + model.get('email'));
        });
        this.on("remove", function(model) {
            console.log("Removed " + model.get('email'));
        });
        this.on("change", function(model) {
            console.log("Changed " + model.get('email'));
        });
        this.on("reset", function() {
            console.log("Collection reset.");
        });
    }
});

Dans le fichier app.js, nous allons instancier cette collection et lui ajouter quelques modèles :

// app.js
/* imports coupés pour des raisons de lisibilité */

var models = require('models/models');
var collections = require('collections/collections');
var employees = new collections.Employees();

var me = new models.Employee({
    id : 1,
    firstName : "Nicolas",
    lastName : "Delaforge",
    title : "CEO",
    email : "nicolas.delaforge@mnemotix"
});

// on ajoute l'instance d'Employee à la collection
employees.add(me);

// on ajoute un modèle anonyme
employees.add({id : 2, firstName : "Mylène", lastName : "Leitzelman", email : "contact@mnemotix"});

// on supprime un modèle par un attribut
employees.remove({id:2});

// on met à jour le contenu d'un modèle
employees.add({id : 1, email : "nicolas.delaforge@mnemotix.com"}, {merge:true}); // => success

// on essaye de mettre à jour un modèle sans l'option merge, sachant c'est {merge:false} par défaut
employees.add({id : 1, firstName : "Nico"});    //  => fail

// on vérifie que la taille de la collection
console.log(employees.length); // => 1

// on dumpe le contenu de la collection en JSON
console.log(employees.toJSON());

Code source

2.b - Charger les modèles depuis un fichier

Nous allons ajouter le fichier ./client/app/people.json au projet qui nous permettra d'initialiser la collection :

// app.js
/* imports coupés pour des raisons de lisibilité */

var models = require('models/models');
var collections = require('collections/collections');

var data = require('./people.json');
var employees = new collections.Employees(data);

console.log("%d employees loaded.", employees.length); // => 12

Retrouver les modèles

Les modèles Backbone considère l'attribut id comme étant la 'clé primaire' par défaut. Il est néanmoins possible de lui indiquer que la clé primaire est rattachée à un autre champ grâce à la propriété idAttribute. Par exemple :

var Employee = Backbone.Model.extend({
    idAttribute : 'email',
   /**/

Les collections Backbone disposent d'une méthode get(<id>) qui leur permet de retrouver directement un modèle à partir de son identifiant.

employees.get(1);

Il est également possible de retrouver les modèles par leur index dans la liste :

employees.at(0);

Underscore

Underscore est une librairie utilitaire sur laquelle Backbone est construit. Elle met à disposition un ensemble de fonctionnalités très pratiques et très performantes notamment sur les listes.

Les collections Backbone étendent les collections Underscore et donc bénéficient de toute les fonctionnalités de tri, de filtrage, de mapping, etc. Voici un aperçu des fonctionnalités les plus utiles :

// foreach
employees.forEach(function(model){
    console.log(model.get('email'));
});

// sortby
var sortedByName = employees.sortBy(function (model) {
    return model.get("lastName").toLowerCase();
});
sortedByName.forEach(function(model){
    console.log(model.get('lastName'));
});

// filter
var engineering = employees.filter(function(model){
    return model.get("department") == "Engineering";
});
engineering.forEach(function(model){
    console.log(model.get('email'));
});

Code source

Step 3 : Les vues

Les vues Backbone sont en réalité plutôt des contrôleurs au sens MVC. Elles contiennent la logique qui va permettre de présenter les données à l'utilisateur. Pour cela, les vues Backbone reposent sur le templating Javascript. N'importe quel moteur de template peut être intégré dans les vues Backbone : Mustache, Handlebars, EJS, Hogan.js, etc.

Les vues disposent d'une méthode render() qui peut être couplée à n'importe quel évènement Backbone (par exemple change) de manière à refléter instantanément toute évolution du modèle dans l'interface utilisateur.

Nous allons tout d'abord préparer notre fichier index.html pour l'intégration de contenu dynamique en remplacant le contenu du conteneur principal (ligne 32) par :

<div class="container">
    <div id="employee-container"></div>
</div>

Ensuite, nous allons créer une classe EmployeeView, qui sera en charge de l'affichage d'un employé, dans le fichier views/views.js :

// views/views.js

var Backbone = require("backbone");

var EmployeeView = Backbone.View.extend({
    el : $('#employee-container'),
    initialize : function() {
        console.log("View created");
    },
    render : function() {
        this.$el.html('<h2>This is my first Backbone View !!</h2>');
        return this;
    }
});

module.exports = {
    EmployeeView : EmployeeView
};

Chaque vue Backbone est associée à un fragment DOM par l'intermédiaire de la propriété el. Cette propriété contient une référence à un élément, ici un sélecteur jQuery. C'est dans cet élément que sera ajouté le contenu HTML généré par la méthode render(). L'élément associé à la propriété el est accessible directment en utilisant l'alias this.$el au sein de la vue.

Il ne reste plus qu'à instancier cette vue. Dans le fichier app.js, on supprime le code inutile, on instancie la vue et on appelle la méthode render() :

// app.js

var $ = require('jquery');
var _ = require('underscore');
var Backbone = require('backbone');
Backbone.$ = window.$ = window.jQuery = $;  // nécessaire pour bootstrap
var bootstrap = require('bootstrap');

var views = require("views/views");

var view = new views.EmployeeView();
view.render();

Ecouter les évènements du DOM

Les vues Backbone permettent d'écouter très simplement n'importe quel évènement JS pouvant survenir sur le fragment du DOM qui lui est associé.

// views/views.js

var EmployeeView = Backbone.View.extend({
    el : $('#employee-container'),
    events : {
        'click' : 'onClick',
        'mouseover h2' : 'onMouseover'
    },
    initialize : function() {
        console.log("View created");
    },
    render : function() {
        this.$el.html('<h2>This is my first Backbone View !!</h2>');
        return this;
    },
    onClick : function(event) {
        console.log("You clicked me right ?");
    },
    onMouseover : function(event) {
        console.log("Get out !");
    }
});

Associer un modèle à une vue

Pour ajouter un modèle à une vue Backbone, il faut lui initialiser l'attribut 'model' :

// app.js

var models = require("models/models");
var views = require("views/views");

var me = new models.Employee({
    firstName : "Nicolas",
    lastName : "Delaforge",
    title : "CEO",
    email : "nicolas.delaforge@mnemotix.com"
});

var view = new views.EmployeeView({
    model:me        // new
});
view.render();

Nous pouvons maintenant récupérer les valeurs du modèle et les afficher dans la méthode render():

// views/views.js

    render : function() {
        this.$el.html('<h2>' + this.model.get('firstName') + ' ' + this.model.get('lastName') + '</h2>');
        return this;
    },

Associer une collection à une vue

Il est nécessaire de distinguer les "vues de modèle" qui vont afficher les propriétés d'un et un seul modèle et les "vues de collections" qui vont avoir un ensemble de modèle à afficher.

Nous avons écrit la vue associée au modèle Employee, écrivons maintenant la vue de collection associée à la collection Employees. Editez le fichier views/views.js et ajouter le code suivant :

// views/views.js
/* Contenu tronqué pour une meilleure lisibilité */

var StaffView = Backbone.View.extend({
    el : $('#employee-container'),
    render : function() {
        var html = '<ol>';
        this.collection.forEach(function(model){
            html += '<li>' + model.get('firstName') + ' ' + model.get('lastName') + '</li>';
        });
        html += '</ol>';
        this.$el.html(html);
        return this;
    }
});

module.exports = {
    EmployeeView : EmployeeView,
    StaffView : StaffView
};

Pour vérifier qu'elle fonctionne, nous devons modifier le fichier app.js :

var $ = require('jquery');
var _ = require('underscore');
var Backbone = require('backbone');
Backbone.$ = window.$ = window.jQuery = $;  // nécessaire pour bootstrap
var bootstrap = require('bootstrap');

var collections = require("collections/collections");
var views = require("views/views");

var data = require("./people.json");
var staff = new collections.Employees(data);
var view = new views.StaffView({
    collection:staff
});
view.render();

Votre page devrait maintenant vous afficher la liste des noms des membres de la collection (12 au total).

Synchroniser la vue et son modèles/sa collection

Les vues Backbone disposent de la même fonction initialize que les modèles et les collections. Selon que nous avons une vue de modèle ou de collection il est donc possible d'ajouter des listeners sur les évènements qui les traversent. Généralement, en cas de changement c'est la méthode render()qui est appelée pour rafraichir la vue.

Par exemple :

var StaffView = Backbone.View.extend({
    el : $('#employee-container'),
    initialize:function(){
        this.collection.on('change', this.render, this);
    },
    render : function() {
        var html = '<ol>';
        this.collection.forEach(function(model){
            html += '<li>' + model.get('firstName') + ' ' + model.get('lastName') + '</li>';
        });
        html += '</ol>';
        this.$el.html(html);
        return this;
    }
});

Le troisième paramètre this passé au listener définit le contexte du this à l'intérieur du callback, ce qui permet à la méthode render de bien faire référence à la vue et non à la collection dans l'instruction this.collection, this.$el ou return this.

Gare aux Zombies !!!

La plupart des développeurs Backbone sont confrontés tôt ou tard à des vues qui se comportent de manière étranges, à un click sur un bouton qui semble se répéter N fois, à une consommation de RAM qui augmente drastiquement au fil de la navigation. Si vous êtes victimes de ce genre de situation, il y a fort à parier que les zombies vous ont attaqué à votre insu.

La plupart de ces comportements sont causés par une mauvaise gestion des vues et en particulier des listeners d'évènements.

Lorsque vous ajoutez des listeners sur les évènements du DOM, comme ceci :

var EmployeeView = Backbone.View.extend({
    el : $('#employee-container'),
    events : {
        'click' : 'onClick',                    // ZOMBIES !!!
        'mouseover h2' : 'onMouseover'          // ZOMBIES !!!
    },

Ou encore des listeners sur des évènements liés aux modèles ou aux collections, comme ceci :

var StaffView = Backbone.View.extend({
    el : $('#employee-container'),
    initialize:function(){
        this.collection.on('change', this.render, this);    // ZOMBIES !!!
    },

Dans chacun de ces cas, vous risquez de générer des vues zombies. Prenons le cas d'un utilisateur qui navigue dans l'application, qui clique sur les liens, qui revient à l'accueil, etc. Il passe d'une vue à l'autre et à chaque fois une nouvelle vue est instanciée, de nouveaux listeners sont créés, etc.

Le problème c'est que les vues précédentes sont encore abonnées aux évènements et que ces listeners vont empêcher le garbage collector de détruire ces objets qui sont encore actifs. Et plus l'utilisateur navigue, plus l'application va se transformer en une cacophonie d'évènements envoyés dans tous les sens à N instances de la même vue. Ce phénomène peut provoquer de graves disfonctionnements de l'application et même résulter à des pertes de données inexpliquées. Il est donc primordial de savoir gérer la destruction de vues et la résiliation des listeners.

Il existe plusieurs solutions pour cela comme l'explique très bien Derick Bailey, l'auteur de Marionette, sur son blog.

Solution #1

La plus simple, mais par forcément la plus efficace consisterait à écrire une méthode close()pour chaque vue qui ressemblerait à ceci :

var StaffView = Backbone.View.extend({
    el : $('#employee-container'),
    initialize:function(){
        this.collection.on('change', this.render, this);    // ZOMBIES !!!
    },
    close : function(){
        this.remove();  // vide le contenu de l'élément this.$el
        this.unbind();  // supprime tous les listeners liés au DOM
        this.collection.off('change');  // supprime le listener sur la collection
    }

NOTE : Backbone dispose de deux syntaxes équivalentes pour ajouter/supprimer des listeners d'évènements sur des modèles ou des collections : bind/unbind est équivalent à on/off :

this.model.on('change', this.render, this);
this.model.off('change');
// équivalent à
this.model.bind('change', this.render, this);
this.model.unbind('change');

Solution #2

La deuxième solution consiste à ajouter la fonction close directement sur le prototype de vue Backbone. Ce bout de code peut être intégré dans le fichier app.js par exemple :

// app.js

Backbone.View.prototype.close = function(){
    this.remove();          // vide le contenu de l'élément this.$el
    this.unbind();          // supprime tous les listeners liés au DOM
    if (this.onClose){      
        this.onClose();     // si une fonction onClose existe sur la vue, on l'invoque
    }
}

Cette solution a le mérite d'éviter d'avoir à réécrire la fonction close pour les vues qui n'ont pas de listeners sur les modèles.

Remarque

Les zombies ne concernent que les listeners ajoutés dans les vues et pas ceux ajoutés dans les modèles ou les collections. Tout simplement parce que les modèles et les collections ne sont (en théorie) instanciés qu'une fois, ils ne risquent donc pas de créer des interférences avec d'autres instances.

Code source complet de cette étape

Step 4 : Les templates

Code source complet de cette étape

Step 5 : Le routeur

Code source complet de cette étape

Step 6 : La synchronisation avec une API REST

Code source complet de cette étape

Conclusion