Débuter avec Backbone.js

Publié le 22 août 2013 par François Vaux | front

Cet article est publié sous licence CC BY-NC-SA

Backbone.js est un des principaux frameworks MV*1 JavaScript utilisés actuellement, avec AngularJS et Ember.

Si ces deux derniers suivent une approche plus « complète » en offrant beaucoup de fonctionnalités, Backbone se veut léger et extensible.

Cet article est le premier d’une série sur Backbone. Pour débuter, nous allons mettre en place un carnet d’adresses en utilisant localStorage pour la persistance des données.

Le code de cet article est disponible sur le dépôt GitHub que nous avons mis en place. Utilisez la version disponible au tag v1 pour le code correspondant à cet article. N’hésitez pas à le récupérer pour l’étudier.

Structure de base

Comme toute application Web, on part sur un squelette en HTML pour notre application. Ce sera le fichier index.html du dossier racine de notre projet. Dans celui-ci on retrouve :

  1. La structure basique d’un document HTML (doctype, head, body, …) ;
  2. Le chargement des feuilles de style associées ;
  3. Le squelette de l’application elle même ;
  4. Les éventuels templates utilisés par les vues ;
  5. Enfin, le chargement des dépendances et du code même de l’application, ainsi que son initialisation.
<!doctype html> <!-- 1 -->
<html>
<head>
  <meta charset="utf-8" />
  <title>Contact Book</title>
  <!-- 2 -->
  <link rel="stylesheet" href="css/normalize.css" type="text/css" charset="utf-8" />
  <link rel="stylesheet" href="css/master.css" type="text/css" charset="utf-8" />
</head>
<body>

  <!-- 3 -->
  <h1>Contact Book</h1>

  <!-- 4 -->

  <!-- 5 -->
  <script src="js/vendor/json2-min.js" charset="utf-8"></script>
  <script src="js/vendor/zepto-min.js" charset="utf-8"></script>
  <script src="js/vendor/handlebars-min.js" charset="utf-8"></script>
  <script src="js/vendor/underscore-min.js" charset="utf-8"></script>
  <script src="js/vendor/backbone-min.js" charset="utf-8"></script>
  <script src="js/vendor/backbone.localStorage-min.js" charset="utf-8"></script>
</body>
</html>

Pour cette application, nous allons utiliser les dépendances suivantes :

Récupérez toutes ces dépendances et placez les feuilles de style dans un dossier css/, et les bibliothèques JavaScript dans js/vendor/, comme c’est fait dans le squelette ci-dessus.

Modèles

Les modèles sont les unités de base d’une application Backbone. Ils permettent d’encapsuler des données et de fournir une grande partie des fonctionnalités qu’on peut attendre telles que les validations, des propriétés calculées, la persistance …

Dans notre cas, nous allons avoir besoin d’un seul modèle, Contact, qui va représenter une personne dans notre carnet d’adresse.

Pour rester simple, nous allons uniquement stocker le prénom, le nom, l’adresse email et le numéro de téléphone d’une personne.

On peut alors commencer à écrire notre modèle, dans le fichier js/models/contact.js :

var Contact = Backbone.Model.extend({
  defaults: {
    firstName: "",
    lastName:  "",
    email:     "",
    phone:     ""
  },

  validate: function(attributes) {
    if (attributes.firstName.length == 0) {
      return "first name must be provided.";
    }
  },

  fullName: function() {
    return [this.get('firstName'), this.get('lastName')].join(' ');
  },
});

Une fois écrit, on ajoute un directive dans notre squelette HTML pour charger le fichier après les dépendances

<!-- 5 -->
<script src="js/vendor/json2-min.js" charset="utf-8"></script>
<script src="js/vendor/zepto-min.js" charset="utf-8"></script>
<script src="js/vendor/handlebars-min.js" charset="utf-8"></script>
<script src="js/vendor/underscore-min.js" charset="utf-8"></script>
<script src="js/vendor/backbone-min.js" charset="utf-8"></script>
<script src="js/vendor/backbone.localStorage-min.js" charset="utf-8"></script>

<script src="js/models/contact.js" charset="utf-8"></script>

Arrêtons nous ici pour analyser le code ci-dessus. Ouvrez le fichier index.html dans votre navigateur et ouvrez votre console de développement.

Tout d’abord, notez comment on créée une classe avec extend. Ici, nos modèles dérivent de Backbone.Model, et on passe en argument un objet contenant les méthodes et propriétés que l’on veut définir sur les instances de nos objets.

