RequireJS et le code JavaScript modulaire

Publié le Mis à jour le Par

Développer des modules en JavaScript et les charger quand c’est nécessaire, c’est le meilleur moyen de bien organiser son code de manière durable et maintenable. Il existe différents types de modules et différentes modalités de chargement. Aussi nous intéresserons-nous d’abord, dans cet article, à RequireJS et aux modules AMD avant de parler des modules universels. Pas de panique, j’explique.

RequireJS, c’est quoi ?

RequireJS est une bibliothèque qui va vous permettre d’organiser vos modules JS et de gérer leurs dépendances. Chaque module est construit en suivant les règles des modules AMD, puis RequireJS va s’occuper de charger et exécuter ces modules, qu’ils soient repartis dans plusieurs fichiers ou dans un seul. Ça parait simple, dit comme ça, mais RequireJS est un outil plus subtil qu’il n’y parait et on a vite fait de mal s’en servir. Dans cet article nous allons voir les bonnes pratiques d’utilisation et les erreurs fatales à ne pas commettre.

Module or not module

Lorsque l’on fait le choix de RequireJS c’est que l’on veut travailler avec du code modulaire. Le code modulaire, un code isolé avec une répartition des tâches identifiée, s’oppose au code « global », un code qui pollue l’environnement global sans sécurité pour les autres utilisateurs de cet environnement. Dans tout projet vous devez choisir votre camp: modules ou non. Mais si vous essayer de mixer les deux en même temps, vous courrez droit à la catastrophe. Au niveau de l’architecture, les deux approches sont très souvent incompatibles et leur mélange est une source de bugs sans fin, la principale raison tenant à ce que le code modulaire est souvent asynchrone là ou le code global est souvent synchrone. Si le projet utilise RequireJS, vous devez impérativement mettre votre code au sein de modules. Ce n’est pas si compliqué, ça donne ça:

define(['unAutreModule'], function (autreModule) {
  // Le code de mon module ici

  // Pour les autres modules qui voudraient utiliser celui-ci
  return {
    // Ici l'API de mon module pour le monde extérieur
  };
});

La fonction define prend 2 paramètres:

  • Un tableau avec le nom des modules dont vous allez avoir besoin pour faire fonctionner votre propre module (ses dépendances)
  • Une fonction qui contient le code de votre module et qui prend en paramètre l’API de chacun des modules définis dans la liste des dépendances. Elle retournera éventuellement elle-même une API qui pourra être utilisée par d’autre modules. C’est RequireJS qui va gérer l’exécution de cette fonction vous ne pouvez donc pas prédire quand il sera exécuter, c’est du code asynchrone.

Une alternative à la gestion des dépendances peut prendre cette forme:

define(['require'], function (require) {
  var autreModule = require('unAutreModule');

  // Le code de votre module ici

  // Pour les autres modules qui voudraient utiliser celui-ci
  return {
    // Ici l'API de mon module pour le monde extérieur
  };
});

Dans cet exemple la gestion des dépendances est faite de manière explicite via l’appel à la fonction require. Les deux méthodes se valent, mais on verra qu’elles auront une incidence sur les différentes optimisations qu’on pourra amener pour la mise en production. De manière habituelle, on va travailler avec la logique: 1 module = 1 fichier, libre a vous d’organiser l’arborescence des fichiers comme vous le voulez. Cette logique est vivement recommandé si vous voulez optimiser vos fichiers pour la production.

Utiliser RequireJS : intégration HTML et initialisation des scripts

L’utilisation canonique de RequireJS n’est pas très compliquée et ne nécessite finalement que peu de chose à savoir. Le point d’entrée de RequireJS est un fichier qu’on appelle souvent main.js par convention. Il ressemble à ça:

