Les modules en Javascript

Aug 13, 2021 • Guillaume Nury

Qu'est-ce qu'un module ?

Avant de faire le tour des différents systèmes de module, rappelons ce qu'est un module. Voici un extrait de la définition de la programmation modulaire de Wikipedia :

En informatique, la programmation modulaire reprend l'idée de fabriquer un produit (le programme) à partir de composants (les modules).

Elle décompose une grosse application en modules, groupes de fonctions, de méthodes et de traitement, pour pouvoir les développer et les améliorer indépendamment, puis les réutiliser ailleurs.

Les modules sont donc obligatoires dès que la base de code de notre application grossit, pour organiser notre code.

Différents systèmes

Plusieurs systèmes ont vu le jour, chacun corrigeant les manques du système précédent.

Browser scripts

Une première façon de séparer notre code est de créer un fichier par module, puis de les charger via une balise script.

<html>
    <body>
        <script src="./jquery.js"></script>
        <script src="./app.js"></script>
    </body>
</html>

Il y a de gros inconvénients :

  • tout est dans le namespace global (object window dans le navigateur). Il peut donc y avoir des conflits de nommage et il est impossible d’avoir des méthodes non exposées.
  • l’ordre des imports est important. Dans l’exemple ci-dessus, le fichier app.js s’il utilise des fonctions de jQuery doit être importé après.

Pour éviter les conflits de nommage, il était courant d’exposer un unique objet contenant toutes les méthodes. Par exemple, le $ de jQuery et le _ de Underscore.js.

Revealing Module Pattern

Pour éviter l’exposition de l’ensemble des fonctions et des variables, il est possible de les encapsuler dans une IIFE (Immediately Invoked Function Expression). Le but est de créer une fonction qui va être immédiatement appelée.

Dans l’exemple suivant, la variable SECRET et la fonction sum() ne sont pas accessibles en dehors de la fonction qui encapsule le tout :

(function() {
    const SECRET = 42;

    function sum(a, b) {
        return a + b;
    }
})();

Si cette méthode permet de ne pas polluer le namespace global, elle n’est pas suffisante pour faire un système de module efficace. En effet, il n’est en l’état pas possible d’exposer des fonctions ou d’utiliser des fonctions déclarées dans une autre IIFE.

C’est là que le Revealing Module Pattern entre en scène. Le principe est que notre IIFE retourne un objet qui est, en quelque sorte, notre API publique !

const MyMath = (function() {
    // Hoisting baby !
    return {
        sum: sum,
        multiply: multiply,
    };

    function sum(a, b) {
        return a + b;
    }
    
    function multiply(a, b) {
        return a * b;
    }
})();

MyMath.sum(1, 2);

Le mécanisme du hoisting nous permet d’utiliser les fonctions avant qu’elles soient définies. Les déclarations des variables et des fonctions sont exécutées avant leur initialisation. Plus d’informations sur MDN.

On peut gérer les dépendances de la façon suivante :

const MyCalculator = (function(myMath) {
    return {
        compute: compute,
    };

    function compute() {
        return myMath.sum(10, 32);
    }
})(MyMath);

MyCalculator.compute();

Le Revealing Module Pattern apporte donc son lot d’avantages :

  • le namespace global n’est pas pollué
  • les modules ont une interface claire : seules les fonctions choisies sont visibles

Cependant, l’ordre des imports reste un problème.

AMD (Async Module Definition)

Ce système de gestion de module est implémenté par la bibliothèque Require.JS. Il est possible de définir un module et de lister ses dépendances :

// math.js
define(function () {
    return {
        sum(a, b) {
            return a + b;
        },
        multiply(a, b) {
            return a * b;
        },
    }
})

// computer.js
define(["./math"], function (math) {
    return {
        compute() {
            math.sum(10, 32);
        }
    }
})

// app.js
requirejs(["./computer"], function(computer) {
    computer.compute();
})

Le fait de lister ses dépendances permet de définir les modules dans n’importe quel ordre : la bibliothèque Require.JS s’occupe ensuite d’exécuter les méthodes de définitions des modules dans le bon ordre.

En bonus, AMD permet de charger des modules à la volée. Il suffit de configurer Require.JS pour lui dire où trouver le fichier associé au module. Par défaut, un module porte le nom de son fichier. Voici un exemple de configuration :

requirejs.config({
    baseUrl: 'lib',
    paths: {
        app: '../app'
    }
});

requirejs(['app/main']);

CJS (CommonJS)