On définit ici trois propriétés :

  • defaults permet de spécifier les attributs par défaut de notre modèle ;
  • validate est une fonction appelée à chaque sauvegarde du modèle (nous reviendrons la dessus plus tard) et qui, si elle renvoie quelque chose, empêche ladite sauvegarde ;
  • fullName est une propriété calculée qui permet de renvoyer le nom complet du contact.

Penchons nous sur cette dernière et créez un contact dans la console :

var contact = new Contact()

Essayez d’exécuter contact.firstName et vous verrez que vous obtiendrez undefined. En effet, avec Backbone on interagit avec les attributs des modèles à l’aide des méthodes get et set.

Le but est de pouvoir gérer des évènements lorsqu’on utilise ces méthodes, chose impossible si on passe directement par l’attribut.

La méthode set par exemple, génère un évènement change, que l’on peut écouter pour réagir d’une façon ou d’une autre.

Un bon exemple d’utilisation est la méthode fullName qui nous permet de concaténer prénom et nom, et qui utilise justement get.

Pour tester les évènements, exécutez le code suivant dans la console :

contact.on('change:firstName', function() {
  console.log('First name is now ' + this.get('firstName'));
})
contact.set('firstName', 'John');

Vous verrez apparaître la ligne suivante First name is now John.

Notez que l’on peut de cette façon écouter les évènements sur un attribut particulier avec la syntaxe change:nomDeLAttribut.

Collections

Les collections permettent de regrouper plusieurs modèles dans un même objet et de les observer ou d’agir sur eux collectivement.

Les collections permettent entre autres de filtrer des modèles, de les sauvegarder collectivement. Elles peuvent se voir imposer un ordre ou être triées sur le volet.

Dans notre cas, nous allons avoir une collection de contacts, que nous appellerons ContactBook.

Dans le fichier js/collections/contact_book.js, que l’on chargera après le modèle Contact dans notre squelette HTML, on place le code suivant :

var ContactBook = Backbone.Collection.extend({
  model: Contact,

  localStorage: new Backbone.LocalStorage('contact-book'),

  comparator: function(model) {
    return model.get('firstName');
  },

  filtered: function(expr) {
    return this.filter(function(contact) {
      if (contact.matches(expr)) return true;
    });
  },
});

La propriété model définit le type de modèles qu’on peut retrouver dans cette collection. On pourra automatiquement ajouter des modèles avec la méthode add de la collection. Notez que celle-ci prend aussi en argument des objets JavaScript qui seront automatiquement convertis en modèles du type indiqué.

localStorage indique que notre collection sera persistée en utilisant l’espace de noms contact-book dans le stockage du navigateur. Cette persistance est fournie par la bibliothèque Backbone.LocalStorage que l’on a chargé dans notre application. Nous reviendrons dans un prochain article sur la persistance des données.

Attardons nous aux deux propriétés restantes.

Filtrage

La méthode filtered nous permet de récupérer un sous ensemble de la collection sous forme d’Array JavaScript (ce n’est donc plus une collection).

Ici, on utilise la méthode filter (fournie par Underscore) qui prend en argument une fonction renvoyant vrai si un modèle doit appartenir à la collection filtrée.

On met en place une méthode matches sur nos modèles dont voici le code :

matches: function(expr) {
  if (expr === null) return true;

  var hasMatch = _.some(this.asMatchable(), function(field) {
    return field.match(expr) !== null;
  });

  if (hasMatch) return true;

  return false;
},

asMatchable: function() {
  var matchable = [
    this.get('firstName'),
    this.get('lastName'),
    this.get('email'),
    this.get('phone'),
  ];

  return matchable;
}

Cette méthode essaye de comparer chaque attribut du modèle avec l’expression passée en argument, sauf si celle ci est null, auquel cas on retourne toujours vrai.

Essayez dans une console de créer une collection de plusieurs modèles et de les filtrer à l’aide de la méthode filtered de la collection.

Voici un exemple :

var collection = new ContactBook();
collection.add({firstName: "John"})
collection.add({firstName: "Jane"})
_.each(collection.filtered(/ja/i), function(contact) {
  console.log(contact.get('firstName'));
})

Vous verrez apparaît uniquement le nom du second modèle (Jane) dans la console.

Ceci nous permettra par la suite d’élaborer un champ de recherche pour nos contacts.

Tri automatique

