Il y a 2 années · 14 minutes · Front

Webpack, ES6 (ES2015) & Babel 6 pour modulariser son application AngularJS

Webpack, ES6 (ES2015) & Babel 6 pour modulariser son application AngularJSLorsque plusieurs projets AngularJS 1.x doivent partager des modules ou que certaines parties de code gagneraient à être partagées entre plusieurs applications (front et/ou back), le système d’injection de dépendances d’Angular ne suffit plus. De nouveaux packagers web permettent une très grande souplesse dans la gestion des dépendances. Cependant Angular n’est pas prévu pour fonctionner avec ces outils et la base de code existante peut être un frein à leur mise en place.

Grâce à Babel et Webpack, il est dès aujourd’hui possible de bénéficier de la souplesse des modules ES6 (ES2015) dans une application Angular, tout en pouvant faire cohabiter du code legacy. C’est ce que nous allons expliquer au travers de cas concrets et d’exemples de code détaillés dans cet article.

Pourquoi utiliser Webpack dans une application Angular ?

Modulariser son application Angular peut paraître trivial. Le framework propose, de manière intégrée, un mécanisme de déclaration de modules. Il reste cependant impératif, lors de l’injection des dépendances dans votre application, de bien faire attention à l’ordre dans lequel vos modules sont importés. Cette contrainte n’est pas toujours évidente à adresser. Imaginons un module A qui peut être une dépendance d’un module B et d’un module C. Il faudra les importer strictement dans l’ordre A, B, C. De même pour l’ordre des déclarations des fichiers javascript dans les balises <script> de la page index.html. Plus vous aurez de modules et plus il faudra vous creuser la tête sur l’ordre d’importation. Lorsque l’on étudie l’orientation des nouveaux frameworks front – approche composants, isolés et indépendants (self-contained) – on voit tous les bénéfices qu’apportent ces solutions dans la gestion des modules.

Nous avons mis à disposition un repository git dans lequel vous pourrez retrouver tous nos exemples. Nous y ferons régulièrement référence au cours de l’article.

Besoins

Dans notre projet actuel, un des besoins était de partager des modules (javascript et css) avec plusieurs autres projets de manière centralisée.

Contraintes

Nous partions également avec les contraintes suivantes :

  • Angular 1.x ;
  • Pouvoir partager certains modules entre le front et le back end NodeJS (javascript “universel” ou “isomorphique”) ;
  • Nécessité de pouvoir venir choisir dans la librairie de modules, uniquement les composants dont chaque projet avait besoin, pour que le code javascript compilé final ne comporte que du code utilisé ;
  • Pouvoir utiliser des modules sans pattern d’import / export (comme CommonJS, AMD ou ES6) ; nous les appellerons ici « legacy ».

Solutions

Nous avons choisi de regrouper les composants dans un projet indépendant et de le gérer comme un module npm. Dans notre exemple, pour simplifier, nous avons placé ces composants dans un dossier « modules ».
D’autre part, nous avons pris les décisions suivantes :

  • Migration vers Angular 1.4 ;
  • Modularisation via import/export ES6 (compilés en require CommonJS par Babel) ;
  • Incorporation de la syntaxe ES6 dans le code Angular (compilé en ES5 par Babel) ;
  • Utilisation de Webpack pour packager l’application.

Pour le détail des dépendances, voir le package.json du projet.

Babel

Babel permet d’utiliser dès maintenant les améliorations apportées par la nouvelle version de javascript ES6 (voire même ES7) en compilant le code en ES5 compatible avec tous les navigateurs modernes.

Require hook :

Afin d’écrire notre application et notre configuration Webpack en ES6, nous utilisons le require hook Babel qui compile à la volée les fichiers importés et importe les polyfills ES6.

Nous l’utilisons par exemple pour Webpack :

require('babel-register');
module.exports = require('./webpack-es6.config.js');

https://github.com/antogyn/es6-angular-webpack-fullstack/blob/master/webpack.config.js#L1-L3

Configuration :

Il est nécessaire de préciser la configuration de Babel depuis sa version 6. Nous ajoutons pour cela un fichier .babelrc, avec une configuration prédéfinie pour ES6 :

{
  "presets": ["es2015"]
}

Webpack

Webpack est un outil similaire à Browserify : il permet de packager des assets web en gérant leurs dépendances via des imports CommonJS ou AMD. Comme avec NodeJS, cela permet de n’avoir qu’un seul point d’entrée dans notre application. Mais Webpack va plus loin en permettant d’importer tout type de fichier et d’avoir de vrais modules indépendants (self-contained), en important aussi les templates et les styles. Il permet entre autre le chargement asynchrone de vos assets ou le remplacement de modules à chaud sans rafraîchissement de la page pour vos développements.