C’est le système utilisé par défaut par NodeJS. Il apporte une syntaxe plus concise avec les mots-clés exports et require. Il permet également la gestion des dépendances cycliques ainsi que la création d’un scope par fichier.

// math.js
module.exports = {
    sum(a, b) {
        return a + b;
    },
    multiply(a, b) {
        return a * b;
    },
}

// computer.js
const math = require('./math');

const SECRET = 42;

exports.compute = function(a) {
    return math.add(a, SECRET);
};

// app.js
const computer = require('./computer');

computer.compute(1);

Comment ça marche ?

Notre fichier est en fait encapsulé dans une fonction :

function (exports, require, module, __filename, __dirname) {
    const math = require('./math');

    const SECRET = 42;

    exports.compute = function(a) {
        return math.add(a, SECRET);
    };
}

C’est ainsi qu’on obtient un scope différent pour chaque fichier (SECRET est bien "cachée" dans le scope de la fonction). Cette fonction nous donne également accès à __filename et __dirname qui sont respectivement le chemin d’accès absolu du fichier et le chemin d’accès absolu du dossier contenant le fichier.

La partie intéressante de Common JS est le fonctionnement de la méthode require (le deuxième paramètre de la méthode précédente). Cette méthode fonctionne de la manière suivante : elle détermine le chemin absolu du script importé, elle vérifie que ce script n’est pas en cache puis elle "compile" le module.

La méthode resolve

Si c’est un chemin relatif la méthode resolve le transforme en chemin absolu.

// Fichier c:/source/lucca/front/app.js
resolve("./math");
// Renvoie c:/source/lucca/front/math.js

Dans les autres cas, il va falloir trouver, dans les répertoires node_modules, le bon script. Dans un premier temps, chaque dossier node_modules va être cherché, en remontant d’un dossier à chaque fois, jusqu’à trouver un dossier avec le nom correspondant :

// Fichier c:/source/lucca/front/app.js
resolve('rxjs');

// Cherche dans c:/source/lucca/front/node_modules/rxjs
//    puis dans c:/source/lucca/node_modules/rxjs
//    puis dans c:/source/node_modules/rxjs
//    puis dans c:/node_modules/rxjs

Une fois le dossier trouvé, le fichier package.json, via la propriété main, donne l’emplacement du script. On obtient donc :

// Fichier c:/source/lucca/front/app.js
resolve('rxjs');
// Renvoie c:/source/lucca/front/node_modules/rxjs/index.js

La méthode resolve est accessible via require.resolve().

Le cache

La récupération du chemin absolu est importante, car c’est lui qui va servir d’identifiant au module. Cet identifiant est, entre autres, la clé du module dans le cache géré par Common JS :

{
  'c:/source/lucca/front/app.js': Module
  'c:/source/lucca/front/math.js': Module,
  'c:/source/lucca/front/node_modules/rxjs/index.js': Module,
}

Si notre module est déjà en cache, celui-ci est immédiatement retourné.

Ce cache, accessible via require.cache apporte des propriétés intéressantes :

  • chaque fichier n’est exécuté qu’une seule fois

    // Fichier c:/source/lucca/front/math.js
    console.log('2 + 2 = 4');
    
    module.exports = {
      sum(a, b) {
        return a + b;
      },
    };
    
    // Fichier c:/source/lucca/front/app.js
    require('./math'); // Affiche '2 + 2 = 4'
    require('./math'); // N’affiche rien
    require('./math'); // N’affiche rien
  • les dépendances circulaires sont gérées (A -> B -> A (version en cache))

  • meilleures performances (principalement lié au fait de ne pas reparcourir et exécuter les dépendances et leurs dépendances)

Il est possible de supprimer une entrée dans ce cache avec :

delete require.cache[require.resolve('./math')];

Compilation

Pas grand-chose à dire sur cette étape : le contenu de notre fichier est exécuté puis stocké dans le cache vu précédemment sous la forme d’un Module :

interface NodeModule {
    exports: any;
    require: NodeRequireFunction;
    id: string;
    filename: string;
    loaded: boolean;
    parent: NodeModule | null | undefined;
    children: NodeModule[];
}

Limitations

  • require est une fonction synchrone (ce n’est pas compatible côté navigateur : chaque import bloquerait le thread principal)
  • il est très difficile d’analyser le code statiquement. Le code suivant est complètement valide :