Une collection peut être triée à l’aide de la méthode sort, qui utilise la propriété comparator pour effectuer le tri.

Il faut cependant noter qu’on n’appelle que très rarement sort car dès lors qu’un comparateur est défini, la collection se trie automatiquement quand on lui ajoute un modèle.

La propriété comparator est une fonction prenant un ou deux arguments selon qu’on souhaite comparer selon une propriété du modèle (avec un argument) ou en effectuant une comparaison plus poussée entre deux modèles (avec deux arguments).

Dans notre cas, on trie simplement selon l’attribut firstName de nos modèles.

Là aussi, vous pouvez essayer cette fonctionnalité dans la console :

var collection = new ContactBook();
collection.add({firstName: "John"})
collection.add({firstName: "Jane"})
collection.each(function(contact) {
  console.log(contact.get('firstName'));
})

Vous verrez apparaître dans la console les prénoms dans l’ordre alphabétique, et non dans l’ordre d’insertion.

Sauvegarde

Les modèles de notre collection sont sauvegardés automatiquement lorsqu’on utilise create et non add pour les ajouter. On peut aussi sauvegarder manuellement toute la collection en utilisant sync.

Dans notre cas, la collection est stockée en utilisant localStorage, mais on peut très bien utiliser un serveur et des appels Ajax pour effectuer cette persistance.

Vues

Maintenant que nous avons mis en place nos données, passons aux vues.

Celles-ci vont nous permettre de faire interagir l’utilisateur et les données à l’aide d’évènements.

Backbone utilise jQuery ou un équivalent compatible (comme Zepto, que l’on a choisi ici) pour gérer la manipulation du DOM et des évènements.

Les vues sont une très bonne manière de structurer une application JavaScript en général, et vous allez voir qu’ici elles sont très puissantes.

Nous allons avoir besoin de 4 vues distinctes. Mettons tout d’abord en place le squelette HTML de notre application :

<h1>Contact Book</h1>

<div id="contact-book">
  <div class="grid">
    <div class="cell cell30" id="roster">
      <button id="new-contact">Add a contact</button>
      <input type="search" id="filter-contacts" placeholder="Search…" />

      <ul id="contact-list">
      </ul>
    </div>

    <div class="cell cell70">
      <div id="contact-details"></div>
    </div>
  </div>
</div>

L’interface est très simple et vous pouvez la voir dans votre navigateur.

La première vue que nous allons écrire est la vue décrivant l’application elle-même et son comportement global. Nous utiliserons des vues plus spécialisées pour les différents composants.

Voici le code de la vue principale, à placer dans js/views/app_view.js et à charger dans notre HTML :

var AppView = Backbone.View.extend({
  el: "#contact-book",

  events: {
    "click #new-contact": "newContact",
    "keyup #filter-contacts": "filterContacts",
  },

  initialize: function(collection) {
    this.collection  = collection;
    this.contactList = new ContactListView(collection);

    this.listenTo(this.contactList, 'select', this.selectContact);
  },

  newContact: function() {
    this.collection.create({firstName: 'Unnamed'})
    this.selectContact(this.collection.last());
  },

  selectContact: function(contact) {
    this.currentContact = contact;
    this.showContact(contact);
  },

  showContact: function(contact) {
    var view = new ContactView({model: contact});
    this.$("#contact-details").html(view.render().el);

    var input = view.$(".contact-firstName").get(0);
    input.focus();
    input.select();
  },

  filterContacts: function(ev) {
    var $elem = $(ev.currentTarget);

    this.contactList.filter($elem.val());
  }
});

Initialisation

Une propriété que nous n’avions pas encore vue est initialize. Elle est disponible pour tous les objets fournis par Backbone et permet d’exécuter du code à l’instanciation d’un objet.

C’est généralement très pratique dans les vues pour mettre en place des gestionnaires d’évènements, comme on le fait ici avec listenTo.

Cette instruction permet, lorsque la vue this.contactList génèrera l’évènement select, d’appeler la méthode selectContact avec les arguments envoyés avec l’évènement.

On utilise ici initialize pour stocker une référence à la collection, instanciée ailleurs, et créer une sous-vue contactList qui gèrera uniquement la partie listant les noms des contacts.

el

el est une propriété intéressante des vues. Elle indique à Backbone à quel élément elle doit attacher cette vue. Dans notre cas on s’attache au wrapper global de l’application #contact-book.

Cet élément, wrappé dans un objet jQuery, sera disponible dans les méthodes de notre vue en utilisant this.$el.

