Les applications AngularJS sur lesquelles j’ai travaillé jusqu’à présent, ont été construites à l’aide de Yeoman et generator-angular. Leurs fondations s’appuient sur Grunt, auquel je n’ai jamais consacré le temps nécessaire et Bower, dont les mainteneurs préconisent désormais l’utilisation de Yarn et webpack. J’ai envisagé un temps de remplacer Grunt par Gulp, mais j’ai eu l’impression que, malgré les qualités de l’outil, comme pour Grunt, sans lui adjoindre quantité de plugins, Gulp ne m’apporterait pas assez. J’ai donc été voir du côté de webpack.
webpack se définit comme un bundler. D’une part, il assemble les ressources du projet pour produire un livrable. De l’autre, par le biais du webpack-dev-server, il joue le rôle de serveur de développement, prenant en charge le rechargement à chaud des fichiers modifiés. webpack est très configurable. Pour qui vient de l’écosystème Java, webpack est moins prescriptif que Maven, mais plus cadré qu’Ant. Le scénario nominal consiste à déclarer un ensemble de fichiers JS, d’éventuelles transformations à leur appliquer et la sortie attendue. webpack a une architecture en plugins qui lui permet de s’adapter à de nombreux usages. Il n’est cependant pas facile de savoir quelle stratégie va s’avérer payante avant d’essayer.
Pour illustrer l’article, je propose d’implémenter le Fizz Buzz test avec AngularJS, puis de rajouter le support de Bootstrap pour voir comment la configuration de webpack évolue. L’intégralité du code source est disponible sur GitHub.
Mise en œuvre initiale
L’initialisation du projet est réalisée dans une invite de commande avec NPM :
npm init -y npm install angular angular-animate angular-cookies angular-resource angular-route angular-sanitize --save npm install angular-mocks jasmine-core karma karma-jasmine karma-phantomjs-launcher karma-spec-reporter phantomjs-prebuilt --save-dev npm install webpack webpack-dev-server html-webpack-plugin --save-dev
L’application :
'use strict'; angular .module('fizzBuzzApp', [ 'ngAnimate', 'ngCookies', 'ngResource', 'ngRoute', 'ngSanitize' ]);
Le service :
'use strict'; angular.module('fizzBuzzApp') .service('fizzBuzzService', function () { return { getFizzBuzz: function (count) { var output = ''; for (var i = 1; i <= count; i++) { if (i % 3 && i % 5) { output += i + ' '; } if (i % 3 === 0) { output += 'Fizz '; } if (i % 5 === 0) { output += 'Buzz '; } } return output; } }; });
Le contrôleur :
'use strict'; angular.module('fizzBuzzApp') .controller('FizzBuzzCtrl', [ 'fizzBuzzService', function (fizzBuzzService) { var that = this; that.count = 10; that.getFizzBuzz = function () { return fizzBuzzService.getFizzBuzz(that.count); }; }]);
Le template :
<!doctype html> <html> <head> <meta charset="utf-8"> <title>Fizz Buzz</title> </head> <body ng-app="fizzBuzzApp"> <div ng-controller="FizzBuzzCtrl as fizzBuzz"> <input type="number" ng-model="fizzBuzz.count" placeholder="Count" /> <div ng-bind="fizzBuzz.getFizzBuzz()"></div> </div> </body> </html>
Le fichier de configuration de webpack :
'use strict'; var glob = require('glob'); var path = require('path'); var webpack = require('webpack'); var HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { entry: { angular: [ './node_modules/angular/angular', './node_modules/angular-animate/angular-animate', './node_modules/angular-cookies/angular-cookies', './node_modules/angular-resource/angular-resource', './node_modules/angular-route/angular-route', './node_modules/angular-sanitize/angular-sanitize' ], main: glob.sync('./app/scripts/**/*.js') }, output: { path: path.resolve(__dirname, 'dist'), filename: '[name].bundle-[hash:6].js' }, plugins: [ new HtmlWebpackPlugin({ template: 'app/index.html' }) ] }
Détaillons un peu.
La configuration de webpack pour le projet prend la forme d’une définition de module CommonJS.
entry
: la déclaration des points d’entréeoutput
: à quel endroit et sous quelle forme webpack produit les éléments à livrer
Schématiquement, pour chaque point d’entrée, webpack produit un fichier JS conformément au paramétrage de output
. Je choisis de regrouper au sein d’un même point d’entrée un ensemble de scripts cohérent. J’en définis 2 : le premier appelé angular
pour AngularJS, l’autre appelé main
pour tous les fichiers de l’application, que je capture à l’aide de la bibliothèque glob. Dans la plupart des exemples que l’on peut trouver sur le Web, un point d’entrée unique fait référence à un seul fichier JS, lequel utilise la fonction require()
pour construire l’arbre des dépendances. Indisponible dans le navigateur, cette fonction pose problème à l’exécution des tests unitaires, c’est pourquoi je lui préfère pour le moment l’utilisation de glob.
webpack émet les fichiers JS dans le répertoire dist. Le nom du fichier émis contient les 6 premiers caractères du hash du module. Le nom étant différent chaque fois qu’une modification est apportée, ce choix permet de garantir que les nouveaux fichiers JS seront chargés par le navigateur après la livraison d’une nouvelle version en production. html-webpack-plugin complète le répertoire dist en créant la page d’accueil de l’application à partir du template indiqué. Les liens vers les fichiers JS émis par webpack y sont automatiquement ajoutés.
Le fichier package.json :
{ "name": "fizz-buzz", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "build": "webpack", "serve": "webpack-dev-server --open --watch", "test": "karma start test/karma.conf.js" }, "keywords": [], "author": "Arthur Noseda", "license": "ISC", "dependencies": { "angular": "^1.6.5", "angular-animate": "^1.6.5", "angular-cookies": "^1.6.5", "angular-resource": "^1.6.5", "angular-route": "^1.6.5", "angular-sanitize": "^1.6.5" }, "devDependencies": { "angular-mocks": "^1.6.5", "html-webpack-plugin": "^2.29.0", "jasmine-core": "^2.6.4", "karma": "^1.7.0", "karma-jasmine": "^1.1.0", "karma-phantomjs-launcher": "^1.0.4", "karma-spec-reporter": "0.0.31", "phantomjs-prebuilt": "^2.1.14", "webpack": "^3.3.0", "webpack-dev-server": "^2.5.1" } }
2 tâches liées à webpack sont ajoutées dans package.json :
build
pour construire le livrableserve
pour démarrer un serveur de développement
Telle que je l’ai configurée, la commande npm run serve
ouvre le navigateur par défaut (modificateur --open
) et recharge automatiquement l’application si un fichier est modifié (modificateur --watch
).
Le test du service :
'use strict'; describe('Service: fizzBuzzService', function () { beforeEach(module('fizzBuzzApp')); var fizzBuzzService; beforeEach(inject(function (_fizzBuzzService_) { fizzBuzzService = _fizzBuzzService_; })); it('should return \'1 2 Fizz \'.', function () { expect(fizzBuzzService.getFizzBuzz(3)).toEqual('1 2 Fizz '); }); });
Le fichier de configuration de Karma :
module.exports = function(config) { 'use strict'; config.set({ autoWatch: true, basePath: '../', frameworks: [ 'jasmine' ], files: [ 'node_modules/angular/angular.js', 'node_modules/angular-animate/angular-animate.js', 'node_modules/angular-cookies/angular-cookies.js', 'node_modules/angular-resource/angular-resource.js', 'node_modules/angular-route/angular-route.js', 'node_modules/angular-sanitize/angular-sanitize.js', 'node_modules/angular-mocks/angular-mocks.js', 'app/scripts/**/*.js', 'test/mock/**/*.js', 'test/spec/**/*.js' ], exclude: [ ], reporters: ['spec'], port: 8080, browsers: [ 'PhantomJS' ], singleRun: false, colors: true, logLevel: config.LOG_INFO, }); };
L’exécution de la commande npm run build
ne supprime pas le contenu du répertoire dist. La commande à exécuter étant dépendante de l’OS, j’introduis la bibliothèque rimraf pour conserver la portabilité du projet.
npm install rimraf --save-dev
Puis je change la définition de la tâche build
:
"build": "rimraf dist && webpack",
Mise en place de Bootstrap
L’interface utilisateur n’est pas très attrayante. Sans prétendre en faire une œuvre d’art, améliorons-la sensiblement avec Bootstrap.
npm install angular-ui-bootstrap bootstrap --save
La liste des dépendances doit rester alignée dans webpack et Karma. Je rajoute une entrée dans webpack :
entry: { angular: [ './node_modules/angular/angular', './node_modules/angular-animate/angular-animate', './node_modules/angular-cookies/angular-cookies', './node_modules/angular-resource/angular-resource', './node_modules/angular-route/angular-route', './node_modules/angular-sanitize/angular-sanitize' ], bootstrap: './node_modules/angular-ui-bootstrap/dist/ui-bootstrap-tpls', main: glob.sync('./app/scripts/**/*.js') },
Et dans Karma :
files: [ 'node_modules/angular/angular.js', 'node_modules/angular-animate/angular-animate.js', 'node_modules/angular-cookies/angular-cookies.js', 'node_modules/angular-resource/angular-resource.js', 'node_modules/angular-route/angular-route.js', 'node_modules/angular-sanitize/angular-sanitize.js', 'node_modules/angular-mocks/angular-mocks.js', 'node_modules/angular-ui-bootstrap/dist/ui-bootstrap-tpls.js', 'app/scripts/**/*.js', 'test/mock/**/*.js', 'test/spec/**/*.js' ],
Il faut rajouter le CSS de Bootstrap dans le livrable, faire un lien dans le template HTML et ajouter ui-bootstrap comme dépendance dans la définition de l’application :
angular .module('fizzBuzzApp', [ 'ngAnimate', 'ngCookies', 'ngResource', 'ngRoute', 'ngSanitize', 'ui.bootstrap' ]);
J’utilise copy-webpack-plugin pour copier les ressources de Bootstrap dans le livrable :
npm install copy-webpack-plugin --save-dev
Dans webpack.config.js, j’importe le plugin :
var CopyWebpackPlugin = require('copy-webpack-plugin');
Et je l’utilise dans la section plugins :
plugins: [ new HtmlWebpackPlugin({ template: 'app/index.html' }), new CopyWebpackPlugin([ { from: 'node_modules/bootstrap/dist/css/bootstrap.min.css', to: 'bootstrap/css' }, { from: 'node_modules/bootstrap/dist/fonts', to: 'bootstrap/fonts' }, ]) ]
Dans le template HTML, je fais le lien avec le CSS, et j’en profite pour modifier le markup :
<!doctype html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Fizz Buzz</title> <link rel="stylesheet" href="bootstrap/css/bootstrap.min.css" /> </head> <body ng-app="fizzBuzzApp"> <nav class="navbar navbar-default navbar-static-top"> <div class="container-fluid"> <div class="navbar-header"> <a class="navbar-brand" ng-href="#!/">Fizz Buzz</a> </div> </div> </nav> <div ng-controller="FizzBuzzCtrl as fizzBuzz" class="container"> <div class="row"> <div class="col-md-12"> <form> <input type="number" ng-model="fizzBuzz.count" placeholder="Count" class="form-control" /> </form> <br /> <div ng-bind="fizzBuzz.getFizzBuzz()" class="well"></div> </div> </div> </div> </body> </html>
Les chunks peuvent avoir des dépendances entre eux. Ainsi UI Bootstrap dépend d’AngularJS et doit donc être importé après ce dernier. Par défaut, html-webpack-plugin ne garantit pas l’ordre d’ajout des scripts dans la page :
ReferenceError: angular is not defined bootstrap.bundle-939c1d.js:9309:4 Error: [$injector:modulerr] Failed to instantiate module fizzBuzzApp due to: [$injector:modulerr] Failed to instantiate module ui.bootstrap due to: [$injector:nomod] Module 'ui.bootstrap' is not available! You either misspelled the module name or forgot to load it. If registering a module ensure that you specify the dependencies as the second argument.
Pour résoudre le problème, je crée une instance de CommonsChunkPlugin
:
plugins: [ new webpack.optimize.CommonsChunkPlugin({ names: [ 'bootstrap', 'angular' ] }), new HtmlWebpackPlugin({ template: 'app/index.html' }), new CopyWebpackPlugin([ { from: 'node_modules/bootstrap/dist/css/bootstrap.min.css', to: 'bootstrap/css' }, { from: 'node_modules/bootstrap/dist/fonts', to: 'bootstrap/fonts' }, ]) ]
Comme on peut le voir en regardant le code HTML généré, l’ordre est désormais déterminé :
<script type="text/javascript" src="angular.bundle-57dbfb.js"></script> <script type="text/javascript" src="bootstrap.bundle-57dbfb.js"></script> <script type="text/javascript" src="main.bundle-57dbfb.js"></script>
Les fichiers JavaScript émis sont toutefois très volumineux. Dans webpack.config.js, j’ajoute un nouveau plugin :
new webpack.optimize.UglifyJsPlugin(),
Et je paramètre les source maps pour que le debugging reste confortable :
devtool: 'source-map', plugins: [ new webpack.optimize.UglifyJsPlugin({ sourceMap: true }), new webpack.optimize.CommonsChunkPlugin({ names: [ 'bootstrap', 'angular' ] }), new HtmlWebpackPlugin({ template: 'app/index.html' }), new CopyWebpackPlugin([ { from: 'node_modules/bootstrap/dist/css/bootstrap.min.css', to: 'bootstrap/css' }, { from: 'node_modules/bootstrap/dist/fonts', to: 'bootstrap/fonts' }, ]) ]