Remarque : Dans cet article nous n’utilisons Webpack que pour la gestion des fichiers javascript.

Configuration basique:

Au lancement, Webpack va automatiquement exporter le fichier ‘webpack.config.js’ s’il existe. C’est lui qui expose notre configuration Webpack.
On spécifie le point d’entrée de notre application (public/app.js) :

context: __dirname,
entry: { app: './public/app.js' },

La clé ‘context’ est un chemin absolu à partir duquel on résout le chemin de nos points d’entrée. Ici, comme dans la plupart des cas, on utilise simplement la racine du projet.
Puis on spécifie notre fichier de sortie (dist/app.js) :

output: {
 path: './dist',
 filename: 'app.js'
}

Webpack va parcourir notre application en commençant par le fichier public/app.js, en ajoutant dans le bundle chaque fichier importé, puis en gérant les imports de ces mêmes fichiers et ainsi de suite. Au final, notre bundle sera généré dans dist/app.js.

Lorsqu’il rencontre un import, Webpack a besoin d’avoir un “loader” associé au fichier afin de savoir comment le charger. Le seul loader built-in est le loader javascript (ES5) : pour tous les autres, il faut les loaders correspondants.
Nous avons donc besoin d’un loader pour les fichiers ES6 : le babel-loader

module: {
  loaders: [
    {
     test: /\.js$/,
     exclude: /node_modules/,
      loader: 'babel-loader'
    }
  ]
}

A l’aide d’expressions régulières, nous associons ici le babel-loader à tous les fichiers importés ayant une extension ‘.js’, en prenant soin d’exclure les fichiers contenus dans node_modules.

Lancement de Webpack :

En production

./node_modules/webpack/bin/webpack.js -p # minification

En développement

./node_modules/webpack/bin/webpack.js -d # ajout des sourcemaps

On peut aussi ajouter l’option –watch qui va relancer la création du bundle lorsqu’un fichier a changé.

Nous avons ajouté pour cela deux scripts npm :

npm run build:prod
npm run build:dev -- [--watch]

Fichier app.js

En front, nous utilisons les modules ES6 (import et export).
Afin de rendre accessible au niveau global angular et jquery, nous utilisons expose-loader. Il suffit de déclarer l’import des librairies dans notre public/app.js en utilisant une syntaxe spécifique :

import 'expose?jQuery!expose?$!jquery';
import 'expose?angular!angular';

Pour simuler un environnement ES6, il est nécessaire d’importer babel-polyfill :

import 'babel-polyfill';

Création d’un module avec ES6 spécialement pour Webpack

Il est temps de créer notre premier module angular. Nous l’avons nommé “shiny”, ce module n’ayant aucune contrainte de partage sans import/export et pouvant donc embrasser une syntaxe moderne sans vergogne.

Les fichiers Angular

Le contrôleur

Nous utilisons une classe ES6 pour la definition du contrôleur :

class ShinyController {
  constructor() {
  }
}

Nous placerons les attributs dans la partie constructor en utilisant this qui, dans un constructeur de classe, se référera toujours à son instance.

(...)
 constructor() {    
   this.isInitialize = false;
   this.init();
 } 
 init() {
    // some init
    this.isInitialize = true;
  };
(...)

Remarque : Attention lorsque vous écrivez les méthodes d’une classe car même en ES6, le this n’est pas garanti d’être une référence à l’instance de la classe (pour palier à ce problème nous pouvons utiliser l’arrow function). Il existe une proposition pour ES7 qui permettrait d’utiliser une « arrow function » pour une méthode de classe. En attendant, il faudra binder le this à la méthode dans le constructeur ou la déclarer directement avec une « arrow function » :

constructor() {    
  this.onSubmitHandler = () =&gt; { (...) } // en déclarant la méthode dans le constructeur directement
  this.onClickHandler = this.onClickHandler.bind(this); // ou grâce à l'utilisation de .bind
}
onClickHandler() { (...) }

Exporter le controller

Afin de pouvoir utiliser la classe créée il nous faut maintenant l’exporter. Avec la syntaxe ES6, cela donne :

export default class ShinyController {
(...)
}


La directive

Dans le cas d’une directive, Angular s’attend à une fonction retournant un objet littéral. Nous choisissons d’assigner une constante à une fonction, puis de l’exporter. Les clés doivent être les mêmes que celles utilisées dans une application Angular classique (scope, controller, etc.).

const ShinyDirective = () =&gt; {
  return {
    scope: {},
    restrict: 'E',
    controller: 'ShinyController',
    controllerAs: 'ctrl',
    bindToController: {
      addResult: '@'
    },
    template: (...)
  };
};