Évènements

On déclare une propriété events qui permet de lier un évènement jQuery sur un sélecteur à une méthode de notre vue.

Ici lorsqu’on cliquera sur le bouton identifié par #new-contact on appellera la méthode newContact, et de façon similaire un levé de touche dans le champ #filter-contacts appellera filterContacts.

Cette liaison est faire automatiquement de façon déclarative, et nous fait gagner beaucoup de temps.

Instanciation de vue

Dans la méthode showContact vous voyez qu’on instancie une vue de type ContactView (décrite un peu plus loin).

La ligne suivante :

this.$("#contact-details").html(view.render().el);

Nous permet de placer le HTML généré par cette vue dans l’élément identifié par #contact-details en remplaçant le contenu précédent.

Notez la méthode $ des vues qui permet d’obtenir un objet jQuery rattaché à l’élément racine de la vue (el). On l’utilise aussi sur l’instance de ContactView pour récupérer un élément de cette vue.

Ces mécanismes nous permettent de découper finement et de façon modulaire nos vues afin d’avoir des composants réutilisables et spécialisés.

Liste de contacts dynamique

Passons maintenant aux vues ContactListView et ItemView, à placer respectivement dans js/views/contact_list_view.js et js/views/item_view.js.

Détaillons tout d’abord ItemView :

var ItemView = Backbone.View.extend({
  events: {
    "click": "select"
  },

  tagName: "li",

  initialize: function() {
    this.listenTo(this.model, 'change',  this.render);
    this.listenTo(this.model, 'destroy', this.remove);
  },

  render: function() {
    this.$el.html(this.model.fullName());

    return this;
  },

  select: function() {
    this.trigger("select", this.model);
  },
});

On retrouve ici les propriétés initialize et events vues précédemment.

Vous notez cependant qu’on n’a pas de propriété el mais une propriété tagName.

En effet, ici Backbone créera automatiquement un élément de type <li> à chaque instanciation de la vue. Cet élément sera disponible via la propriété el par la suite (comme c’est le cas si on définit explicitement celle-ci).

Cette vue observe les changements du modèle et appelle render à chaque modification (évènement change) et remove lorsqu’on détruit le modèle (évènement destroy).

render met à jour le contenu de l’élément li en utilisant fullName sur le modèle.

remove est fourni par Backbone et supprime la vue.

select génère un évènement select avec le modèle en argument. Cet évènement sera passé à tout objet l’observant, en l’occurrence ici la liste de contacts que nous décrivons ci-après.

Ce code très simple nous permet d’avoir des éléments de liste dynamiques.

Passons maintenant à une vue plus complexe, celle de la liste de contacts :

var ContactListView = Backbone.View.extend({
  el: "#contact-list",

  initialize: function(collection) {
    this.collection = collection;

    this.listenTo(this.collection, 'add',              this.addOne);
    this.listenTo(this.collection, 'change:firstName', this.refresh);
    this.listenTo(this.collection, 'reset',            this.addAll);

    this.collection.fetch({reset: true});
  },

  refresh: function() {
    this.collection.sort();
    this.$el.html("");
    this.addAll();
  },

  addOne: function(contact) {
    var item = new ItemView({model: contact});
    this.listenTo(item, 'select', this.selectContact);

    this.$el.append(item.render().el);
  },

  addAll: function() {
    _.each(this.collection.filtered(this.filterExpr), function(contact) {
      this.addOne(contact)
    }, this);
  },

  selectContact: function(contact) {
    this.trigger('select', contact);
  },

  filter: function(text) {
    if (text.length != 0) {
      this.filterExpr = new RegExp(text, "i");
    } else {
      this.filterExpr = null;
    }
    this.refresh();
  },

  filterExpr: null,
});

On a ici beaucoup plus de code mais très peu de nouvelles fonctionnalités.

La méthode refresh nous permet de reconstruire toute la liste en la triant auparavant. En effet, les collections maintiennent l’ordre uniquement lors de l’insertion de nouveaux modèles, pas lors de leur modification. On doit donc appeler sort explicitement.

On utilise aussi refresh pour reconstruire la liste lorsque l’utilisateur modifie le motif de recherche dans l’interface.

addOne et addAll permettent respectivement d’ajouter un élément en fin de liste ou de tous les ajouter successivement.

Notez aussi la façon dont on capture les évènements select des ItemView créées pour les ré-émettre. Ils seront capturés par notre vue principale AppView pour afficher un contact sélectionné dans la liste.

