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.
Plusieurs systèmes ont vu le jour, chacun corrigeant les manques du système précédent.
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 :
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.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.
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 :
Cependant, l’ordre des imports reste un problème.
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']);
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);
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.
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 viarequire.resolve()
.
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')];
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[];
}
require
est une fonction synchrone (ce n’est pas compatible côté navigateur : chaque import bloquerait le thread principal)const modules = ['foo', 'bar', 'baz'];
const randomIndex = Math.floor(Math.random() * modules.length);
const chosenModule = require(`./services/${modules[randomIndex]}.service`);
chosenModule.doStuff();
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) :
define
existemodule
ayant une propriété exports
récupérer le module math
['math']
qui permet de demander math
require('./math')
math
est déjà disponible via root.Math
le paramètre
root
vautthis
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
define
pour AMDmodule.exports
pour CJSroot.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 reprend tout ce qui marchait bien dans les systèmes précédents :
// 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);
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.
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 :
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.
Une fois la mémoire réservée, la fonction exportée y est stockée puis le reste du script est exécuté.
Cette spécification décrit :
Ce dernier point est géré par un loader qui dépend de la plateforme :
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 :
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
.
Par défaut, NodeJS utilise CommonJS. Dans ce cas :
*.js
utilisent CommonJS*.mjs
utilisent ESMIl est possible d'inverser la logique en ajoutant "type": "module"
dans le fichier package.json
:
*.js
utilisent ESM*.cjs
utilisent CommonJS:warning: Dans les versions de NodeJS antérieures à 13.2, il faut ajouter le flag
--experimental-modules
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 {}
Ç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;
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");
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) { /* ... */ }
}]);
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.