export default ShinyDirective; 

Nous pouvons bénéficier des template strings ES6 en utilisant les back-ticks pour définir le template de la directive (même si ce n’est pas une obligation).

const templateString = `
  &lt;li&gt;
    addResult: {{ ctrl.addResult }} &lt;-- should be 4
  &lt;/li&gt;
  &lt;li&gt;
    substractResult: {{ ctrl.substractResult }} &lt;-- should be 11
  &lt;/li&gt;
`;

Et l’importer dans la directive de manière suivante :

(...)
template: templateString
(...)

Remarque : on peut imaginer utiliser la même technique, mais placer le template dans un fichier séparé qui exporte la constante, pour ensuite pouvoir l’importer comme une dépendance externe à la directive. Grâce à Webpack, le fichier sera correctement référencé et importé.


Le service

Là encore nous pouvons utiliser une classe :

export default class ShinyService {
  constructor() {
  }

  add(a, b) {
    return a + b; 
  }

  substract(a, b) {
    return a - b;
  }
}


Injection de dépendances

Pour injecter une dépendance Angular – par exemple un service – il suffit d’utiliser la directive $inject, en fin de fichier, sur la classe créée. Nous pouvons lui passer, comme dans une application Angular classique, une référence à un service / directive Angular ($scope, …) ou de notre propre base de code. Ici, nous injecterons ShinyService dans le contrôleur. Nous devons ensuite passer ce service en paramètre du constructeur de la classe, il sera injecté lors de l’initialisation du contrôleur, via le mécanisme d’injection d’Angular.

Dans notre exemple, nous déléguons l’intelligence de nos méthodes à celles du service injecté :

export default class ShinyController {
  constructor(ShinyService) {
    this.addResult = ShinyService.add(2, 2);
    this.substractResult = ShinyService.substract(15, 4);
  }
}

ShinyController.$inject = ['ShinyService'];

La déclaration du module Angular

La partie la plus importante. D’ordinaire, nous devons, dans chaque fichier, déclarer le type et le nom de l’élément Angular, ses dépendances et le rattacher au module Angular. Ici nous déclarons tout dans un seul fichier :

import ShinyService from './shiny.service.js';
import ShinyController from './shiny.controller.js';
import ShinyDirective from './shiny.directive.js';

export default angular
  .module('ShinyComponent', []) // Déclaration du module
  .service('ShinyService', ShinyService)
  .controller('ShinyController', ShinyController)
  .directive('shiny', ShinyDirective)
  .name;

Voir le code complet.

Remarque : Après la déclaration du module, nous pouvons même déclarer des dépendances externes, en important le module et en l’injectant. Par exemple un autre de nos modules. Comme on exporte son .name il suffit de placer le module directement dans la liste de dépendances du module :

import externalComponent from '(...)'

angular
    .module('ShinyComponent', [externalComponent])
 .service(...)

Ou encore avec un module Angular tiers, par exemple ngSanitize :

import 'angular-sanitize'

angular
    .module('ShinyComponent', ['ngSanitize'])
 .service(...)

Webpack pour les modules legacy

Bien que la plupart des modules legacy ne suivent pas les conventions modernes, il est parfois pratique de les utiliser dans notre application.

Nous étudierons plusieurs cas et apporterons une solution viable pour chacun d’eux.

Code legacy non Angular

On doit parfois intégrer du code utilisant directement une fonction de jQuery, provenant par exemple d’une librairie tierce :

$('#legacy-js').css('color', 'blue');

Il suffit d’importer le fichier directement dans notre application pour l’exécuter :

import '../legacy_modules/js/blue';

Module Angular legacy

Il est également trivial d’utiliser un module Angular legacy :

angular
  .module('LegacyModule', [])
  .controller('LegacyCtrl', ['$scope', function ($scope) {
    $scope.message = 'Hello !';
  }])
  .directive('legacy', function () {
    return {
      scope: {},
      restrict: 'E',
      controller: 'LegacyCtrl',
      template: 'I\'m a legacy directive with a message : {{ message }}'
    };
  });

Nous pouvons faire un simple import, et ajouter le module comme dépendance de notre application Angular en déclarant son nom :

import '../legacy_modules/components/legacy';
 
export default angular
  .module('Es6App', ['LegacyModule']);

Module Angular legacy partagé avec un projet sans Webpack

C’est exactement le même besoin que précédemment, mais dans ce cas nous utilisons un pattern adopté depuis longtemps par les librairies JS qui, en fonction de l’existence en global d’une variable module, exporte en global ou en CommonJS :