Templating

Passons à la dernière vue, ContactView.

Pour celle-ci, censée représenter un formulaire permettant de noter les informations relatives à un contact, nous avons besoin d’une structure beaucoup plus complexe que pour les ItemView.

Pour cela, nous allons utiliser un template Handlebars. On place celui-ci avant le chargement des dépendances JavaScript :

<script type="text/template" id="contact-template" charset="utf-8">
  <div class="grid">
    <div class="cell cell50">
      <input type="text" value="" placeholder="First name" class="contact-firstName" />
    </div>
    <div class="cell cell50">
      <input type="text" value="" placeholder="Last name" class="contact-lastName" />
    </div>
    <div class="cell">
      <input type="text" value="" placeholder="Email" class="contact-email" />
    </div>
    <div class="cell">
      <input type="text" value="" placeholder="Phone number" class="contact-phone" />
    </div>
    <div class="cell">
      <button class="remove-contact">Remove</button>
    </div>
  </div>
</script>

Utiliser un template nous évite d’avoir à générer le DOM manuellement (avec createElement par exemple). Beaucoup d’efforts en moins donc.

Voici le code de ContactView :

var ContactView = Backbone.View.extend({
  tagName: 'div',

  className: 'contact',

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

  events: {
    "click .remove-contact": "destroy",
    "keyup input": "update",
  },

  initialize: function() {
    this.listenTo(this.model, 'destroy', this.remove);
  },

  render: function(arg, args) {
    this.$el.html(this.template(this.model.toJSON()));

    return this;
  },

  update: function(ev) {
    var $elem      = $(ev.target),
        attribute = $elem.attr('class').replace('contact-', ''),
        update    = {};

    update[attribute] = $elem.val();

    this.model.save(update);
  },

  destroy: function() {
    this.model.destroy();
  }
});

On retrouve tagName, accompagné de className qui permet de donner une classe automatiquement à l’élément tagName créé.

Là encore, events et initialize sont assez classiques par rapport à ce que l’on a vu précédemment.

Intéressons nous à render. Cette dernière insère le contenu du template défini par template dans l’élément <div> créé par Backbone. Ce template est obtenu en compilant le contenu du tag #contact-template avec Handlebars.

Ce mécanisme permet de très facilement changer de mécanisme de templates car Backbone n’impose rien.

On passe comme argument à notre template notre modèle serialisé. Attention ici, toJSON ne renvoie pas une chaîne de JSON, mais le modèle serialisé sous forme d’objet (que l’on peut convertir en chaîne avec JSON.stringify).

En ce qui concerne le reste de la vue, le callback update met à jour l’attribut dans le modèle et sauvegarde celui-ci, et destroy détruit le modèle sous-jacent.

Notez que this.model est défini lorsqu’on crée la vue dans AppView en passant en argument {model: contact} au constructeur ContactView.

L’application terminée

Maintenant que tout le code est en place, ouvrez l’application dans votre navigateur et observez comment Backbone permet de créer des applications dynamiques :

  • Le clic sur un nom de contact affiche son profil automatiquement ;
  • La modification d’un champ dans le profil du contact est automatiquement répercutée, notamment dans la liste ou son nom est modifié dynamiquement ;
  • Les contacts sont automatiquement triés. Essayez de changer les prénoms pour observer ce comportement plus précisément ;
  • La suppression d’un contact entraîne sa suppression dans la liste ;
  • Le filtrage permet de restreindre la liste de contacts selon une expression régulière testée sur chaque attribut du contact ;
  • Grâce à la persistance, vous pouvez fermer l’onglet (et même le navigateur), puis le réouvrir et retrouver tous vos contacts.

La suite

Nous avons vu dans cet article les bases d’une application Backbone, sans trop de complexité et sans interaction avec le serveur.

On voit cependant déjà à quel point l’interactivité fonctionne et comment le développeur peut très facilement construire une application très dynamique avec Backbone.

Dans les prochains articles concernant Backbone, nous aborderons la mise en place de routeurs pour séparer notre application en plusieurs modules, et nous mettrons en place une interaction avec le serveur pour persister les données.

  1. L’astérisque symbolisant soit C pour Controller soit VM pour View-Model. Deux approches différentes du développement d’applications JavaScript.

  2. Zepto est un équivalent plus léger de jQuery qui fonctionne parfaitement avec Backbone.