Après l'article précédent qui a détaillé les principaux concepts d'Ember, il est grand temps de se dégourdir les doigts. Comme on a peur de rien, on va même construire progressivement ensemble une application avec Ember.
Bon, faut pas non plus trop vous emballer, c'est juste une application bateau de gestion d'une collection de BD. Ça pourrait être des clients, des légumes ou des timbres mais là, c'est des BD.
L'article précédent a insisté sur le fait qu'Ember était un framework avec des partis pris forts et un modèle de développement structurant.
Note : sur ce sujet, vu que ça fait réagir, je précise. Je ne dis pas que c'est bien ou que ce n'est pas bien. Je dis juste que c'est une caractéristique importante d'Ember. Autant le savoir.
Eh bien, aussi étonnant que cela puisse paraître, Ember nous laisse quand même nous débrouiller tout seul comme des grands pour organiser nos applications.
Il existe donc différentes manières de structurer une application Ember, de la plus basique à la plus complète et, sur ce point, chacun pourra trouver ce qui conviendra le mieux à ses goûts, ses envies, ses contraintes, son contexte... Que sais-je ? Ci-dessous, quelques exemples parmi d'autres.
De base, avec Ember on peut donc déclarer nos routes, contrôleurs,
modèles, etc. dans un seul fichier JavaScript ou dans une balise <script>
. On
doit par contre impérativement respecter les
conventions de nommage
et enregistrer nos objets dans une variable globale :
App = Ember.Application.create();
App.Router.map(function() {
...
});
App.BookRoute = Ember.Route.extend({
...
});
De la même manière, on peut déclarer nos gabarits
Handlebars via des balises <script>
:
<script type="text/x-handlebars"></script>
Comme on peut trouver ça un peu limité dans la vraie vie, on peut essayer
d'organiser nous-même notre application, nos fichiers, gérer des modules, etc.
Tout ça va passer par l'utilisation d'un outil de build javascript de type
Grunt, Gulp, Broccoli.
Ces outils vont nous permettre de concaténer nos différents fichiers JavaScript
en un seul, de sortir les gabarits dans des fichiers .hbs
et de les
précompiler. On n'aura ensuite qu'à importer ces fichiers dans notre index.html
:
...
<script src="dist/libs/handlebars.min.js"></script>
<script src="dist/libs/ember.js"></script>
<script src="dist/application.js"></script>
<script src="dist/templates.js"></script>
...
Ça peut convenir parfaitement et on peut se mitonner des phases de build aux petits oignons pour nos besoins spécifiques. Mais on peut aussi rester un peu sur sa faim. Surtout si on a choisi Ember pour son côté structurant.
Du coup, une partie des membres de l'équipe Ember a mis au point Ember CLI.
Ember CLI est une interface en ligne de commande pour Ember. Elle repose sur l'outil de build Broccoli et permet :
Je ne vais pas vous détailler davantage le truc, vous trouverez vous-même la doc officielle. Et puis, on va tout de suite le mettre en pratique.
Note : là encore, Ember CLI, c'est un parti pris. Ce sera probablement très bien accueilli par certains pour qui cela offre un cadre de travail structuré et structurant. Mais ce sera aussi rejeté par d'autres qui le verront comme une grosse machine inutile. Ici encore, question de goût, question de contexte, question de besoins.
Trèves de bavardages, on s'y met sérieusement :
On installe Node, Ember CLI, Bower :
$ npm install -g ember-cli
$ npm install -g bower
Ça y est, on peut maintenant demander gentiment à Ember CLI de nous
créer notre application grâce à la commande ember
et voir ensuite une
magnifique page de bienvenue sur http://localhost:4200/ :
$ ember new ember-articles
$ cd ember-articles
$ ember server
Je ne vous fais pas l'affront de détailler ici la structure de l'application, tout est décrit dans la documentation.
Pour que cela ne soit pas trop moche dans le cadre de cet exemple ou va ajouter un peu de CSS et des fonts mais comme c'est pas l'objet de l'article, on ne va pas passer de temps là-dessus. Ceci dit, comme il y a quand même un peu de conf Ember CLI qui peut vous intéresser, vous avez les styles et la conf sur GitHub et l'explication dans ce gist.
Note: le code de l'exemple est dispo sur github.
/app/templates/application.hbs
:<div class="app">
<a class="sources" href="https://github.com/bmeurant/ember-articles"
>View source on GitHub</a
>
<h1>Comic books library</h1>
<div class="main">
{{outlet}}
</div>
</div>
Note : on en profite pour remarquer le rechargement à chaud via ember server
lorsqu'on modifie un fichier.
/series
via ember
:$ ember generate route series
Ember CLI met à jour le routeur :
// routeur app/router.js
Router.map(function() {
this.route("series");
});
Il génère aussi pour nous une route app/routes/series.js
et un gabarit
app/templates/series.hbs
, vides.
Modifions tout de suite le gabarit app/templates/series.hbs
, histoire de mieux
visualiser les choses :
<h2>Comics Series</h2>
L'URL /series
est désormais accessible sur http://localhost:4200/series et on
peut constater l'imbrication du gabarit series.hbs
dans le gabarit général
application.hbs
grâce à son {{outlet}}
.
{{outlet}}
et routes imbriquéesCes notions sont au cœur d'Ember. Leur fonctionnement est assez simple.
Lorsqu'une route est imbriquée dans une autre, Ember va rechercher les
gabarits de ces deux routes et remplacer la zone {{outlet}}
de la route mère
avec le rendu de la route fille. Ainsi de suite jusqu'à résolution complète de
la route. Lors des transitions entre routes, les zones des {{outlet}}
concernées par le changement sont mises à jour.
Toutes les routes de l'application sont imbriquées dans la route
ApplicationRoute
générée par Ember et dont le gabarit est
application.hbs
. C'est ce qui explique que, dans le cas présent, le gabarit
series.hbs
ait été injecté dans application.hbs
pour construire
l'application entière.
Pour rappel, Ember définit et utilise différents types d'objets ayant chacun une responsabilité propre (voir article précédent) et, pour ne pas nous obliger à fournir nous-même une implémentation par défaut de ces objets, les génère pour nous (voir article précédent - Génération d'objets).
Si nous n'avons eu ici qu'à fournir le gabarit application.hbs
, c'est
qu'Ember a généré pour nous la route implicite ApplicationRoute
activée au démarrage de l'application et le contrôleur ApplicationController
.
Mais Ember a également généré pour nous la route IndexRoute
et le
contrôleur IndexController
en réponse à l'URL /
. Pour être tout à fait
complet, Ember a aussi généré les éléments suivants LoadingRoute
,
LoadingController
, ErrorRoute
et ErrorController
dont les caractéristiques
peuvent être trouvées dans la
documentation.
Ces éléments implicites sont générés pour chaque route qui n'est pas une route de dernier niveau et peuvent être surchargés.
/
ne nous intéresse pas. Surchargeons la route IndexRoute
pour
rediriger vers /series
:// /app/routes/index.js
import Ember from "ember";
export default Ember.Route.extend({
redirect: function() {
this.transitionTo("series");
},
});
-> Par-là la doc sur les redirections.
Maintenant, on veut afficher la liste des séries en allant sur /series
. Encore
faut-il avoir des séries... Pour ça, on va utiliser la librairie Ember
Data pour la gestion de nos modèles. Ce n'est pas obligatoire et
beaucoup font sans, mais nous on va l'utiliser quand même.
Cette librairie qui est développée en parallèle d'Ember permet de gérer les modèles de données et les relations entre eux à la manière d'un ORM (à la ActiveRecord). Elle permet notamment de récupérer les données depuis une interface REST HTTP (et est parfaitement adaptée à JSON API) mais pas que.
Ember Data s'appuie sur un store
(cf.
doc) manipulé par
l'application et qui contient des méthodes telles que find
, createRecord
,
update
, etc. qui permettent d'effectuer des actions sur les différents modèles
du store. Au travers d'Adapters
, le store
transmet à la couche de
persistence (REST ou autre).
Le RESTAdapter
(cf. doc)
et son jumeau maléfique le RESTSerializer
(cf.
doc) peuvent être
étendus facilement de manière à s'adapter à une interface REST spécifique.
Pour un POC, on peut utiliser
le FixtureAdapter
(cf.
doc) qui permet de
charger simplement les objets depuis la mémoire. C'est ce que l'on utilise ici.
// /app/adapters/application.js
import DS from "ember-data";
export default DS.FixtureAdapter.extend({});
// /app/models/series-item.js
import DS from 'ember-data';
var SeriesItem = DS.Model.extend({
title : DS.attr('string', {defaultValue: 'New Series'}),
scriptwriter : DS.attr('string'),
illustrator : DS.attr('string'),
publisher : DS.attr('string'),
coverName : DS.attr('string', {defaultValue: 'default.jpg'}),
summary : DS.attr('string'),
coverUrl: function() {
return '/assets/images/series/covers/' + this.get('coverName');
}.property('coverName')
});
SeriesItem.reopenClass({
FIXTURES: [{
id: 1,
title: 'BlackSad',
scriptwriter: 'Juan Diaz Canales',
illustrator: 'Juanjo Guarnido',
publisher: 'Dargaud',
coverName: 'blacksad.jpg',
summary: 'Private investigator John Blacksad is up to his feline ears in mystery, digging into the backstories behind murders, child abductions, and nuclear secrets. Guarnido\'s sumptuously painted pages and rich cinematic style bring the world of 1950s America to vibrant life, with Canales weaving in fascinating tales of conspiracy, racial tension, and the "red scare" Communist witch hunts of the time. Guarnido reinvents anthropomorphism in these pages, and industry colleagues no less than Will Eisner, Jim Steranko, and Tim Sale are fans! Whether John Blacksad is falling for dangerous women or getting beaten to within an inch of his life, his stories are, simply put, unforgettable'
}, {
id: 2,
title: 'The Killer',
scriptwriter: 'Luc Jacamon',
illustrator: 'Matz',
publisher: 'Casterman',
coverName: 'the-killer.jpg',
summary: 'A man solitary and cold, methodical and unencumbered by scruples or regrets, the killer waits in the shadows, watching for his next target. And yet the longer he waits, the more he thinks he\'s losing his mind, if not his cool. A brutal, bloody and stylish noir story of a professional assassin lost in a world without a moral compass, this is a case study of a man alone, armed to the teeth and slowly losing his mind.'
}, ...
]});
export default SeriesItem;
Au passage, on remarque les valeurs par défaut ainsi que la propriété
calculée coverUrl
. On aura l'occasion d'en reparler.
SeriesRoute
, la liste des séries :// /app/routes/series.js
import Ember from "ember";
export default Ember.Route.extend({
model: function() {
return this.store.find("seriesItem");
},
});
<!-- /app/templates/series.hbs -->
<div class="series">
<h2>Comic Series</h2>
<ul class="series-list">
{{#each}}
<li class="series-item">
{{title}}
</li>
{{/each}}
</ul>
<span>Number of series: {{length}}</span>
</div>
{{outlet}}
On remarque le {{#each}}
sans arguments qui par convention retrouve l'objet
model
du contrôleur. {{#each model}}
, {{#each controller}}
ou
{{#each controller.model}}
sont des notations équivalentes.
<!-- /app/templates/series.hbs -->
...
<h2>Comic Series</h2>
<button {{action "sort"}} {{bind-attr class=":sort sortAscending:asc:desc"}}></button>
<ul class="series-list">
...
Pour ça, il est nécessaire de définir notre propre SeriesController
:
// /app/controllers/series.js
import Ember from "ember";
export default Ember.ArrayController.extend({
sortAscending: true,
actions: {
sort: function() {
this.toggleProperty("sortAscending");
},
},
});
Ce simple ajout demande de s'arrêter sur quelques points importants, histoire de bien comprendre ce qui se passe.
Nous avons dû remplacer le SeriesController
implicite par notre propre
contrôleur, histoire de proposer cette fonction de tri. Ember dispose
de deux types de contrôleurs :
ObjectController
et ArrayController.
Comme leur nom l'indique, ces contrôleurs permettent respectivement de gérer des
modèles de type objet ou de type collection.
Dans notre cas, nous souhaitons manipuler la liste des séries et utilisons donc
un ArrayController
. Il utilise notamment le
SortableMixin qui
fournit des fonctions de tri natives. Il nous suffit donc d'initialiser et de
mettre à jour la propriété sortAscending
apportée par ce mixin pour bénéficier
du tri sur notre collection de séries, sans avoir besoin d'autre chose.
La mise à jour de cette propriété est effectuée grâce à {{action "sort"}}
qui
nous permet de lier l'action sur le bouton (le clic) à une fonction sort
du
contrôleur définie dans actions: { ... }
.
-> Plus d'infos sur les actions ici.
On remarque aussi la manière dont les classes de l'élément button
sont liées
aux propriétés du contrôleur de manière à être mises à jour dynamiquement grâce
à {{bind-attr class="..."}}
. Cette syntaxe permet de basculer la classe du
bouton de asc
à desc
automatiquement lorsque la valeur de sortAscending
change. On note la notation :sort
qui permet d'ajouter une classe de base,
statique.
-> Plus d'infos sur les classes ici.
<!-- /app/templates/series.hbs -->
<div class="series">
<h2>Comic Series</h2>
{{input value=filter class="filter"}}
<button {{action "sort"}} {{bind-attr class=":sort sortAscending:asc:desc"}}></button>
<ul class="series-list">
{{#each filteredModel}}
<li class="series-item">
{{title}}
</li>
{{/each}}
</ul>
<span>Number of series: {{filteredModel.length}}</span>
</div>
{{outlet}}
// /app/controllers/series.js
...
filter: "",
sortAscending: true,
filteredModel: function() {
var filter = this.get('filter');
return this.get('content').filter(function(item){
if (item.get('title') === undefined) {
return true;
}
return item.get('title').toLowerCase().match(new RegExp(filter.toLowerCase()));
}).sort(function(a, b) {
return this.get('sortAscending') ? (b.get('title') < a.get('title')) : (b.get('title') > a.get('title'));
}.bind(this));
}.property('filter', 'sortAscending', 'model.@each.title'),
actions: {
...
Je vous passe le contenu de la fonction filteredModel
qui n'apporte rien au
sujet. Examinons par contre la notation
.property('filter', 'sortAscending', 'model.@each.title')
. Cela constitue la
définition d'une propriété calculée : propriété accessible et
manipulable comme n'importe quelle propriété au sein des gabarits mais qui est
le résultat d'une fonction dont le retour dépend de l'état d'autres propriétés.
La syntaxe .property('filter', 'sortAscending', 'model.@each.title')
définit
les autres propriétés observées par cette propriété calculée et dont le
changement provoquera l'exécution de la fonction ainsi que le rafraîchissement
du gabarit. Ici, on peut constater que l'affichage est mis à jour et la liste
filtrée à chaque changement du champ de formulaire filter
et donc de la
propriété filter
associée ou du sens du tri via la propriété sortAscending
.
La syntaxe particulière model.@each.title
permet de mettre à jour l'affichage
en cas de changement externe du titre de l'une (@each
) des séries. Vous pouvez
facilement vous rendre compte de ça en utilisant Ember Inspector
(Chrome
et Firefox).
Allez dans Data > MODEL TYPES / series-item
, sélectionnez-en une et modifiez
son titre. Vous constaterez que la liste est mise à jour automatiquement.
Les bindings et les propriétés calculées constituent deux manières d'observer les changements et de rafraîchir l'application en conséquence. Les observeurs en sont une troisième. Si vous avez un doute sur ce qu'il faut utiliser, allez voir ici.
Alors, comment ça marche ? Comment, concrètement, Ember se débrouille
pour mettre à jour le gabarit lors de la mise à jour d'un modèle, d'une
propriété ? En réalité, lorsqu'on affiche dans un gabarit une propriété
dynamique liée à un modèle ou à une propriété, Ember va l'encadrer par
des éléments HTML spéciaux, des marqueurs de type <script>
d'id unique appelé
metamorph
. Attention, je préfère vous prévenir, ça va piquer !
Ainsi,
<h1>Blog de {{name}}</h1>
va se transformer en :
<h1>
Blog de
<script id="metamorph-0-start" type="text/x-placeholder"></script>
Baptiste Meurant
<script id="metamorph-0-end" type="text/x-placeholder"></script>
</h1>
Alors oui, il faut avouer que là on est tenté de partir en courant. C'est le
point qui m'a vraiment gêné quand j'ai découvert Ember et ça me gêne
encore. C'était même à la limite du rédhibitoire. Ça pollue vraiment le DOM et
introduit même quelques effets de bord en CSS lorsqu'on utilise les
:first-child
ou :last-child
. Ceci étant, c'est ce qui permet à
Ember de mettre à jour non pas un gabarit mais uniquement ces zones
dynamiques de manière performante - j'ai fini par voir ça comme un mal
nécessaire. Mais surtout, j'ai compris que ces metamorph
étaient voués à
disparaître assez rapidement avec l'utilisation du moteur HTMLBars.
Vous pouvez jeter un œil à ce sujet à la
présentation d'Eric Bryn (notamment slide
10). Ouf ! Le support d'HTMLBars est prévu pour Ember 1.9
ou 1.10 (la release actuelle est 1.7) ... On est impatients !
Bonus: l'élément est un élément script
et pas un autre car c'est à priori le
seul élément qui peut être inséré partout sans rien casser.
Edit: Aujourd'hui (28/10/2014) est sortie la
version 1.8.0 d'Ember.
Cette release ne contient pas encore le support complet d'HTMLBars
mais signe déjà la fin des metamorph
au profit de l'utilisation d'élements
Text
vides, non intrusifs ! Ça méritait d'être signalé.
Un autre mécanisme important est impliqué tant dans le rendu des gabarits que
dans le calcul et la synchronisation des propriétés entre elles : la RunLoop.
Ce mécanisme est absolument central dans le fonctionnement d'Ember et
s'appuie sur la lib Backburner. Dans
la plupart des cas, vous n'avez pas à vous en préoccuper et vous pouvez
parfaitement mettre en place une application Ember complète sans
interagir directement avec la RunLoop, sauf lorsque vous ajoutez vos propres
helpers
Handlebars ou vos propres composants
avancés. C'est par contre important d'en comprendre le fonctionnement.
Comme son nom ne l'indique pas, la RunLoop n'est pas une loop mais un ensemble
de queues permettant à Ember de différer un certain nombre d'opérations
qui seront ensuite exécutées en dépilant ces queues dans un ordre de priorité
donné. Les queues sont sync
, actions
, routerTransitions
, render
,
afterRender
, et destroy
. Je vous laisse découvrir par vous-même dans la
doc officielle et
dans cette
présentation d'Eric Bryn
le contenu de ces queues et la manière dont est faite l'exécution.
Je voudrais juste insister sur un aspect particulier : c'est ce mécanisme qui permet, en quelque sorte, d'empiler les calculs de propriétés calculées lorsque les propriétés observées sont modifiées et surtout c'est grâce à ce mécanisme que le rendu n'est effectué qu'une seule fois lors de la modification d'un modèle.
Pour reprendre l'exemple de la doc officielle, si vous avez l'objet suivant :
var User = Ember.Object.extend({
firstName: null,
lastName: null,
fullName: function() {
return this.get("firstName") + " " + this.get("lastName");
}.property("firstName", "lastName"),
});
Et le gabarit :
{{firstName}} {{fullName}}
Tout ça, sans la RunLoop, ferait qu'on exécuterait le rendu deux fois si l'on
modifie successivement firstname
puis lastname
. La RunLoop met tout ça (et
plein d'autres choses) en queue et n'effectue le rendu qu'une seule et unique
fois, lorsque nécessaire.
// /app/router.js
Router.map(function() {
this.resource("series", function() {
this.route("seriesItem", { path: "/:seriesItem_id" });
});
});
<!-- /app/templates/series.hbs -->
...
<ul class="series-list">
{{#each filteredModel}}
<li class="series-item">
{{#link-to 'series.seriesItem' this title=title}} {{title}} {{/link-to}}
</li>
{{/each}}
</ul>
...
<!-- /app/templates/series/series-item.hbs -->
<div class="series-details">
<h3>{{title}}</h3>
<img
{{bind-attr
src="coverUrl"
}}
alt="Series's first album cover"
class="cover"
/>
<dl class="description">
<dt>scriptwriter</dt>
<dd>{{scriptwriter}}</dd>
<dt>illustrator</dt>
<dd>{{illustrator}}</dd>
<dt>publisher</dt>
<dd>{{publisher}}</dd>
</dl>
<p class="summary">
{{summary}}
</p>
</div>
Et voilà ! Quelques remarques en passant :
{{link-to}}
. On remarque au passage qu'Ember sélectionne
automatiquement (ajoute une classe active
) la série de la liste dont la
route est activée. On note également l'utilisation de this
pour référencer
l'objet courant (ici l'instance courante de SeriesItem
). ->
doc officielle.series
en Resource qui permet de grouper les
routes sous un même espace de nommage. Notez que si la route seriesItem
avait été une ressource, on aurait dû fournir le template
/app/templates/series-item.hbs
au lieu de
/app/templates/series/series-item.hbs
car une ressource réinitialise
l'espace de nommage et permet ainsi de simplifier les URL. ->
doc officielle{path: '/:seriesItem_id'}
à la route
seriesItem
pour l'ID de la série. ->
doc officielle.Cet article est un peu plus long que ce que j'avais prévu et je n'ai pas abordé tous les sujets que je voulais traiter. Mais, plutôt que de dérouler simplement du code pour montrer que ça marche, j'ai préféré m'arrêter sur les points importants pour en expliquer le fonctionnement. Ça me paraissait important. J'espère que ce n'était pas trop pénible à lire. Les points que je n'ai pas eu le temps de traiter (API REST avec un backend, tests, helpers, partials, composants, relations avec ember-data, etc.) feront peut-être l'objet d'un autre post mais un peu plus tard parce que je suis fatigué là et je sens que vous aussi.
Concernant Ember, j'apprécie vraiment le modèle de développement, la structure et j'aime vraiment développer avec cet outil. La discussion framework / lib déjà évoquée fera sans doute toujours rage. En ce qui me concerne, quand j'ai besoin d'un framework, ma préférence va à Ember.
Concernant Ember CLI, je suis plus partagé. J'apprécie l'aspect normalisation de la structure de l'appli ainsi que l'outillage assez fourni qu'il embarque, le transpileur ES6. Je ne suis, par contre, pas fan du scaffolding en général mais, au démarrage, ça peut donner une idée de la manière de faire. J'espère cependant vous avoir donné suffisamment de clefs pour que vous vous fassiez une idée.
Pour finir, je souhaite remercier l'équipe de Putain de code ! qui ne partage pas mes opinions sur les frameworks en général mais qui m'accueille quand même. Cet article a vraiment dû vous piquer les yeux. Désolé :-)
Note: les sources de l'application exemple sont disponibles sur github.