Ember.js, framework challenger pour les Single Page Applications

le 01/07/2014 par Florent Jaby
Tags: Software Engineering

On parle souvent en ce moment (et nous les premiers, parce qu'on en fait souvent aussi) de Single Page Applications (SPA), ces application web riches et interactives à base de JavaScript et d’API. Le framework leader du moment pour la réalisation de telles applications est AngularJS, porté par Google, très populaire parmi les développeurs.

Cependant, il existe une quantité d’autres frameworks dont le but est de faciliter la réalisation de SPA. Ils varient en périmètre fonctionnel, en philosophie, en adoption et parfois même en langage utilisé (un bon aperçu ici http://todomvc.com/). Un des plus gros challengers d’AngularJS sur son terrain de prédilection est Ember.js. Ces deux frameworks proposent des outils fonctionnellement similaires pour réaliser des SPA, tout en utilisant des approches très différentes.

C’est justement l’approche qu’adopte Ember.js dont nous allons discuter dans cet article, en se focalisant bien sûr sur les aspects qui nous ont le plus marqués lors de nos projets réalisés avec Ember.js.

Structure imposée, la convention plutôt que la configuration

Ember.js fait beaucoup de choix pour nous. C’est la fameuse philosophie convention over configuration chère au framework Ruby on Rails à l’origine. Que ce soit au niveau des routes, des contrôleurs, des vues et des templates, Ember.js suit une convention de nommage très stricte et à la fois très intuitive qui ne laisse aucune place au doute sur la responsabilité de tel ou tel composant.

C’est d’ailleurs aussi par ce mécanisme qu’est gérée la création de singletons et l’injection de dépendance. Les classes à injecter sont trouvées par leur nom et il est alors possible de préciser dans quels types de composants (contrôleur, routeur, etc.) elles doivent être injectées.

Un exemple de la documentation officielle donne un aperçu des conventions de nommage :

URLRoute NameControllerRouteTemplate
/indexApp.IndexControllerApp.IndexRouteindex
N/ApostApp.PostControllerApp.PostRoutepost
/post/:post_id2post.indexApp.PostIndexControllerApp.PostIndexRoutepost/index
/post/:post_id/editpost.editApp.PostEditControllerApp.PostEditRoutepost/edit
N/AcommentsApp.CommentsControllerApp.CommentsRoutecomments
/post/:post_id/commentscomments.indexApp.CommentsIndexControllerApp.CommentsIndexRoutecomments/index
/post/:post_id/comments/newcomments.newApp.CommentsNewControllerApp.CommentsNewRoutecomments/new

Comme vous le voyez, cela peut rapidement demander un nombre de classes important pour faire des actions pourtant simples. En réalité il est possible de s'en tirer en n'écrivant que le fichier de template, une fois que la route est configurée. Lorsqu'Ember.js recherche des routes et des contrôleurs en fonction de l'URL demandée, et si aucune classe ne correspond au nom prévu, une route et/ou un contrôleur avec des comportements par défaut sont instanciés. Ce comportement, appelé Active Generation, permet de limiter le code à écrire lorsqu'on suit les comportements par défaut. Lorsqu'il devient nécessaire de modifier ces comportements, alors on peut s'aider de l'outil d'inspection Ember-Inspector pour s'aider à trouver le nom des classes à implémenter.

De plus, lorsque Ember-Data (la couche de persistence des modèles) est utilisé, la convention est poussée jusqu'au modèle qui sera recherché en fonction du nom du paramètre et de la route. Par exemple pour une route /users/:user_id, sans rien ajouter au comportement par défaut, l'utilisateur correspondant à l'id donné dans l'URL sera recherché par Ember-Data et disponible directement dans le template !

URL-driven architecture

Dans cette continuité, Ember.js encourage très fortement à organiser ses fichiers en fonction de la structure d'URL de son application. En réalité, la philosophie d'Ember.js est que toute interaction ou état doit être reflété dans l'URL et vice versa. Il y a une correspondance 1:1 entre les deux. C'est selon Tom Dale (le fondateur) la base de toute application web qui se respecte, puisque c'est sa vraie plus-value par rapport à une application native.

exemples d’URL :

/app/vols/32/avion/place/43 /groups/ /groups/3 /groups/3/users/ /groups/3/authorizations

Et l'organisation du code qui devrait en découler :

/templates/application.hbs /templates/groups.hbs /templates/groups/group.hbs /templates/groups/group/authorizations.hbs /templates/groups/group/users.hbs /controllers/GroupsController.js contient la classe ‘GroupController’ /controllers/GroupsGroupController.js contient la classe ‘GroupsGroupController’

Là où AngularJS est de base peu contraignant sur la structure des URL et la structure du code au sein de l’application (typiquement il faut utiliser le routeur ui-router pour avoir des états dans les URL, et mettre en place soi-même des bonnes pratiques de structuration des vues et contrôleurs dans l’application), Ember.js impose un modèle REST.

Ember.js se base sur les noms des classes des contrôleurs pour les lier aux vues et aux URL. La convention porte donc uniquement sur le nom des classes, mais en pratique la plupart des développeurs Ember.js utilisent aussi le nom des classes comme noms de fichiers. Il est souvent intéressant de se baser sur un projet Seed pour démarrer un projet. Ember-App-Kit est un de ces projets, qui vise à structurer son code applicatif, ses tests et sa compilation de fichiers statiques en utilisant les outils à la pointe. On y retrouve Grunt, Less, Karma, PhantomJS et le système de module d'ECMAScript 6.

De plus, Ember.js vous encourage à être cohérent avec vos URL et votre interface. En effet, en suivant les conventions d'Ember.js, l'imbrication des fragments d'URL impliquent une imbrication des vues dans l'interface utilisateur. Comme dans l'image ci dessous, pour une URL du type /category/:category/posts, la partie "/category/:category" servira à déterminer quel route, contrôleur et template utiliser pour la partie violette, alors que la partie "/posts" servira à déterminer les mêmes informations pour la partie verte, imbriquée dans la partie violette.

En pratique, on perçois visuellement l'imbrication des routes lorsqu'on les déclare, en miroir avec l'imbrication visuelle de l'interface.

App.Router.map(function() {
  this.resource('post', { path: '/post/:post_id' }, function() {
    this.route('edit');
    this.resource('comments', function() {
      this.route('new');
    });
  });
});

Cette approche présente plusieurs avantages :

  • Les URL de l’application seront forcément très expressives, garantissant par exemple la possibilité de mettre des favoris sur chaque vue de l’application
  • L'historique est géré nativement par le navigateur sans intervention outre mesure du framework
  • Il est facile de se greffer aux changements d'URL pour y ajouter un système de suivi tel que Google Analytics, en minimisant le risque de manquer des actions de l'utilisateur
  • Il existe une cohérence naturelle entre l'URL, l'interface et les fichiers impliqués qui permet de s'y retrouver aisément.

La règle de pouce (comme ne disent absolument pas les anglo-saxons) c'est : Si votre vue est imbriquée, alors votre URL est tout autant composée, et vice versa.

Modèle objet et utilisation de classes

Ember.js permet de définir un modèle d’objets, comme dans les langages typés.

Cela permet de mutualiser du code via l’héritage de classes et de typer les objets, améliorant la robustesse du code.

Définir des classes en Ember.js et instancier des objets se rapproche plus des langages traditionnels comme Ruby ou Java, où les héritage de propriétés et de méthodes se font au travers d’une arborescence statique de classes.

Il est probablement plus facile à aborder pour des développeurs étrangers au JavaScript par rapport au principe d’héritage prototypal natif de JavaScript.

Cela permet aussi, lorsqu’on utilise Ember-Data, de définir des modèles avec des comportements particuliers, une stratégie de persistance adaptée et une sérialisation adéquate pour les requêtes.

var Vehicle = Ember.Object.extend({
  brand: null,
  commonName: 'vehicle'
  commonNamePlural: function () {
    return this.get('commonName') + 's';
  }.property('commonName')
});

var WheeledVehicle = Vehicle.extend({
  commonName: 'wheeled vehicle',
  wheels: null,
  numberOfWheels: function() {
    return this.get('wheels.length') || 0;
  }.property('wheels')
});

var NoisyVehicle = Ember.Mixin.extend({
  baseSound: null,
  makeSound: function () {
    if(this.get('baseSound')) {
      console.log(this.get('baseSound'));
    }
  }
});

var Plane = Vehicle.extend(NoisyVehicle, {
  baseSound: 'ffffFFRRRRRRRRRRR',
});

var Bycicle = Vehicle.extend(NoisyVehicle, {
  baseSound: 'DRING DRING',
  commonName: 'bycicle',
  init: function () {
    this._super();
    this.set('wheels', Ember.A([
      Wheel.create({location: 'front'}),
      Wheel.create({location: 'back'})
    ]));
  }
});

var Car = Vehicle.extend(NoisyVehicle, {
  baseSound: 'HONK HONK',
  commonName: 'car',
  init: function () {
    this._super();
    this.set('wheels', Ember.A([
      Wheel.create({location: 'front left'}),
      Wheel.create({location: 'front right'})
      Wheel.create({location: 'back left'}),
      Wheel.create({location: 'back right'})
    ]));
  }
});

var myCar = Car.create({
  brand: 'Peugeot'
});

On note dans cet exemple l'utilisation de Mixins avec NoisyVehicle. Ces pseudo-classes permettent de greffer des fonctionnalités à d'autres objets. Ils sont appliqués ici directement à la classe, mais peuvent aussi être appliqués directement aux objets.

Computed properties, un petit goût de réactive programming

Les Computed Properties, également présentes dans Knockout.js, m’ont par exemple beaucoup manqué dans AngularJS. Sur tous les objets Ember.js, il est possible de définir une ou plusieurs propriétés précalculées : c’est à dire une fonction simple, composée d’une ou plusieurs variables et retournant une nouvelle valeur. Cette valeur n’est calculée à nouveau que si les variables dont elle dépend sont modifiées ; sinon, c’est la précédente valeur qui est retournée.

App.Person = Ember.Object.extend({
  // these will be supplied by `create`
  firstName: null,
  lastName: null,
  fullName: function() {
    return this.get('firstName') + ' ' + this.get('lastName');
  }.property('firstName', 'lastName')
});
var ironMan = App.Person.create({
  firstName: "Tony",
  lastName:  "Stark"
});
ironMan.get('fullName') // "Tony Stark"

Ce mécanisme fonctionne du fait que tous les attributs de tous les objets Ember sont potentiellement Observables. C’est ainsi que sont implémentées les Computed Properties.

Il en résulte la possibilité d’avoir un style de programmation de type fonctionnel, où les transformations sur la donnée s’enchaînent, tout en ayant les bienfaits de la réactivité à moindre coût de développement.

var Student = Ember.Object.extend({
  now: moment(),
  birthday: null,
  name: null,
  age: function () {
    // utilisation de la bibliothèque moment.js
    return this.get('now').diff(this.get('birthday'), 'years');
  }.property('birthday', 'now')
});

var ClassRoom = Ember.Object.extend({
  students: Ember.A(),

  ages: function () {
    return this.get('students').mapProperty('age')
  }.property('students.@each.age'),

  averageAge: function () {
    var total =  this.get('ages').reduce(function (total, age) {
      return total + age;
    }, 0);
    return total / this.get('ages.length');
  }.property('ages.@each'),

  oldest: function () {
    return Math.max.apply(null, this.get('ages'));
  }.property('ages.@each')
});

/// Utilisation
var now = moment('2014-04-01');
var alice = Student.create({
  now: now,
  name: 'Alice',
  birthday: moment('1995-06-18')
});
var bob = Student.create({
  now: now,
  name: 'Bob',
  birthday: moment('1995-07-20')
});
var classroom = ClassRoom.create({
  students: Ember.A([alice, bob])
});

classroom.get('averageAge')
/// 18

classroom.get('oldest')
/// 18

classroom.get('students').pushObject(Student.create({
  now: now,
  name: 'Charles',
  birthday: moment('1993-09-05')
}));

classroom.get('averageAge')
/// 18.6

classroom.get('oldest')
/// 20

Ces avantages ont toutefois un inconvénient. l’utilisation des méthode .get() et .set() des objets Ember.js, pour garder trace des changements d’attributs en attendant le standard ECMAScript 6 et ses Observables natifs.

Data binding au coeur du modèle

A l’époque d’AngularJS, React.js, Knockout et consorts, le two-way data binding n’impressionne plus personne. Et pourtant, Ember.js offre une intégration telle du data binding à travers tout le framework, qu’il en devient presque difficile de l’éviter. Dans les templates et les vues, chaque variable affichée dans le template est automatiquement data-bindée. A la différence d’AngularJS qui fait du Dirty Checking, Ember.js utilise un pattern Observable pour garder un œil sur les modifications des variables (http://www.youtube.com/watch?v=mVjpwia1YN4). Cette approche résiste mieux à un grand nombre d’éléments affichés sur une même page, par exemple.

Avec les computed properties citées plus haut, il est alors possible de faire du chaînage complexe d’attributs et d’opérations depuis le modèle jusqu’à la vue qui est automatiquement mise à jour lorsqu’un maillon de la chaîne change. L’utilisation du data-binding n’est d’ailleurs pas limitée aux templates : dans les vues ou les contrôleurs il est aussi possible de manipuler des attributs bindés comme s’ils étaient des attributs propres de la classe.

Promises all the way down!

Comme dans beaucoup de cas en JavaScript, l’asynchronie joue un grand rôle dans la complexification d’une application. Ember.js reconnaît l’aspect profondément asynchrone d’une application JavaScript riche et gère une grande partie de son fonctionnement à l’aide de promises. Toute opération ou presque en Ember.js est considérée comme potentiellement asynchrone et son déclenchement donne lieu à la création d’une promise associée.

Une promise est un objet marqueur d’un résultat futur. C’est à dire qu’il tient lieu de valeur pour une variable que l’on peut passer à des méthodes ou des fonctions, à l’exception que cette valeur sera disponible dans le futur (c’est pourquoi elles sont aussi parfois appelées Futures). Cela permet aux méthodes faisant l’utilisation de promises d’encapsuler leur traîtement à faire sur le résultat “promis” sans se soucier de s’il est déjà disponible ou non.

En pratique :

App.UserRoute = Ember.Route.extend({
  actions: {
    saveUser: function (user) {
      var promise = user.save();
      promise.then(function () {
        // la sauvegarde est complète
      });
    }
  }
});

De plus, la fonctionnalité de promise en Ember.js n’est pas seulement gérée par une classe dédiée, mais c’est aussi un Mixin, c’est à dire une fonctionnalité que l’on peut greffer à tout type d’objet. En pratique, on trouve des modèles, des transitions de route et des chargements de contrôleur qui se comportent comme des promises, et bien d’autres.

C’est aussi un mécanisme important dans le data-binding d’Ember.js qui n’a pas besoin de faire la différence entre un modèle chargé et un modèle en attente. L’objet promise/modèle est utilisé comme n’importe quel autre objet dans les templates et la résolution de la promise n’est qu’un cas comme un autre de mise à jour des attributs de l’objet.

var App.MyController = Ember.Controller.extend({
  user_id: 8,

  currentUser: function () {
    var user_id = this.get('user_id');
    return this.model('user').find(user_id);
  }.property('user_id')
});

et aucun souci dans le template, même si le modèle n'est pas encore chargé, comme ci-dessous.

<h4>{{currentUser.name}}</h4> <ul> {{#each currentUser.phoneNumbers}} <li>{{this}}</li> {{/each}} </ul>

Conclusion

Ember.js est pour l’instant plus discret qu’AngularJS sur la scène des Single Page Applications mais il a d’incontestables avantages sur certains aspects. En prenant une approche résolument orientée objet (au sens classique du terme) il se démarque des solutions comme AngularJS qui ont fait plutôt le pari d’un style procédural. Son lot de conventions, s’il demande un certain temps d’adaptation, permet d’apporter une structure homogène à une ou plusieurs applications Ember.js. Son implémentation du pattern Observer très profonde, ainsi que les nombreuses autres fonctionnalités construites par dessus, lui permettent d’apporter des paradigmes de programmation fonctionnels et réactifs assez disruptifs dans le monde du développement web front-end.

Avec l’arrivée très prochaine de la partie Ember-Data en version stable, Ember.js sera un des frameworks MVC les plus complets sur le marché. Une fois la totalité du framework stabilisé, l’objectif de l’équipe Ember.js est d’optimiser au maximum les performances pour les terminaux mobiles ; point sur lequel Ember.js a un train de retard.

Enfin, comme le dit très régulièrement Tom Dale, la différence entre une application lourde et une application web, c’est l’URL. L’URL est au cœur du fonctionnement d’Ember.js et c’est à partir d’elle que l’organisation du code et de la page est faite. C’est un atout par rapport aux autre frameworks du même type qui permettent des états de l’application en détachement de l’URL, ce qui pose un certain nombre de difficultés, notamment pour le partage de liens entre utilisateurs.