const modules = ['foo', 'bar', 'baz'];
const randomIndex = Math.floor(Math.random() * modules.length);
const chosenModule = require(`./services/${modules[randomIndex]}.service`);

chosenModule.doStuff();

UMD (Universal Module Definition)

Si on récapitule, AMD semble être une bonne approche côté navigateur et CJS fonctionne très bien, mais, sur NodeJS seulement. Comment faire pour partager du code sur les deux plateformes ?

Il suffit d’introduire un nouveau système d’import : UMD. UMD va combiner tout ce qui a été vu depuis le début.

Reprenons notre petit module qui exporte la fonction compute et qui dépend du module math :

function(math) {
    const SECRET = 42;

    return {
        compute = function(a) {
            return math.add(a, SECRET);
        };
    }
}

Ce module va être encapsulé par une fonction qui va :

  • détecter quel est le système de modules utilisé (AMD, CJS ou browser scripts) :

    • AMD si une méthode globale define existe
    • CJS s’il existe un objet module ayant une propriété exports
    • Browser scripts en solution de secours
  • récupérer le module math

    • pour AMD, c’est ['math'] qui permet de demander math
    • pour CJS, c’est require('./math')
    • pour browser scripts, on compte sur le fait que le module math est déjà disponible via root.Math

    le paramètre root vaut this qui, lancé en dehors de toute fonction renvoie l’objet global de plus haut niveau (window dans le navigateur, global sur NodeJS)

  • exécuter la fonction ci-dessus (c'est le paramètre factory)

  • enregistrer le résultat

    • via la méthode define pour AMD
    • via la propriété module.exports pour CJS
    • via la variable globale root.Computer pour browser scripts
(function(root, factory)) {
    if (typeof define === 'function' && define.amd) {
        // AMD
        define(['math'], function(math) {
            return factory(math)
        })
    } else if (typeof module === 'object' && module.exports) {
        // CommonJS
        module.exports = factory(require('./math'));
    } else {
        // Browsers scripts
        root.Computer = factory(root.Math);
    }

})(this, function(math) {
    const SECRET = 42;

    return {
        compute = function(a) {
            return math.add(a, SECRET);
        };
    }
})

Ce système permet d'être étendu à d'autres systèmes de modules mais il est extrêmement verbeux. Il existe des outils pour générer l'enveloppe, mais le code généré reste assez lourd.

ESM (ECMA Script Module)

ESM reprend tout ce qui marchait bien dans les systèmes précédents :

  • un module = un fichier
  • un scope local
  • un système d'import/export
  • la possibilité de faire du chargement synchrone (CSJ) et asynchrone (AMD)
  • une syntaxe compacte (CSJ)
  • marche dans le navigateur et côté serveur

Exemple

// math.js

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

// computer.js
import { add } from './math';

const SECRET = 42;

export function compute(a) {
    return add(a, SECRET);
}

// app.js
import { compute } from './compute';

compute(12);

Fonctionnalités

  • default export

    // math.js
    export default function add(a, b) {
        return a + b;
    }
    
    // computer.js
    import maFonctionAdd from './math';
  • alias

    // math.js
    export function add(a, b) {
        return a + b;
    }
    
    // computer.js
    import { add as addition } from './math';
  • namespace

    // math.js
    export function add(a, b) {
        return a + b;
    }
    
    // computer.js
    import * as MyMath from './math';
    
    MyMath.add(12, 13)
  • les imports sont hoisted

  • re-export

    export { add } from './math';
    export { add as default } from './math';
    export { default } from './math';
    export { default as luAdd } from './math';
  • import dynamique asynchrone

    import('./math')
        .then(math => math.add(1, 2))

En bonus, ESM permet une analyse statique du code : l'autocomplétion est plus précise et la détection de code inutile est facilitée.

Comment ça marche ?

Phase 1 : Construction

La première phase consiste à contruire, à la manière de CJS un dictionnaire contenant l'ensemble des modules. Cependant, les clés ne sont plus le chemin du script sur le disque, mais une URL (ex: file:///c:/source/lucca/front/app.js sur NodeJS et http://127.0.0.1:8080/app.js sur un navigateur).

Il est possible de connaitre cette URL via import.meta.url

Chaque fichier est récupéré puis analysé mais pas exécuté : seuls les imports et exports sont lus.

À la fin de la construction, on a :

Phase 2 : Instanciation

L'étape suivante consiste à faire pointer un import d'un fichier sur un export d'un autre. Pour ce faire, une adresse mémoire est réservée. Cet espace mémoire sera accessible en lecture seule par le script qui fait l'import et en lecture/écriture par le script qui fait l'export.

Phase 3 : Évaluation

Une fois la mémoire réservée, la fonction exportée y est stockée puis le reste du script est exécuté.

Spécification ECMAScript modules

Cette spécification décrit :

  • Comment transformer un fichier en module record
  • Comment instancier un module
  • Comment évaluer un module
  • Mais PAS comment récupérer les fichiers

Ce dernier point est géré par un loader qui dépend de la plateforme :

  • Sur les navigateurs, ce loader suit la spécification HTML
  • Sur NodeJS, c’est une implémentation interne

Comment s'en servir ?

Puisque ESM est assez récent (norme ES2015), il n'est pas (encore) utilisé par défaut de partout. De plus, il n'est pas supporté de partout :

  • Support navigateur : tous les navigateurs modernes
  • Support NodeJS depuis la version 8.9.0 (en 2017)

Il faut utiliser le type module au lieu du traditionnel application/javascript :

<script src="/app/main-es2015.js" type="module"></script>

Il est possible de gérer les navigateurs ne supportant pas ESM via le pattern module/nomodule :

<script src="/app/main-es2015.js" type="module"></script>
<script src="/app/main-es5.js" nomodule></script>

Par exemple, Internet Explorer ne comprend pas le type module, il ignore donc cette ligne. Les navigateurs plus récents, eux, ignorent la ligne nomodule.

NodeJS

Par défaut, NodeJS utilise CommonJS. Dans ce cas :

  • les fichiers *.js utilisent CommonJS
  • les fichiers *.mjs utilisent ESM

Il est possible d'inverser la logique en ajoutant "type": "module" dans le fichier package.json :

  • les fichiers *.js utilisent ESM
  • les fichiers *.cjs utilisent CommonJS

:warning: Dans les versions de NodeJS antérieures à 13.2, il faut ajouter le flag --experimental-modules

Et TypeScript dans tout ça ?

TypeScript utilise un système qui a les mêmes fonctionnalités que ESM. Depuis la version 3.8, il y a une fonctionnalité supplémentaire : import type ... from.

import type { PrincipalInitializer } from '@lucca/principal';

// Je ne veux pas de l'implémentation de PrincipalInitializer dans ce bundle.
// J'utilise la classe en tant que type seulement
function principalFactory(initializer: PrincipalInitializer) {
    return initializer.principal;
}
import { PrincipalInitializer } from '@lucca/principal';

@NgModule({
    providers: [
        // J'ai besoin de la classe, donc pas d'`import type`
        { provide: APP_INITIALIZER, useClass: PrincipalInitializer }
    ]
})
export class AppModule {}

En cas de mauvaise utilisation de import type, TypeScript renvoie une erreur :

import type { PrincipalInitializer } from '@lucca/principal';

@NgModule({
    providers: [
        // error! 'PrincipalInitializer' only refers to a type, but is being used as a value here.
        { provide: APP_INITIALIZER, useClass: PrincipalInitializer }
    ]
})
export class AppModule {}

À quoi ressemble le javascript généré ?

Ça dépend de la configuration ! TypeScript peut générer des fichiers utilisant les systèmes de modules suivants : CommonJS, UMD, AMD, System(JS), ESM (ESNext ou ES2020).

/* tsconfig.json */
{
  "compilerOptions": {
    "baseUrl": "./",
    "outDir": "./dist/out-tsc",
    "target": "es2015",
    "module": "es2020"
  }
}

Exemple :

Pour le code suivant :

// index.ts
import { valueOfPi } from "./constants";

export const twoPi = valueOfPi * 2;

Voici les différentes sorties :

  • CommonJS

    "use strict";
    Object.defineProperty(exports, "__esModule", { value: true });
    exports.twoPi = void 0;
    const constants_1 = require("./constants");
    exports.twoPi = constants_1.valueOfPi * 2;
  • UMD

    (function (factory) {
        if (typeof module === "object" && typeof module.exports === "object") {
            var v = factory(require, exports);
            if (v !== undefined) module.exports = v;
        }
        else if (typeof define === "function" && define.amd) {
            define(["require", "exports", "./constants"], factory);
        }
    })(function (require, exports) {
        "use strict";
        Object.defineProperty(exports, "__esModule", { value: true });
        exports.twoPi = void 0;
        const constants_1 = require("./constants");
        exports.twoPi = constants_1.valueOfPi * 2;
    });
  • AMD

    define(["require", "exports", "./constants"], function (require, exports, constants_1) {
        "use strict";
        Object.defineProperty(exports, "__esModule", { value: true });
        exports.twoPi = void 0;
        exports.twoPi = constants_1.valueOfPi * 2;
    });
  • System

    System.register(["./constants"], function (exports_1, context_1) {
        "use strict";
        var constants_1, twoPi;
        var __moduleName = context_1 && context_1.id;
        return {
            setters: [
                function (constants_1_1) {
                    constants_1 = constants_1_1;
                }
            ],
            execute: function () {
                exports_1("twoPi", twoPi = constants_1.valueOfPi * 2);
            }
        };
    });
  • ESNext/ES2020

    import { valueOfPi } from "./constants";
    export const twoPi = valueOfPi * 2;

