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.
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 :
doctype
, head
, body
, …) ;<!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.
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
.
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.
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.
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.
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.
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());
}
});
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
.
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.
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.
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.
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
.
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 :
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.