(function ( legacySharedModule ) {
  'use strict';
  if (typeof module !== 'undefined') {
    module.exports = legacySharedModule();
  } else {
    legacySharedModule();
  }
})( function() {
  'use strict';
  return angular.module('LegacySharedModule', [])
    .controller('LegacySharedCtrl', ['$scope', function ($scope) {
      $scope.message = 'Hi !';
    }])
    .directive('legacyShared', function () {
      return {
        scope: {},
        restrict: 'E',
        controller: 'LegacySharedCtrl',
        template: 'I\'m a legacy directive, that can be loaded with or without Webpack, with a message : {{ message }}'
      };
    }).name;
});

Il est possible de créer un fichier pour déclarer le module de manière à être exporté en tant que module ES6. Une application Angular sans Webpack pourra toujours utiliser le composant en l’important dans une balise <script> :

import SharedLegacyComponent from '../legacy_modules/components/legacy-shared';
 
export default angular
  .module('Es6App', [
    SharedLegacyComponent
  ]);

Webpack pour module « universel » (ou « isomorphique »)

Imaginons maintenant que notre shinyService veuille utiliser du code métier partagé avec le back. Nous créons un service qui comprend deux méthodes :

export const add = (a, b) =&gt; a + b;

export const substract = (a, b) =&gt; a - b;

Maintenant nous pouvons modifier notre shinyService pour les utiliser :

import * as sharedMath from '../../../shared/services/sharedMath.js';

export default class ShinyService {

  constructor() {
  }

  add(a, b) {
    return sharedMath.add(a, b);
  }

  substract(a, b) {
    return sharedMath.substract(a, b)
  }
}

https://github.com/antogyn/es6-angular-webpack-fullstack/blob/master/shared/services/sharedMath.js

Limitations

Déclarations des dépendances globales

Webpack permet d’automatiquement préfixer les modules “legacy” avec des imports de leurs dépendances via l’expose-loader. Il faut cependant préciser les fichiers concernés et quels sont les dépendances à éventuellement rajouter.

On pourrait donc utiliser cela pour n’avoir aucune dépendance globale dans notre application.

Cependant, en fonction de la dépendance concernée, celle-ci peut exécuter une fonction d’initialisation interne (et renvoyer ou non un résultat). C’est par exemple le cas d’Angular. Beaucoup d’exemples sur le net importent Angular au niveau de chaque module qui en a besoin. Faire cela permet d’expliciter la dépendance, ce qui est une bonne chose, mais exécute la fonction d’initialisation d’Angular pour chaque import ! Heureusement, Angular détecte qu’il a déjà été initialisé, mais envoie un message dans la console du navigateur pour chaque import supplémentaire prévenant que l’on a déjà essayé de le charger ; si vous avez beaucoup de modules, ça peut vite devenir gênant.

C’est pour cela que l’on a choisi de déclarer Angular et jQuery en tant que dépendance globale; cela facilite aussi l’intégration des modules legacy en évitant de complexifier davantage la configuration de Webpack.

Élimination des exports inutiles

Webpack n’enlève pas les exports non utilisés du bundle, ce qui est pourtant possible à déterminer vue la nature statique du système de module ES6 ; du coup, si vous avez besoin d’optimiser la taille de plusieurs bundles utilisant partiellement le même module, vous êtes obligés de le découper en plusieurs fichiers plutôt que de simplement importer ce dont vous avez besoin !

Heureusement, depuis sa version 2 (encore en bêta), Webpack est capable de parser des modules ES6 pour déterminer quels sont les exports inutiles et les éliminer du bundle : c’est ce qu’on appelle le ‘tree-shaking’.

Conclusion

Malgré l’utilisation de son propre injecteur de dépendances, Angular se marie bien avec Webpack et l’utilisation de modules ES6, et fonctionne même avec du code non modulaire. Webpack est particulièrement utile si l’on a besoin de partager des composants, pour avoir du code commun avec un back en Node ou si les dépendances entre vos modules sont complexes et qu’il est difficile de maintenir l’ordre d’import.

Pour aller plus loin, lisez ce très bon article sur comment gérer le lazy loading avec Webpack et AngularJS.

Antoine Le Taxin
Développeur fullstack, afición du javascript de la première heure, Antoine suit avec passion les évolutions de l’écosystème : de Backbone à React en passant par Angular et Node, bon nombre de frameworks JS lui sont passés sous les doigts.
Anthony Giniers
Anthony est développeur fullstack, Javaiste repenti spécialisé dans les technologies JavaScript et les problématiques liées au Cloud.

Retrouvez le sur Twitter : @aginiers

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *