Leaflet et browserify: améliorez vos pratiques de développement

Depuis quelques temps déjà nous regardons l'activité assez hallucinante en terme de production de code de la société MapBox (qui fait un produit éponyme MapBox.JS basé sur Leaflet).

Nous avons constaté qu'ils s'inspiraient beaucoup d'outils venant du monde de Node JS, qui permet du développement JavaScript côté serveur. Ce développement de Node JS s'est accompagné de nouveaux outils qui permettent d'améliorer son processus de travail pour le développement d'interfaces cartographiques dans le navigateur. L'un des outils les plus importants est Browserify. Celui-ci permet avec NPM (le gestionnaire de bibliothèques de Node JS) de facilement modulariser son développement. Nous allons expliquer à quoi il peut servir en illustrant ce cas en passant d'un code avec Leaflet "simple" à un autre qui utilise Browserify.

Cet article prend principalement son inspiration de cet article anglais.

Nous allons donc voir comment on faisait "Avant"

Avant

On considère qu'on ne veut pas s'appuyer sur un CDN (une url qui permet de ne pas télécharger la bibliothèque) car celui-ci bien que plus performant nous oblige d'avoir Internet, implique d'augmenter le nombre d'appels à des fichiers externes. Sur fixe, ce n'est pas le plus problématique mais si vous ciblez le mobile, la latence sur ces supports est mauvaise (la latence est le temps qu'il faut pour votre navigateur appelle une ressource et la récupère). Pour la contrer, il faut diminuer le nombre d'appels.

Les étapes classiques

Sans se préoccuper du language de programmation choisi côté serveur, les étapes classiques pour débuter un projet sont du type:

  • création du dossier racine du projet

  • création d'un fichier index.html à la racine du dossier créé

  • création d'un répertoire nommé static (certains préfèrent assets comme nom) qui contient un répertoire vendor pour les bibliothèques ne faisant pas partie du code de l'application ainsi qu'un répertoire js, css et img (ou images) et potentiellement un répertoire data si vous avez des GeoJSON, des CSV ou des KML dans la nature.

  • téléchargement de vos bibliothèques externes dans le répertoire vendor (il y a surement d'autres conventions).

Au final, vous utilisez de nombreuses bibliothèques, vous allez devoir télécharger, dézipper puis gérer les versions de vos bibliothèques vous-même. (en supposant que vous utilisez un gestionnaire de version type Git ou SVN) puis référencer chacune des resources (css, js principalement) dans votre fichier index.html.

L'inconvénient, c'est la gestion de dépendances de vos bibliothèques. Si le projet change de main, que vous déléguez ou partez du projet, ceux qui passeront derrière vont devoir retrouver chaque version des bibliothèques utilisées.

Par ailleurs, vous ne compressez pas par défaut votre code si vous en avez besoin pour un déploiement en production. Vous vous dites "je le ferais quand on sera en production" et au final, le code restera comme ça (PS: d'expérience, ça m'est arrivé...). Pour les performances comme la maintenabilité du code, on repassera...

Code et structure classique d'un projet JavaScript

Voici un petit exemple de structure "classique" avec Leaflet reprenant quelques idées de OpenBeerMap mais de manière plus simplifiée :

Nous utilisons en plus de Leaflet, le plugin "Leaflet Dynamic JSON Layer" pour appeler les données de l'Overpass API (les données de OpenStreetMap (OSM) en tant que service).

Structure projet Leaflet classique sans Browserify

index.html

<!doctype html>
<html>
<head>
  <title>Exemple de Leaflet sans Browserify</title>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="static/vendor/leaflet/leaflet.css">
  <link rel="stylesheet" href="static/css/style.css">
  <script src="static/vendor/leaflet/leaflet.js"></script>
  <script src="static/vendor/leaflet-layerjson/leaflet-layerjson.min.js"></script>
</head>
<body>
  <div id="map"></div>
  <script src="static/js/app.js"></script>
</body>
</html>

style.css

#map {
  height: 800px;
}

app.js

// Instancier la carte 
var map = L.map('map', {
  scrollWheelZoom: false
});

// Donner un centre et un zoom à la carte
map.setView([47.2061, -1.5519], 13);

// Passer une chaine pour l'attribution
var attribution = 'Tuiles mise à disposition par <a href="http://openstreetmap.se/" target="_blank">OpenStreetMap Suède</a> &mdash; Données <a href="http://www.openstreetmap.org/copyright">&copy; les contributeurs OpenStreetMap</a>';

// Déclarer le motif pour les urls des tuiles qui seront utilisées
var tiles = 'http://{s}.tile.openstreetmap.se/hydda/full/{z}/{x}/{y}.png';

// Créer la couche de tuiles en passant l'url des tuiles, les sous-domaines,
// le seuil de zoom et enfin l'attribution
var layer = L.tileLayer(tiles, {
  subdomains: "1234",
  maxZoom: 18,
  attribution: attribution
});

// Enfin, terminer en ajoutant la couche avec les tuiles sur la carte
layer.addTo(map);

// On construit l'icone une seule fois comme elle on a choisi de faire
// un point = même icone dans tous les cas
var icon = new L.Icon({
  iconUrl:'static/img/beer1.png',
  iconSize: new L.Point(32, 37),
  iconAnchor: new L.Point(18, 37),
  popupAnchor: new L.Point(0, -37)
});

// Déclaration d'une couche qu'on appelle via un webservice puis qu'on ajoute à la carte
L.layerJSON({
  url: 'http://overpass-api.de/api/interpreter?data=[out:json];node({lat1},{lon1},{lat2},{lon2})[amenity=bar];out;',
  propertyItems: 'elements',
  propertyTitle: 'tags.name',
  propertyLoc: ['lat','lon'],
  buildIcon: function(data, title) {
    return icon;
  },
  buildPopup: function(data, marker) {
    return data.tags.name || null;
  }
}).addTo(map);

Pour l'image du verre de bière, la source est https://openbeermap.github.io/assets/img/beer1.png et elle est placée dans le répertoire static/img.

Il a fallu télécharger deux bibliothèques js. Pour la deuxième, nous n'avons gardé qu'un fichier plutôt que l'ensemble des fichiers.

Le résultat visuel obtenu va être du type ci dessous :

Structure projet Leaflet classique sans Browserify

Avant de passer à l'après, il faut noter que nous avons pollué l'objet global: tapez map dans la console du debugger du navigateur et vous allez voir... Cette pratique d'utiliser des variables globales en JavaScript est considérée comme mauvaise: le nom des variables dans plusieurs parties du code peuvent être les mêmes et générer des erreurs dans votre application. Si vous êtes sur une petite application gérée seule, vous arriverez peut être à éviter les problèmes mais si vous êtes plusieurs développeurs ou même que votre application grossit, vous allez rapidement comprendre l'intérêt de cette pratique.

La façon la plus courante de régler ce cas est de tout mettre dans une IIFE ("Immediately-invoked function expression"): vous entourez tout le code JavaScript précédent en commençant par (function() { et en terminant par })();. L'inconvénient dans ce cas est qu'on ne peut plus accéder aux variables pour manipuler la carte ultérieurement. Généralement, on fait un compromis qui permet de gérer des variables publiques et privées en changeant la forme de l'IIFE. Pour cela, voir le lien wikipédia sur l'IIFE, plus complet ainsi que les design patterns "Module" et Revealing Module

L'après

Même si nous allons vous présenter Browserify, il existe un panel d'outils complémentaires mais que nous n'allons pas aborder: l'article déjà assez long deviendrait "imbuvable". Regardez les mots clés Bower, ComponentJS, Gulp, Grunt en particulier.

De nouvelles dépendances dans vos outils

Vous allez comparativement au cas avec Leaflet "simple" devoir sortir la "machine de guerre". Nous entendons par là que vous n'allez pas utiliser seulement un éditeur de texte et votre navigateur pour travailler.

Le développement avec Browserify s'appuie sur Node JS. Sans dire de faire du code JavaScript côté serveur (une voire la raison initiale de Node JS), il devient de plus en plus incontournable pour le développement côté client. En effet, il permet d'utiliser des fonctions très pratiques pour vous rendre productif (indépendamment d'être dans un contexte cartographique). D'ailleurs, cette tendance se confirme comme le montre cet article du blog NPM (l'installateur de bibliothèques de Node JS)

Pour ne pas faire que du théorique, installons NodeJS

Installez Node JS

Allez sur http://nodejs.org, cliquez sur le bouton INSTALL. Normalement, le navigateur va détecter si vous êtes sous Windows, Linux ou Mac. Pour Linux ou Mac, suivez le tutoriel de OpenClassRooms

Sous Windows, le téléchargement doit être un fichier msi ou exe. Si ce n'était pas le cas, vous devriez aller sur la page DOWNLOADS puis allez cliquer sur Windows Installer (.msi) en 64bits. Certains tutoriels privilégient la version 32 bits mais normalement avec un PC de moins de 5 ans, vous êtes en 64 bits. Exécutez-le fichier téléchargé dans tous les cas.

Ouvrez un terminal et lancer

node -v

S'il n'y a pas d'erreurs, vous devriez voir un numéro de version retourné comme v0.10.33.

Le chargement des modules

Il faut comprendre que quand on développe "habituellement" (sans Browserify), on doit gérer les dépendances entre les différents fichiers JavaScript. si vous développer avec 30 fichiers js, vous devez inclure 30 balises <script src=""> Cela n'est pas très pratique et quand vous allez devoir compresser les fichiers, il faudra faire des changements pour ne faire plus qu'un appel à un seul script.

AMD

Vous pouvez aussi gérer les dépendances avec un standard comme AMD. Honnêtement, nous ne le trouvons pas la syntaxe très lisible (une question de goût et d'habitude onc inutile de "troller") car il faut déclarer à la main les dépendances au début des fichiers plutôt qu'au moment où on en a besoin.

Vous trouverez un exemple ci-dessous pour vous donner un aperçu de la syntaxe. Il pourrait être traduit "Quand la page est chargée, appelez ./js/common.js. Lorsque ce dernier est chargé, la racine pour appeler les fichiers js est déclaré à /js (grâce à une instruction require.config non présentée dans l'exemple) et on appelle un deuxième fichier depuis js/app/main.js"

app.js

require(['./js/common'], function (common) {
  //js/common définit la baseUrl à js/ ainsi on ne peut
  //demander que 'app/main1' ici plutôt que 'js/app/main1'
  require(['app/main1']);
});

L'intérêt de AMD est qu'il permet de faire des appels conditionnels: vous pouvez ne charger qu'une partie du JavaScript et quand vous en avez besoin, vous pouvez grâce au système de dépendances, charger de nouvaux fichiers au moment où vous utilisez une fonction particulière par exemple.

Nous pensons néanmoins qu'il n'y a pas que la taille du fichier JavaScript initial chargé qui compte mais aussi le nombre d'appels à différents fichiers JS: cela joue sur la latence déjà évoquée (le temps de demander une ressource puis de l'obtenir, voir l'article de Wikipédia). Le chargement asynchrone AMD perd alors une partie de son intérêt.

Il faut aussi noter que vous pouvez ne pas utiliser le chargement asynchrone avec AMD et compacter tous les fichiers en un et qu'une solution intermédiaire peut être de compacter certains groupes de fichiers js et de continuer à en charger quelque uns de manière asynchrone. Par ailleurs, vous pouvez gérer la dépendance sur des CSS. Comme quoi, ce n'est pas notre préférence mais l'apport d'AMD reste intéressant.

CommonJS, une autre choix pour gérer des dépendances

Une solution alternative est d'utiliser CommonJS, un standard utilisé par Node JS qui permet de gérer les dépendances. C'est ce système qui est utilisé par Browserify.

Pour reprendre l'exemple précédent, vous pouvez faire pour déclarer la dépendance, en suivant ce standard, ajouter dans le fichier app/main1.js

var common = require('./js/common');

Mais Browserify n'offre pas que le support pour le rôle de gestion des dépendances, il permet aussi de créer des modules ou bien de faire l'équivalent des "include" (pour ceux qui connaissent PHP) c'est à dire de faire d'appeler d'autres fichiers dans un fichier et donc de modulariser votre code facilement.

Changez les fichiers pour utiliser NPM

Revenons à notre exemple Leaflet et changeons les choses pour être "compatible Browserify".

Créons un fichier package.json pour gérer les dépendances dans NPM (gestionnaire de dépendances de Node JS)

Pour cela, lancer à la racine du projet, en ligne de commande:

npm init

et répondre aux questions. Pour le fichier principal, remplacez index.js par app.js

Ne vous inquiétez pas trop, il n'y a pas de mauvaises réponses! Pas de bonnet d'âne!

Vous devriez avoir un nouveau fichier package.json similaire à ci-dessous :

{
  "name": "leaflet_browserify",
  "version": "0.0.0",
  "description": "Leaflet browserify demo",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "leaflet",
    "map"
  ],
  "author": "Thomas Gratier",
  "license": "MIT"
}

Pour l'instant, il nes sert à rien, juste à déclarer le nom du projet (sauf si par exemple, nous voulions le mettre à disposition sur https://www.npmjs.org, le site qui sert à partager les paquets Node JS)

Ajoutons lui les dépendances pour charger Leaflet et leaflet-layerjson avec

npm install leaflet leaflet-layerjson --save

Inspectez le fichier package.json pour voir des changements avec l'ajout d'un bloc après la licence:

...
"dependencies": {
  "leaflet": "^0.7.3",
  "leaflet-layerjson": "^0.1.5"
}

Constatez l'apparition d'un répertoire node_modules qui contient lui-même deux répertoires leaflet et leaflet-layerjson. En fait, npm install leaflet leaflet-layerjson sert à dire "récupère-moi les paquets leaflet leaflet-layerjson" et l'option --save sert elle à dire "garde-moi la trace de ces dépendances entre mon projet et les paquets dans le fichier package.json" (le bloc dependencies)

Normalement, votre projet dans le gestionnaire de version doit ignorer le répertoire node_modules sauf cas particulier (cherchez le mot clé .gitignore dans un moteur de recherche pour en savoir plus si vous utilisez Git)

Installation de Browserify et Beefy

Maintenant, installons Browserify et un autre utilitaire nommé Beefy (installez-le, ne réfléchissez pas pourquoi pour le moment) avec:

npm install browserify beefy --save-dev

Nous n'avons pas utilisé l'option -g qui veut dire globale, c'est à dire que les utilitaires ne seront pas spécifiques à ce projet (un répertoire avec package.json à la racine). Il vaut mieux éviter les effets de bord. Browserify ou Beefy peuvent être utilisés sur d'autres projets sur votre machine et si la version change entre les projets vous allez probablement avoir des erreurs.

Changement du code v1

remplaçons le code de index.html avec:

<!doctype html>
<html>
<head>
  <title>Exemple de Browserify avec Leaflet</title>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet" href="node_modules/leaflet/dist/leaflet.css">
  <link rel="stylesheet" href="static/css/style.css">
</head>
<body>
  <div id="map"></div>
  <script src="static/js/bundle.js"></script>
</body>
</html>

Dans app.js, on ajoute au début du fichier

// Ajouter Leaflet.js
var L = require('leaflet');
// Puis le plugin pour le JSON (utilisé pour l'Overpass API)
require('leaflet-layerjson');

// Indiquer le chemin vers le dossier d'images de Leaflet
L.Icon.Default.imagePath = 'node_modules/leaflet/dist/images/';

La première ligne indique qu'on veut charger Leaflet et qu'on affectera le retour de Leaflet à la variable L (celle par défaut déjà utilisée par Leaflet).

La deuxième permet de faire comme un include donc d'insérer le code de leaflet-layerjson directement au fichier app.js. On devrait être normalement obligé de déclarer explicitement le chemin vers le fichier leaflet-layerjson.min.js car le fichier package.json dans node_modules/leaflet-layerjson ne contient pas de bloc main qui permet de faire un simple require('leaflet-layerjson'); par défaut (une PR a été ouverte).

Heureusemement, il est possible pour éviter ce problème, de créer un alias pour cela en utilisant le block browser dans package.json (comme ci-dessous)

"dependencies": {
  "leaflet": "^0.7.3",
  "leaflet-layerjson": "^0.1.5"
},
"browser": {
  "leaflet-layerjson": "./node_modules/leaflet-layerjson/dist/leaflet-layerjson.min.js"
},

Dans les deux cas avec require, le code est pris depuis les répertoires dans node_modules.

La dernière ligne de app.js, elle, permet si on utilise les icônes par défaut de Leaflet, d'avoir le "bon" chemin vers les images.

Maintenant, il est de temps de lancer Browserify en ligne de commande

./node_modules/.bin/browserify static/js/app.js > static/js/bundle.js

Ouvrez dans votre navigateur index.html et vous devriez avoir un rendu identique à précédemment. Ce qui change ici c'est qu'on n'appelle plus qu'un fichier JavaScript au lieu de trois en passant par require et on rassemblé les trois fichiers en un grâce à Browserify. Il faut noter qu'un truc moins sympa est la référence au fichier CSS dans node_modules/leaflet/dist/leaflet.css et qu'on a pour le moment 2 CSS alors qu'on pourrait aussi compacter.

Vous allez nous demander, que vous vous voyez mal faire le "c.." à chaque fois à relancer la ligne de commande de Browserify au moindre changement.

C'est justement là qu'intervient Beefy, c'est un utilitaire qui permet de surveiller les changements sur des fichiers, de recharger le navigateur lors de changement de code (à la "LiveReload", un autre utilitaire pour cela) et de servir les pages comme si on avait déjà déployé sur un serveur du type Apache ou Nginx (2 des serveurs web les plus répandus du Net)

./node_modules/.bin/beefy static/js/app.js:static/js/bundle.js --live --open

La ligne dit "prend-moi le contenu de static/js/app.js, met-le dans un nouveau fichier static/js/bundle.js, sers-moi le contenu à l'adresse http://127.0.0.1:9966 (par défaut), ouvre-le navigateur et recharge la page si il y a des changements

Le navigateur par défaut doit s'ouvrir tout seul sinon ouvrez-le à l'adresse http://127.0.0.1:9966 et voyez le résultat.

Essayez par exemple d'éditer le fichier app.js en ajoutant la chaine "console.log(L.Icon.Default.imagePath);" après la ligne L.Icon.Default.imagePath = ... et observez le navigateur recharger la page si vous sauvegardez le fichier.

Vous pouvez aussi supprimer le répertoire static/vendor car il ne sert plus à rien maintenant.

Changement de code v2

On peut procéder à plusieurs améliorations :

  • ne plus avoir à taper le code chaque fois pour lancer Beefy et/ou Browserify en sauvegardant les instructions pour les lancer
  • "minifier" le js dans Browserify: celui-ci est rassemblé dans un seul fichier bundle.js mais sa taille n'est pas optimisée. La minification, c'est le fait de compacter (en supprimant espaces et retours à la ligne inutiles) et parfois de renommer les variables en des noms plus courts (on parle d'obfuscation) afin de limiter la taille du JavaScript qui sera chargé dans le navigateur.
  • "Assembler et minifier le css en un"

On va utiliser une fonctionnalité de package.json. Il est possible d'ajouter une bloc scripts qui contient des clés valeurs avec la clé pour le nom de la commande et la valeur pour la commande qu'on va exécuter, associée à la clé.

Par exemple, ajoutez dans le bloc scripts existant:

"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "echoer": "echo \"It's working\""
}

Puis pour voir le fonctionnement, lancez :

npm run echoer

ou son équivalent, plus long:

npm run-script echoer

Vous allez voir que le code est bien exécuté. Il faut noter que même si le nom de la clé dans le bloc scripts peut être arbitrairement choisi, certains noms de clés ont un rôle particulier (voir https://www.npmjs.org/doc/misc/npm-scripts.html à ce propos) ainsi dans scripts, remplacez les deux lignes par :

"start": "./node_modules/.bin/beefy static/js/app.js:static/js/bundle.js --live"

Puis lancez:

npm start -- --open

Celui-ci équivaut à lancer npm run start ou npm run-script start mais en passant l'option --open en plus (depuis la version 2.0 de NPM)

Maintenant, on va passer à la gestion des CSS et à minification en général. Pour cela, il faut installer deux outils supplémentaires:

npm install clean-css --save-dev
npm install uglify-js --save-dev

Au niveau de package.json, on ajoute après la section start ce qui suit (en n'oubliant pas la virgule à la fin de la ligne start):

"deploystatic": "npm run minifycss && npm run bundle",
"prestart": "npm run minifycss",
"bundle": "./node_modules/.bin/browserify static/js/app.js | ./node_modules/.bin/uglifyjs > static/js/bundle.js",
"minifycss": "./node_modules/.bin/cleancss -o static/css/styles.min.css static/css/style.css"

Changer dans les références, au niveau du fichier index.html, les appels à style.css par styles.min.css

Au final, ce qu'on a fait, c'est préparer

Quelques pistes à explorer

Dans les idées à explorer, vous pourriez utiliser jshint ou htmlhint pour améliorer la qualité de votre code.

Vous devriez aussi apprendre à rendre compatible des modules initialement incompatibles avec Browserify (on est gentil, les liens fournis donnent de quoi faire).

Vous pouvez aussi vous pencher sur UMD (Universal Module Definition), qui permet d'unifier la gestion des modules AMD, CommonJS.

Vous pouvez aussi jeter un oeil au support des modules dans la version à venir de JavaScript (ES6)

Le code est disponible sur Github https://github.com/ThomasG77/leaflet-browserify avec des tags before et after pour facilement vous répérer.

N'hésitez pas à commenter ici ou à nous faire un retour sur le compte Twitter ThomasG77.

Réferences utilisées et/ou liées pour aller plus loin:

Français:

Anglais:

Comments