Publié le 09 février 2016
Prérequis
Le projet va être développé avec :
node.js
version 5.5.0npm
version 3.3.12J'utilise nvm pour gérer différentes versions de node
, avec un peu de configuration de npm
pour accélérer l'installation des modules, et fixer leur version :
npm config set progress=false
npm config set save-prefix='~'
Je vais utiliser Babel pour transpiler le code ES6 en ES5. La version 6 a introduit une nouvelle organisation du projet. Il faut maintenant installer au minimum babel-core, sur lequel vont s'appuyer tous les autres modules.
npm install babel-core –save-dev
Ensuite, il faut installer babel-cli permettant entre autres de vérifier l'environnement avec babel-doctor ainsi que babel-node qui lui permet de lancer des scripts ES6 avec node avec une transpilation dynamique (on a ainsi un REPL ES6).
npm install babel-cli –save-dev
Enfin, Babel utilise maintenant un système de presets permettant de ne transpiler que ce qui sera nécessaire au code final en fonction de son environnement d'exécution, en l'occurrence le navigateur.
npm install babel-preset-es2015
# package.json
"devDependencies": {
"babel-cli": "~6.5.1",
"babel-core": "~6.5.1",
"babel-preset-es2015": "~6.5.0"
},
"babel": {
"presets": [
"es2015"
]
}
Remarque : J'ai configuré le preset de Babel directement dans le fichier package.json
. Il est également possible de réaliser cette configuration dans un fichier .babelrc
.
Je conseille la lecture de l'article de Jean-Charles Sisk, « *Clearing up the Babel 6 Ecosystem *» pour avoir plus de détail sur Babel 6.
Maintenant, il faut choisir si l'on veut utiliser Babel :
C'est la solution que je vais utiliser (expliquant la déclaration des modules Babel en dépendance de développement, puisque Babel ne sera jamais présent dans le js de production). Et pour cela, nous allons devoir passer par un autre outil : Webpack.
Webpack est un « module bundler » permettant de gérer notre code es6 écrit en module et générer un fichier final js utilisable dans le navigateur, en effectuant au passage la transpilation ES6 avec Babel.
webpack takes modules with dependencies and generates static assets representing those modules.
Webpack fait d'ailleurs bien plus que cela comme cela sera vu plus tard. Pour le moment, installons le afin de faire un premier test de transpilage de deux modules ES6.
Tous les fichiers de cette phase de bootstraping sont créés dans le répertoire /src
et utilisent la syntaxe ES6 pour la gestion des modules.
├── src
│ ├── css
│ │ └── main.css
│ ├── index.html
│ └── js
│ ├── main.js
│ └── paris.js
<!-- src/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Road to Caen</title>
</head>
<body>
<h1>Road to Caen - version 0.1.0</h1>
<div id="start_here"></div>
<script src="js/main.js"></script>
</body>
</html>
// src/js/main.js
import city from './paris';
document.getElementById('start_here').innerHTML = `For the moment, you are in ${city}`;
// src/js/city.js
export default 'Paris';
Si l'on essaye d'ouvrir index.html directement dans le navigateur, on a logiquement une erreur :
On va donc configurer webpack pour effectuer un build du fichier main.js
dans le répertoire /build
, obligant au passage à installer deux plugins:
npm install webpack babel-loader copy-webpack-plugin –save-dev
Je ne vais pas rentrer dans les détails de webpack (vous pouvez pour cela lire xxx ou xxx), mais le grand principe est :
entry
),output
) correspondant aux entrées,On déclare aussi des plugins pour des cas particuliers, comme le plugin copy-webpack-plugin permettant de copier le fichier /src/index.html
dans le répertoire cible /build/
.
# Fichier de configuration de webpack webpack.config.js à la racine du projet
const path = require('path');
const webpack = require('webpack');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const dirJs = path.resolve(__dirname, 'src/js');
const dirHtml = path.resolve(__dirname, 'src');
const dirBuild = path.resolve(__dirname, 'build');
module.exports = {
entry: path.resolve(dirJs, 'main.js'),
output: {
path: dirBuild,
filename: 'js/main.js',
},
module: {
loaders: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
],
},
plugins: [
new CopyWebpackPlugin([
{
from: dirHtml + '/index.html'
},
]),
],
stats: {
colors: true,
},
};
./node_modules/.bin/webpack
On retrouve au final un fichier index.html
et main.js
dans le répertoire /build/
, que l'on peut charger sans erreur dans un navigateur.
On arrive donc à transpilet notre ES6 dans un fichier unique fichier js, et c'est déjà chouette. Mais on ne va pas lancer un build à chaque modifications du code...
Pour éviter cela, on va installer le webpack-dev-server, et en profiter pour faire un makefile afin de faciliter le lancement des commandes (make build
pour lancer un transpilation et make run-dev
pour le lancement du serveur de dev), et permettre à d'éventuels autre participants au projet de pouvoir facilement installer tout l'environnement de développement (make install
).
npm install webpack-dev-server –save-dev
# makefile
.PHONY: default install run test build clean-build
default: run-dev
install:
@npm install
clean-build:
@rm -rf ./build/*
build: clean-build
@./node_modules/.bin/webpack
run-dev: clean-build
@./node_modules/.bin/webpack-dev-server --hot --inline --port=8080
Remarque : on pourrait aussi utiliser npm
pour lancer ces commandes et éviter le node_modules/.bin
vers les exécutables des outils. Mais par habitude, ce sera make.
Le webpack-dev-server est très rapide (il charge le code en mémoire) et permet de faire du live-reload (option –hot
). On transpile maintenant bien le code ES6 en js lisible par le navigateur à la volée.
Mais on ne gère que le js, alors que l'on veut aussi avoir du css.
Pour gérer le css, on ajoute un nouveau plugin et un nouveau loader (pour du css simple, mais on pourra aussi par la suite "pre-processer" du sass par exemple, grace à d'autres loaders).
npm install style-loader --save-dev
# webpack.config.js
...
module: {
loaders: [
{
test: /\.css$/,
loader: "style!css",
},
...
L'étrangeté, c'est l'appel du css dans le main.js
, et pas dans l'index.html
:
# src/js/main.js
import '../css/main.css';
Mais c'est comme cela que l'on va pouvoir gérer nos css avec Webpack, comme un module js.
Il faut maintenant encore améliorer les choses afin de gérer :
Pour cela on va utiliser quelques nouveaux modules et plugins webpack :
Le plugin html-webpack-plugin est tout simplement génial, et je vous renvoie vers l'article de Jonathan Petitcolas pour découvrir tout son potentiel : Webpack HTML plug-in in a nutshell.
Le module config va lui permettre de gérer un fichier de configuration (config/default.js
) accessible depuis le code, et l'on va pouvoir surcharger cette configuration selon les environnements de node NODE_ENV.
npm install config html-webpack-plugin extract-text-webpack-plugin –save-dev
# webpack.config.babel.js
import path from 'path';
import plugins from './webpack/plugins';
import ExtractTextPlugin from 'extract-text-webpack-plugin';
const dirJs = path.resolve(__dirname, 'src/js');
const dirHtml = path.resolve(__dirname, 'src');
const dirBuild = path.resolve(__dirname, 'build');
module.exports = {
entry: path.resolve(dirJs, 'main.js'),
output: {
path: dirBuild,
filename: 'js/road_to_caen.js', // On génère un fichier js final nommé
},
module: {
loaders: [
{
test: /\.js$/, // le loader des fichiers js
loader: 'babel-loader', // on transpile l'ES6 avec Babel
exclude: /node_modules/,
},
{
test: /\.css$/, // le loader des css
// Ici on traite différemment le css selon l'environnement : en dev on l'inject avec le javascript
// en prod, on génère un fichier css distinct grace au extract-text-webpack-plugin
loader: process.env.NODE_ENV === 'dev' ? 'style!css' : ExtractTextPlugin.extract("style-loader", "css-loader"),
},
],
},
plugins: plugins(process.env.NODE_ENV, dirHtml), // On appel un fichier de conf des plugin distinct en passant l'environnement
stats: {
colors: true,
},
// On va créer un fichier sourcemap si l'on est en environnement de développement
devtool: process.env.NODE_ENV === 'dev' ? 'source-map' : false,
};
# webpack/plugin.js
import config from 'config'; // on appel le module de configuration
import ExtractTextPlugin from 'extract-text-webpack-plugin';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import webpack from 'webpack';
export default (env, dirHtml) => {
const pluginsArray = [
// Avoid publishing files when compilation fails
new webpack.NoErrorsPlugin(),
new HtmlWebpackPlugin({
hash: env === 'prod', // ici, on génère le cache busting des assets si l'on est en prod
title: config.app_name, // définit une variable utilisable dans le template html. On recupère le titre depuis la config
template: dirHtml + '/index.html',
}),
];
if (env === 'prod') {
pluginsArray.push(
// Ici on configure le plugin permettant de générer le fichier css de production
new ExtractTextPlugin('css/road_to_caen.css', {
allChunks: true,
})
);
}
return pluginsArray;
};
Remarque : Le fichier de configuration de webpack a été renommé en webpack.config.babel.js
: c'est ce qui permet d'utiliser de l'ES6 dans ce fichier js :).
# src/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<!-- On utilise la variable options de webpack -->
<title><%= htmlWebpackPlugin.options.title %></title>
<!-- Plus besoin d'appeller le fichier css, webpack va injecter l'appel pour la production -->
</head>
<body>
<h1>Road to Caen - version 0.1.0</h1>
<div id="start_here"></div>
<!-- Plus besoin d'appeller le fichier js, webpack va injecter l'appel pour la production -->
</body>
</html>
# build/index.html - le fichier html final de production
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<!-- On a bien le nom de production défini dans le fichier config/prod.js -->
<title>Road to Caen</title>
<!-- L'appel au fichier css final est bien fait, avec du cache busting. Ce fichier est bien minifié -->
<link href="css/road_to_caen.css?e79fbcf65e16662aa388" rel="stylesheet"></head>
<body>
<h1>Road to Caen - version 0.1.0</h1>
<div id="start_here"></div>
<!-- L'appel au fichier js final est bien fait, avec du cache busting. Ce fichier est bien minifié -->
<script src="js/road_to_caen.js?e79fbcf65e16662aa388"></script></body>
</html>
# makefile
.PHONY: default install run test build clean-build
default: run-dev
install:
@npm install
clean-build:
@rm -rf ./build/*
build: clean-build
# On défini l'environnement de node comme prod pour le build de production
# On ajoute l'option -p (production) pour la minification des assets
# --progress permet de visualiser l'avancement du build sur la console
@NODE_ENV=prod ./node_modules/.bin/webpack -p --progress
run-dev: clean-build
# On défini l'environnement de node comme dev pour le webpack-dev-server
@NODE_ENV=dev ./node_modules/.bin/webpack-dev-server --hot --inline --port=8080
On y est presque ! Mais il manque encore les tests unitaires qui seront réalisés avec mocha et chai.
npm install mocha chai --save-dev
# makefile
test-unit:
# On indique à mocha ou trouver les fichiers de test : src/js/**/*.spec.js
# On ajoute un compileur à mocha, pour pouvoir écrire les tests en ES6 : --compilers js:babel-core/register
# On configure la sortie de console de mocha avec --reporter spec --ui bdd
@NODE_ENV=test node_modules/.bin/mocha src/js/**/*.spec.js --compilers js:babel-core/register --reporter spec --ui bdd --timeout 5000
Concernant les fichiers de tests unitaires, je trouve cela plus pratique et plus cohérent de créer le fichier à côté du fichier testé unitairement en rajoutant un .spec
, plutôt que de les mettre dans un répertoire /tests
distinct.
├── src
│ ├── css
│ │ └── main.css
│ ├── index.html
│ └── js
│ ├── main.js
│ ├── paris.js
│ └── paris.spec.js
# src/js/paris.spec.js
import city from './paris';
import { assert } from 'chai';
describe('city', () => {
it('should export "Paris" as city', () => {
assert.equal(city, 'Paris');
});
});
Pour que les tests soient complets, il manque encore les tests fonctionnels. Pour les réaliser, je vais utiliser Nightwatch. Mais tout d'abord, pour faire des tests fonctionnels, on va avoir besoin d'un build de prod et d'un serveur permettant à Nightwatch d'appeler une URL à tester.
L'exposition http des fichiers statiques finaux sera assurée par http-server. Lors de l'execution des tests, il va falloir gérer :
Cela peut se gérer via les PID
des instances de serveurs
start-static-server: static-server.PID
static-server.PID:
@echo "Starting static server"
@cd ./build && { ../node_modules/.bin/http-server -p 8081 & echo $$! > ../$@; } && cd ..
stop-static-server: static-server.PID
@kill `cat $<` && rm $<
Mais je n'aime pas trop cette solution, qui présente entre l'autre l'inconvéniant de laisser trainer des fichiers .PID
en cas d'échec des tests, empêchant la relance des serveurs après correctif.
J'ai trouvé une solution plus élégante consistant à utiliser PM2 pour gérer les serveurs lors des tests. J'ai soumis cette solution à mes éminents collègues marmelabien et en fonction de leurs retours, je ferais un petit post spécifique sur l'utilisation de PM2 en environnement de développement et de tests.
Mais voici ce que cela donne pour Road to Caen :
npm install nightwatch selenium-standalone pm2 --save-dev
# rtc_functional_test.json : le fichier de configuration des serveurs de PM2
{
"apps" : [{
"name" : "rtc-static-server", // le nom du serveur
"script" : "./node_modules/.bin/http-server", // l'appel au serveur
"args" : ["./build", "-p 8088", "--silent"], // les options
"instances" : 1
}]
}
# nightwatch.json : le fichier de configuration de Nightwatch
{
"src_folders": ["./test-functional/"], // repertoire des tests fonctionnels
"output_folder": "./test-functional/reports", // repertoire des rapports de tests
"custom_commands_path": "",
"custom_assertions_path": "",
"globals_path": "",
"selenium": {
"start_process": true, // Nightwatch se charge de lancer et d'eteindre selenium !
"server_path": "./node_modules/selenium-standalone/.selenium/selenium-server/2.48.2-server.jar",
"log_path": "",
"host": "127.0.0.1",
"port": 4444,
"cli_args": {
"webdriver.chrome.driver": "./node_modules/selenium-standalone/.selenium/chromedriver/2.21-x64-chromedriver",
"webdriver.ie.driver": ""
}
},
"test_settings": {
"default": {
"launch_url": "http://localhost:8088",
"selenium_port": 4444,
"selenium_host": "localhost",
"silent": true,
"firefox_profile" : false,
"desiredCapabilities": {
"browserName": "chrome",
"javascriptEnabled": true,
"acceptSslCerts": true
},
"screenshots": {
"enabled": true,
"path": "./test-functional/screen-caps"
},
"desiredCapabilities": {
"browserName": "firefox",
"javascriptEnabled": true,
"acceptSslCerts": true
}
},
"firefox": {
"desiredCapabilities": {
"browserName": "firefox",
"javascriptEnabled": true
}
},
"chrome": {
"desiredCapabilities": {
"browserName": "chrome",
"javascriptEnabled": true
}
}
}
}
# makefile
.PHONY: default install run test build clean-build
install:
@npm install
# On lance l'installation du serveur selenium
@node_modules/.bin/selenium-standalone install --version=2.48.2
test-functional: clean-build
# lancement du build des fichiers statiques finaux identiques à la prod
@make build
# lancement du serveur statique avec pm2 en indiquant le fichier de configuration du serveur
@node_modules/.bin/pm2 start rtc_functional_tests.json
# lancement de nightwatch, qui va se charger de l'instance de selenium
@node_modules/.bin/nightwatch
# arret du serveur statique
@node_modules/.bin/pm2 stop rtc-static-server
# test-functional/home.spec.js : fichier de tests de l'accueil du site
module.exports = {
'Display Home': function (client) {
client
.url('http://localhost:8088')
.pause(1000);
client.expect.element('body').to.be.present.before(1000);
client.expect.element('#start_here').text.to.equal('For the moment, you are in Paris');
client.end();
},
};
Remarque : Je n'ai pas encore trouver comment écrire les tests fonctionnels en ES6 :(
Pour terminer cet environnement de développement ES6, on va ajouter un dernier outil permettant d'être cohérent sur le code styling du projet : ESLint
npm install eslint eslint-config-airbnb eslint-plugin-mocha --save-dev
Eslint se configure dans un fichier .eslintrc
:
{
"extends": "airbnb",
"env": {
"mocha": true,
},
"rules": {
"indent": [2, 4],
},
}
J'utilise le preset airbnb, qui gère bien l'ES6, mais aussi la syntaxe particulière de Redux. Je ne lance pas de tests directement avec ESLint, mais j'utilise avec ATOM le plugin linter-eslint.
Alors, JavaScript fatigue ? Il est vrai que cette phase de bootstraping est assez longue, et oblige à voir et comprendre pas mal d'outils. Un ls
dans le répertoire des node_modules
est plutôt impressionnant ! On est tenté de se dire : tout ça pour générer un (encore) pauvre fichier js final ?
Eh bien oui, mais j'oppose à cela plusieurs remarques.
Tout d'abord, on est en présence d'un environnement de développement complet permettant :
Ensuite, toutes ces librairies et autres modules envahissant le répertoire node_modules
ne sont là que pour l'environnement de développement. Rien de cela n'est visible au final sur ce qui sera mis en production.
C'est donc une espèce d'usine Lego à produire du code final optimisé, pas de la surcharge de code.
Et ceci nous oblige en tant que développeur a bien connaître et si possible à maitriser ces outils, plutôt que de les avoir comme une boite noire intégrée à un IDE :)
En plus, un simple make install
, et "tadam", tout est prêt !
Si vous voulez voir un autre bootstraping encore plus complet, car intégrant un serveur back en Koa et un front en React/Redux, vous pouvez jeter un coup d'oeil à ce dépôt : marmelab/javascript-boilerplate.
Tout le code du bootstrap de Road to Caen
est également disponible sur Github : alexisjanvier/road-to-caen
La prochaine étape, donc le prochain post, abordera la question du déploiement continu du projet.