AngularJS nouveau routeur

Le gros point faible d'AngularJS c'est son routeur. Rob Eisenberg, auteur de Durandal, a passé quelques temps dans l'équipe d'Angular pour leur écrire un nouveau routeur qu'il a aussi porté dans son nouveau framework Aurelia.io. Ce routeur apporte une bien meilleure flexibilité que le routeur de base. Nous allons voir ici comment l'utiliser dans un projet AngularJS 1.4 en ES6.

Je vous invite à démarrer depuis ce projet. L'idée va être d'abord d'installer le module, l'intégrer à l'application et à configurer les routes.

Pour bien commencer, on va changer quelques trucs dans le projet. Nous allons d'abord supprimer tout le contenu du dossier src, sauf src/decorators/decorators.js que nous allons déplacer à la racine de src. Le contenu de app.js va être modifié par la suite. Si vous modifiez le nom du module de votre app ("AngularES6" par défaut), n'oubliez pas de le modifier aussi dans le fichier decorators.js.

C'est parti.

Installation

Nous allons commencer par installer le package angular-new-router à l'aide de jspm :

$ jspm install npm:angular-new-router

Le package est maintenant disponible.

Intégration

Il nous faut maintenant l'intégrer dans l'application. Nous allons éditer le fichier app.js pour ajouter les lignes suivantes qui permettent de charger le module angular-new-router, puis de l'ajouter à l'application :

import ngNewRouter from 'angular-new-router';  
var app = angular.module('AngularES6', ['ngNewRouter']);  

Nous avons besoin aussi de modifier le template mapping car les fichiers sont préfixés de "dist" dans cette architecture. Mais c'est à configurer selon votre projet :

app.config(($componentLoaderProvider) => {  
  $componentLoaderProvider.setTemplateMapping(name => {
   return `dist/components/${name}/${name}.html`;
 });
});

Voici à quoi ressemblera au final le fichier src/app.js :

import angular from 'angular';  
import ngNewRouter from 'angular-new-router';  
import {AppController} from './components/app/app';  
import {MainController} from './components/main/main';

var app = angular.module('app', ['ngNewRouter']);

angular.element(document).ready(function() {  
  angular.bootstrap(document, ['app']);
});

app.config(($componentLoaderProvider) => {  
  $componentLoaderProvider.setTemplateMapping(name => {
   return `dist/components/${name}/${name}.html`;
 });
});
export {app};  

Nous allons modifier aussi notre index.html pour y indiquer de charger le AppController et une cible de viewport en modifiant le conteneur de la sorte :

<div class="container" ng-controller="AppController as ctrl">  
  <div ng-viewport></div>
</div>  

Et pour finir, nous allons créer le fichier src/components/app/app.js :

import {inject, register, routes} from './decorators';  
/* jshint ignore:start */
@register({
  type:'controller'
})
@routes([{
  path: '',
  component: 'main'
}])
/* jshint ignore:end */
export class AppController {}  

Ah mais voici un nouveau décorateur ! @routes est un décorateur que j'ai écrit pour faciliter la spécification des routes. Voici son implémentation à ajouter dans decorators.js. J'ai aussi modifié le décorateur @inject alors voici le fichier entier :

export function inject (...components) {  
  return function decorate (target, key, descriptor) {
    if (descriptor) {
      target = descriptor.value;
    }

    target.$inject = target.$inject || [];
    for (let component of components) {
      target.$inject.push(component);
    }

    return descriptor;
  };
}

export function register (opts) {  
  return function decorate(target, key, descriptor) {
    return System.import('angular').then( () => {

      if(opts.inject) {
        if (descriptor) {
          target = descriptor.value;
        }

        target.$inject = opts.inject;
      }

      var app = angular.module('app');
      app[opts.type](opts.name || target.name, target);

      return descriptor;
    });
  };
}

export function routes (opts) {  
  return function decorate(target, key, descriptor) {
    target.$routeConfig = opts;
    target.$inject = target.$inject || [];
    target.$inject.unshift('$router');
  };
}