require(
  // La liste des module qui doivent être
  // chargés avant d'initialiser le bazar
  ['monModule', 'monAutreModule'],

  // La fonction qui sera lancée une fois
  // les modules précédents chargés
  function (monModule, monAutreModule) {
    // L'initialisation des modules et
    // de toute votre application ici
  },

  // Une fonction de gestion des erreurs
  // de RequireJS (c-à-d s'il n'arrive pas
  // a charger un fichier, quel qu’en soit
  // la raison)
  function (err) {
    console.error('ERROR: ', err.requireType);
    console.error('MODULES: ', err.requireModules);
  }
);
require(
  // La liste des module qui doivent être
  // chargés avant d'initialiser le bazar
  ['monModule', 'monAutreModule'],

  // La fonction qui sera lancée une fois
  // les modules précédents chargés
  function (monModule, monAutreModule) {
    // L'initialisation des modules et
    // de toute votre application ici
  },

  // Une fonction de gestion des erreurs
  // de RequireJS (c-à-d s'il n'arrive pas
  // a charger un fichier, quel qu’en soit

Dans votre point d’entrée, pensez bien à définir une fonction de gestion des erreurs. En effet, si vous l’oubliez, en cas d’erreur, RequireJS lance une exception qui va brutalement interrompre le script. Dans un tel cas, impossible de savoir quel est le module qui pose problème. Une fois que votre point d’entrée est prêt, vous n’avez plus qu’à l’appeler dans votre page HTML :

<script data-main="scripts/main" src="scripts/require.js"></script>

Là, vous dites au navigateur de charger le fichier ./scripts/require.js puis, une fois que c’est fait, de charger votre fichier ./scripts/main.js et de l’exécuter. A noter que le fichier require.js est celui fourni par RequireJS.

Gérer les bibliothèques tierces qui ne sont pas des modules

Bon, dans la vraie vie on ne travaille pas que avec ses petits modules amoureusement façonnés. On utilise aussi plein de bibliothèques tierces qui ne sont pas forcement elles-mêmes de jolis modules AMD. C’est la que RequireJS offre un vrai avantage par rapport à d’autres systèmes de gestion de modules. En effet, RequireJS va vous permettre de gérer le chargement de vos bibliothèques tierces. Ainsi, si vous voulez utiliser une variable globale définie par cette bibliothèque (au hasard: jQuery) dans un de vos modules, vous avez la garantie qu’elle sera bien disponible au moment d’exécuter le code de votre module. Tout cela se fait via la configuration de RequireJS qu’on va également mettre dans notre fichier main.js. Voila par exemple comment gérer jQuery et d’éventuels plugins:

require.config({
  // Le paramètre baseUrl permet de spécifier un
  // préfixe à appliquer à tous les chemins
  // qu'utilisera RequireJS pour résoudre les chemins
  // d'accès aux modules. S'il n'est pas précisé,
  // tous les chemins sont résolus par rapport à
  // l'emplacement du fichier main.js. Dans cet
  // exemple on considère que tous nos modules sont
  // dans le répertoire 'lib', lui même au même
  // niveau que notre fichier main.js
  baseUrl: 'lib',

  // l'objet path va permettre de faire une
  // association entre un nom de module et son
  // chemin d'accès. Si vous ne faite pas ça,
  // RequireJS considère que le nom d'un module
  // est le nom du fichier (sans l'extension .js)
  // précédé de son chemin relatif par rapport au
  // fichier main.js
  path: {
    // Ici, avec notre baseUrl, on indique que le
    // fichier qui contient jQuery est situé à
    // l'emplacement: ./lib/vendors/jquery.min.js
    'jquery' : 'vendors/jquery.min',
    'jquery.equalize': 'vendors/plugin/ca.jquery.equilize',
    'jquery.carousel': 'vendors/plugin/carousel',
    'trucSale' : 'vendors/truc-sale'
  },

  // l'objet shim va permettre de spécifier les
  // dépendances entre fichiers qui n'utilisent
  // pas la fonction define et associer d'éventuelles
  // variables globales à un nom de module.
  shim: {
    // Pour les scripts qui exposent une variable
    // globale il faut la déclarer explicitement
    trucSale: {
      exports: 'laVariableDeTrucSale'
    },

    // Pour les bibliothèque qui en ont besoin,
    // un simple tableau de dépendances permettra
    // à RequireJS d’ordonnancer les chargements
    jquery.equalize: ['jquery'],

    // Si vous devez supporter IE8 ou 9, il faut
    // être un peu plus verbeux pour que RequireJS
    // gère les erreurs proprement
    jquery.carousel: {
      deps : ['jquery', 'jquery.equalized'],
      exports: 'jQuery.fn.carousel'
    }
  }
});

Dans l’exemple que nous venons de voir, vous remarquerez que nous n’avons pas mis jQuery dans la partie shim. C’est dû au fait que jQuery est bien fichu et utilise la fonction define pour se référencer comme un module AMD. Ça, c’est gens qui travaille bien 🙂 Dans la même veine, Underscore et Moment font la même chose. Notez que ce système de dépendance permet d’ordonnancer le chargement de n’importe quel fichier JS, y compris des fichiers que vous n’utiliserez jamais comme des modules (par exemple un player vidéo fourni par un OVP quelconque qui dépendrait de jQuery et qui s’initialise tout seul… le point clé dans la phrase précédente c’est: « qui dépendrait de jQuery »).

Optimiser RequireJS

Normalement, jusque là vous devriez vous en sortir assez bien (si ce n’est pas le cas, passez directement à la partie: « Oups, ça marche pas! »). Voyons comment optimiser un peu tout ça. Par optimiser, on veut dire concaténer et minifier nos modules en un ou plusieurs fichiers histoire d’optimiser les temps de chargement du navigateur.

En ligne de commande

RequireJS fournit un outil qui fait le boulot pour nous, le script r.js. Il s’utilise très simplement avec Node.js:

$ npm install -g requirejs
$ r.js -o build.js

Dans la dernière commande, r.js c’est l’optimiseur qui a été installé et build.js est un fichier de configuration de l’optimisation. Tous les paramètres de configuration peuvent être passés en ligne de commande. Cependant, de manière générale, c’est une bonne idée de créer un fichier de configuration que vous versionnerez avec le reste du projet. De cette manière, tout le monde pourra produire les mêmes optimisations (et éventuellement les automatiser via un serveur d’intégration continue). A partir de là qu’est-ce qu’on peut faire ? Voici quelques exemples de configuration typique (notez que tous ces fichiers de configuration partent du principe que build.js est dans le même répertoire que main.js).

Tous les modules dans un seul fichier

({
  name: 'main',
  out: 'main.min.js',
  mainConfigFile: 'main.js',

  // Uniquement si vous avez envie
  // de faire de la minification
  optimize: "uglify2",
  generateSourceMaps: true
})

Vraiment tout dans un seul fichier

On peut encore aller plus loin en forçant toutes les ressources (même celles chargées conditionnellement) dans un unique fichier. On peut même inclure RequireJS lui-même dedans pour arriver a garantir qu’un seul et unique fichier sera chargé. Si.

({
  name: 'main',
  out: 'main.min.js',
  mainConfigFile: 'main.js',

  // Aller, on embarque RequireJS
  path: {
    requireLib: 'require'
  },
  include: ['requireLib']

  // Les options de l’extrême:

  // Si vous utilisez le plugin Text,
  // ça va forcer l'ajout de ces contenus
  // text dans le fichier résultant
  inlineText: true,

  // Ça c'est si vous utiliser la fonction
  // require à l’intérieur de vos modules.
  // Même si vous appelez vos modules de
  // manière conditionnelle, ils seront
  // embarqués dans le fichier résultant
  findNestedDependencies: true
})

Si vous embarquez RequireJS directement dans votre fichier optimisé, vous devez également changer votre appel au script dans votre code HTML:

<script src="scripts/main.min.js"></script>

Un cœur en cache et des ensembles modulaires chargés à la demande

Alors oui, c’est possible mais je ne vous l’expliquerai pas dans cet article. Si vous êtes impatient et que vous voulez voir ce que ça donne, vous pouvez jeter un coup d’œil à l’exemple donné dans la documentation officielle. Cependant la solution la plus simple étant d’avoir tous les module indispensables (le cœur) chargés par le fichier main.js puis d’utiliser la fonction require dans vos modules pour charger uniquement les modules nécessaires à la demande.

Automatisation

Évidement, je ne m’étale pas dessus mais l’utilisation de RequireJS peut s’automatiser avec vos outils favoris :

Oups! Ça marche pas!

Voila, tout ça, c’est dans un monde idéal. Maintenant voyons quelques bêtises qui vont mettre le bazar dans votre joli code.

Mélanger RequireJS avec d’autre scripts

En théorie, ça ne devrais poser aucun problème… en théorie. En fait, ça marchera uniquement si les modules de RequireJS n’interagissent jamais avec les scripts tiers. L’exemple classique est l’utilisation de jQuery. Si vous avez un script tiers qui utilise jQuery et que vous avez des modules chargés via RequireJS qui utilisent des scripts, il y a 99% de chance que vous deviez faire face à une Race Condition. Je m’explique.

  1. Au chargement de la page, le script tiers voit que jQuery n’est pas là et commence à charger jQuery et les plugins dont il aura besoin.
  2. Plus ou moins au même moment RequireJS, en suivant son arbre de dépendance, va également commencer à charger jQuery et les plugins dont il a besoin.
  3. Les aléas réseau aidant, un des jQuery chargé arrive et créé la variable globale jQuery
  4. Certains plugins commencent à arriver et sont rattachés à l’objet jQuery
  5. Ah, mais voila que l’autre jQuery arrive et écrase la variable globale jQuery
  6. Finalement, le reste des plugins manquants arrivent, mais les premiers ont été « oubliés » lorsque la variable jQuery a été écrasée

Et voila comment on se retrouve avec la moitié de ses plugins jQuery manquants et des erreurs plein la console qui ne seront jamais les mêmes à chaque chargement de la page. Super ! Quand on vous dis que les variables globales, c’est le mal. C’est un exemple bateau mais qui peut arriver à la seconde où une partie de vos scripts n’est pas gérée par RequireJS. Le plus drôle avec ce bug c’est qu’il y a peu de chance que vous le voyez avant d’être en recette ou pire en production avec de véritables conditions réseau. Corriger un Race Condition en production le jour de l’ouverture au public… le mot pression prend une toute autre dimension. On l’a déjà vu, mais souvenez vous: RequireJS est un ordonnanceur de chargements. Si vous ne l’utilisez pas, rien ne peut vous garantir un ordre de chargement.

Création de module conditionnel

if (document.body.className === 'tralala') {
  define(...);
}

RequireJS ne permet pas de créer des modules de façon conditionnelle, il permet seulement d’utiliser des modules de manière conditionnelle. En effet, tester de manière synchrone l’opportunité de créer un module qui sera utilisé de manière asynchrone va conduire à ne pas avoir le module en question au moment ou on en aura besoin. Inversement, il peut être créé alors qu’on en aura jamais besoin. Une alternative rigolote (mais tout aussi buguée) à la définition de module conditionnel, c’est la dépendance conditionnée à l’utilisation d’une variable de configuration globale (mmh…):

define(['module.' + $VERSION], function (module) {
  // Tu le vois mon gros bug!
});

Dans ce cas-là, allez allumer un cierge pour espérer que le module sera bien chargé après la définition de la variable. Et si par hasard c’est le cas, cela vous empêche d’optimiser votre fichier comme vu ci-avant. En effet, au moment de l’optimisation, vous pouvez être sûr à 100% que la variable globale n’est pas définie. La seule façon de gérer ces problèmes, c’est comme ça:

define(['require'], function (require) {
  // C'est seulement à ce moment-là qu'on
  // est sûrs de l'état qu'on teste et si
  // le module est dans un fichier séparé,
  // il ne sera chargé qu'à ce moment-là.
  if (document.body.className === 'tralala') {
    autreModule = require('unAutreModule');
  }

  // ...
});

Pour conclure sur RequireJS

J’espère que tout ça vous servira de kit de survie pour les projets qui utilisent RequireJS. Cela ne vous dispense pas de lire la documentation mais normalement, vous devriez éviter les grosses tartes à la crème qui jonchent ce genre de projets. En particulier, en cas de problème, la FAQ sur les erreurs les plus communes est bien fichue. Lisez-là !

Vous noterez que je n’ai pas parlé des plugins qui sont disponibles (text, json, i18n, etc.). Il y en a trop et cet article est bien trop long mais n’hésitez pas à aller regarder ce qu’ils ont dans le ventre car il peuvent rendre la vie bien plus facile 😉

Le concept de module universel

Nous n’avons abordé ci-dessus que les modules AMD chargés par RequireJS. Le problème c’est qu’il existe moult autres façons de faire des modules en JavaScript: CommonJS, IIFE, ES2015. Dans un projet en général, on utilise un seul type de module. D’ailleurs, chaque type de module a sa bibliothèque de choix pour pouvoir les utiliser sans se poser trop de questions :

  • CommonJS: Browserify
  • ES2015: Hmm… on va y revenir, c’est encore un peu compliqué.
  • IIFE: Euh… là vous n’avez besoin de rien de spécial.

Jusque là, ça va… il vous faut « juste » connaitre 4 façons différentes de faire des modules (sans parler des nuances d’implémentation comme les plugins jQuery qui sont une variante des modules à base de IIFE, ou les nuances entre les modules CommonJS et les modules NodeJS). Là où ça va devenir franchement pénible c’est quand on va vouloir réutiliser des modules d’un projet à l’autre: il va falloir changer l’emballage du bonbon à chaque fois, c’est nul ! Pour résoudre se problème, on entend de plus en plus parlé d’UMD (Universal Module Definition) qui est une façon d’écrire un module de manière à ce qu’il soit compatible avec tous les systèmes de modules existants. Concrètement ça peut ressembler à ça:

(function (name, factory) {
  'use strict';

  // NodeJS
  if (module && module.exports) {
    module.id = name;
    module.exports = factory();
  }

  // CommonJS 1.1
  else if (module && exports) {
    module.id = name;
    exports = factory();
  }

  // AMD
  else if (typeof define === 'function') {
    define(name, factory);
  }

  // Quand rien d'autre n'est possible,
  // on créé une variable globale
  else if (window) {
    // On definie `require` pour gérer les
    // dépendances de la même façon que les autres
    window.require = window.require ||
    function (module) { return window[module]; };

    window[name] = factory();
  }
}('monModule', function () {

  // Le code de votre module vie ici ou vous
  // utiliserez la fonction require pour appeler
  // vos éventuelles dépendances

  return {
    // Libre a vous de retourner une API pour accéder à votre module
  };
}));

Un module créé de cette manière peut être utilisé avec Browserify, RequireJS ou même sans rien. Le seul problème connu à ce jour avec UMD c’est qu’il n’est pas possible de supporter aussi le standard ECMAScript 2015. En effet, les prérequis d’utilisation de ce standard le rendent incompatible avec les autres définitions de modules, en outre le support navigateur n’est pas encore au rendez-vous. Cependant, il est tout de même possible d’utiliser les modules ES2015 et de les convertir en une autre forme de module grâce à BabelJS (BabelJS est un transpiler qui convertit du code ES2015 en ES5). Ainsi si vous voulez créer des modules utilisables partout vous avez deux choix :

  • mettre directement votre module dans une enveloppe UMD (c’est ce que font jQuery, Underscore, Moment et bien d’autres) ;
  • écrire votre module en suivant le standard ES2015 et automatiser sont enrobage UMD (que ce soit en utilisant BabelJS, vos outils de build favoris: Grunt, Gulp, Brunch, etc. ou bien en utilisant les nouveaux outils à la mode: SystemJS ou Webpack).

En travaillant avec des modules universels, vous gagnez en souplesse car vous pouvez changer votre stratégie de gestion de modules à tout moment de votre projet, ce n’est pas à négliger. Vive les modules !