Et Webpack ?

Quelle que soit la configuration TypeScript, les fichiers générés par @angular/cli (et donc par webpack) ne ressemblent pas du tout à de l'ESM.

On voit en effet plein de __WEBPACK_IMPORTED_MODULE__ et de __webpack_require__.

var _angular_core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("fXoL")
  , _ngrx_store__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("kt0X")
  , rxjs__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__("EY2u")
  , rxjs__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__("HDdC")
  , rxjs__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__("LRne")
  , rxjs__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__("VRyK")
  , rxjs__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__("qgXg")
  , rxjs__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__("jtHE")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__("w1tV")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__("pLZG")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__("lJxs")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__("bOdf")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__("tS1D")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__("Kj3r")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__("JIr8")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__("IzEk")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__("1G5W")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__("eIep")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__("zP0r")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_19__ = __webpack_require__("pxpQ")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_20__ = __webpack_require__("zp1y")
  , rxjs_operators__WEBPACK_IMPORTED_MODULE_21__ = __webpack_require__("Kqap");

Pourquoi ?

  • En ESM "pur", on aurait un appel HTTP par import : les bundlers sont donc encore nécessaires pour minifier et regrouper les fichiers dans un nombre restreint de fichiers
  • Webpack est agnostique de la technologie de module utilisée. Via des loaders, il "apprend" à importer du SCSS / TypeScript / CommonJS & co. Il crée donc à son tour son propre système de gestion des modules.

Comment ça marche ?

Webpack arrive avec un runtime (fichier runtime.js sur vos applications Angular) et la définition des différents modules.

Dans le runtime, on trouve un système équivalent à CommonJS :

function __webpack_require__(moduleId) {
 // Check if module is in cache
 if(installedModules[moduleId]) {
  return installedModules[moduleId].exports;
 }
 // Create a new module (and put it into the cache)
 var module = installedModules[moduleId] = {
  i: moduleId,
  l: false,
  exports: {}
 };
 // Execute the module function
 modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
 // Flag the module as loaded
 module.l = true;
 // Return the exports of the module
 return module.exports;
}

On trouve également la partie pour charger les modules de façon dynamique.

Côté définition de modules, on peut trouver un dictionnaire de modules qui fait penser au cache sur NodeJS en mode CJS :

(window.webpackJsonp = window.webpackJsonp || []).push([[1], {
  "+VKU": function(/* module */ t, /* exports */ e, /* require */ n) { /* ... */ },
  "+W7E": function(/* module */ t, /* exports */ e, /* require */ n) { /* ... */ },
  "+Zhm": function(/* module */ t, /* exports */ e, /* require */ n) { /* ... */ },
  "/Ekm": function(/* module */ t, /* exports */ e, /* require */ n) { /* ... */ },
  "/opI": function(/* module */ t, /* exports */ e, /* require */ n) { /* ... */ },
  "0EZp": function(/* module */ t, /* exports */ e, /* require */ n) { /* ... */ },
  "0Z7X": function(/* module */ t, /* exports */ e, /* require */ n) { /* ... */ }
}]);

Conclusion

ESM est l'aboutissement d'un long chemin et semble assez flexible pour mettre tout le monde d'accord. La période de transition sera encore probablement longue et les auteurs de bibliothèques devront exporter leur code dans différents formats.

Dans le choix des dépendances, il me semble pertinent de prendre en compte son format d'export et de choisir des dépendances compatibles avec ESM. En effet, l'analyse statique des imports/exports permet de mettre en place pas mal d'outils comme de l'autocomplétion, des graphes de dépendances ou encore du tree shaking.

Biblio

About the author

Guillaume Nury

Expert Software Engineer