Ce décorateur va donc renseigner la propriété de constructeur $routeConfig et aussi injecter le service $router.

Bon ! Faisons marcher la magie !

Configuration

Donc ici, nous avons un routeur et nous avons indiqué que sur la route "/", il fallait charger le composant "main" dans le viewport. Mais où se trouve ce composant ?

Il va chercher, par défaut, un template src/components/${name}/${name}.html et un controller src/components/${name}/${name}.js. Par défaut, car on peut changer ça comme on l'a fait plus haut.

Alors, go, on crée un dossier src/components/main dans lequel on crée un fichier main.html et un fichier main.js.

Dans ce template, nous allons mettre ceci :

<p>Hello {{ main.text }}</p>  

et dans le controller :

import {register} from '../../decorators';  
/* jshint ignore:start */
@register({
  type:'controller'
})
/* jshint ignore:end */
export class MainController {  
  constructor() {
    this.text = 'world';
  }
}

On remarquera que $scope n'est pas injecté. On ne peut pas, j'ai essayé et je me suis pris une erreur. En fait, le controller est assigné au template avec la syntaxe controllerAs en utilisant le nom du module. C'est pourquoi dans le template, la variable text est interpolée grâce à {{ main.text }}.

Nous avons donc ici, un routeur qui fonctionne. Nous pouvons créer de nouveaux composants et ajouter de nouvelles routes dans AppController. N'oubliez cependant pas de les importer dans src/app.js.

Routing avancé

Ce qui est très intéressant avec ce routeur, c'est qu'il se configure au niveau du composant. Ainsi, chaque controller peut gérer ses propres routes. Nous pourrons alors avoir dans MainController une liste de routes de second niveau, comme on le ferait avec les states de ui-router. Il suffira d'ajouter un élément <div ng-viewport></div> dans le template main.html. Il est aussi possible d'avoir plusieurs cibles de viewport dans le même template et ainsi charger plusieurs composants différents pour une même route. On pourra par exemple avoir dans le composant main une architecture de ce genre :

<header>  
  <div ng-viewport="header"></div>
</header  
<nav>  
  <div ng-viewport="sidebar"></div>
</nav>  
<div ng-viewport="content"></div>  
<footer>  
  <div ng-viewport="footer"></div>
</footer>  

Concernant le resolving, ça a pas mal changé, et c'est bien mieux foutu aussi. C'est le même principe que pour Aurelia.io. Le controller du composant expose une série de méthodes qui permettent d'exécuter du code juste avant l'instanciation et juste avant la destruction. On aura donc la méthode canActivate qui retournera une valeur positive ou une promise résolue si on peut instancier ce composant. Dans la négative, la route ne sera pas changée. La méthode activate sera exécutée ensuite et pourra aussi retourner une promise. Le routeur attendra la réponse de cette promise pour changer de route. Et donc, même chose avec canDeactivate et deactivate.

Bonus

Je vous donne un fichier .jshintrc qui n'est pas fourni avec le projet angular/es6 et qui permettra d'avoir un joli code. Attention, jshint ne prend pas encore en charge les décorateurs, il faudra donc les ignorer explicitement à l'aide d'un commentaire.

{
  "node": true,
  "esnext": true,
  "bitwise": false,
  "curly": false,
  "eqeqeq": true,
  "eqnull": true,
  "immed": true,
  "latedef": true,
  "newcap": true,
  "noarg": true,
  "undef": true,
  "strict": false,
  "globalstrict": true,
  "trailing": true,
  "smarttabs": true,
  "globals": {
    "window": false,
    "document": false,
    "angular": false,
    "System": false
  }
}

Hadrien

Hi, I'm a french Javascript Lead Developer, Web Architect from Toulouse, France. I've worked for 12 years for many projects with YUI, AngularJS, Aurelia.io and now React and React native.

Toulouse, France https://hadrien.eu