A mesure que le développement JavaScript devient de plus en plus courant, les espaces de noms et les dépendances deviennent beaucoup plus difficiles à gérer. Différentes solutions ont été développées pour faire face à ce problème sous la forme de systèmes de modules. Dans ce post, nous allons explorer les différentes solutions actuellement employées par les développeurs et les problèmes qu’elles tentent de résoudre. Lisez la suite!
Introduction : Pourquoi les modules JavaScript sont-ils nécessaires ?
Si vous êtes familier avec d’autres plateformes de développement, vous avez probablement une certaine notion des concepts d’encapsulation et de dépendance. Différents morceaux de logiciel sont généralement développés de manière isolée jusqu’à ce qu’une certaine exigence doive être satisfaite par un morceau de logiciel existant précédemment. Au moment où cet autre logiciel est intégré au projet, une dépendance est créée entre lui et le nouveau code. Comme ces logiciels doivent fonctionner ensemble, il est important qu’aucun conflit ne survienne entre eux. Cela peut sembler trivial, mais sans une certaine forme d’encapsulation, ce n’est qu’une question de temps avant que deux modules n’entrent en conflit l’un avec l’autre. C’est l’une des raisons pour lesquelles les éléments des bibliothèques C portent généralement un préfixe :
#ifndef MYLIB_INIT_H#define MYLIB_INIT_Henum mylib_init_code { mylib_init_code_success, mylib_init_code_error};enum mylib_init_code mylib_init(void);// (...)#endif //MYLIB_INIT_H
L’encapsulation est essentielle pour prévenir les conflits et faciliter le développement.
Lorsqu’il s’agit de dépendances, dans le développement JavaScript traditionnel côté client, elles sont implicites. En d’autres termes, c’est le travail du développeur de s’assurer que les dépendances sont satisfaites au moment où tout bloc de code est exécuté. Les développeurs doivent également s’assurer que les dépendances sont satisfaites dans le bon ordre (une exigence de certaines bibliothèques).
L’exemple suivant fait partie des exemples de Backbone.js. Les scripts sont chargés manuellement dans le bon ordre :
<!DOCTYPE html><html lang="en"> <head> <meta charset="utf-8"> <title>Backbone.js Todos</title> <link rel="stylesheet" href="todos.css"/> </head> <body> <script src="../../test/vendor/json2.js"></script> <script src="../../test/vendor/jquery.js"></script> <script src="../../test/vendor/underscore.js"></script> <script src="../../backbone.js"></script> <script src="../backbone.localStorage.js"></script> <script src="todos.js"></script> </body> <!-- (...) --></html>
A mesure que le développement JavaScript devient de plus en plus complexe, la gestion des dépendances peut devenir lourde. Le remaniement est également altéré : où faut-il mettre les dépendances plus récentes pour maintenir l’ordre correct de la chaîne de chargement ?
Les systèmes de modules JavaScript tentent de traiter ces problèmes et d’autres. Ils sont nés de la nécessité de s’adapter au paysage JavaScript en constante évolution. Voyons ce que les différentes solutions apportent à la table.
Une solution ad hoc : Le modèle de module révélateur
La plupart des systèmes de modules sont relativement récents. Avant qu’ils ne soient disponibles, un motif de programmation particulier a commencé à être utilisé dans de plus en plus de code JavaScript : le motif de module révélateur.
var myRevealingModule = (function () { var privateVar = "Ben Cherry", publicVar = "Hey there!"; function privateFunction() { console.log( "Name:" + privateVar ); } function publicSetName( strName ) { privateVar = strName; } function publicGetName() { privateFunction(); } // Reveal public pointers to // private functions and properties return { setName: publicSetName, greeting: publicVar, getName: publicGetName };})();myRevealingModule.setName( "Paul Kinlan" );
Cet exemple est tiré du livre JavaScript Design Patterns d’Addy Osmani.
Les scopes JavaScript (du moins jusqu’à l’apparition de let
dans ES2015) fonctionnent au niveau de la fonction. En d’autres termes, toute liaison déclarée à l’intérieur d’une fonction ne peut échapper à son scope. C’est, pour cette raison, que le pattern module révélateur s’appuie sur les fonctions pour encapsuler les contenus privés (comme de nombreux autres patterns JavaScript).
Dans l’exemple ci-dessus, les symboles publics sont exposés dans le dictionnaire retourné. Toutes les autres déclarations sont protégées par la portée de la fonction qui les englobe. Il n’est pas nécessaire d’utiliser var
et un appel immédiat à la fonction enfermant la portée privée ; une fonction nommée peut être utilisée pour les modules également.
Ce pattern est utilisé depuis un certain temps dans les projets JavaScript et traite assez bien la question de l’encapsulation. Il ne fait pas grand-chose au sujet de la question des dépendances. Les systèmes de modules appropriés tentent de traiter ce problème également. Une autre limitation réside dans le fait que l’inclusion d’autres modules ne peut pas être faite dans la même source (à moins d’utiliser eval
).
Pros
- Simple pour être implémenté n’importe où (aucune bibliothèque, aucun support de langage requis).
- Des modules multiples peuvent être définis dans un seul fichier.
Cons
- Pas de moyen d’importer programmatiquement des modules (sauf en utilisant
eval
). - Les dépendances doivent être gérées manuellement.
- Le chargement asynchrone des modules n’est pas possible.
- Les dépendances circulaires peuvent être gênantes.
- Difficile à analyser pour les analyseurs de code statique.
CommonJS
CommonJS est un projet qui vise à définir une série de spécifications pour aider au développement d’applications JavaScript côté serveur. L’un des domaines que l’équipe de CommonJS tente d’aborder est celui des modules. Les développeurs de Node.js avaient à l’origine l’intention de suivre les spécifications de CommonJS, mais ont ensuite décidé de ne pas le faire. En ce qui concerne les modules, l’implémentation de Node.js en est très influencée :
// In circle.jsconst PI = Math.PI;exports.area = (r) => PI * r * r;exports.circumference = (r) => 2 * PI * r;// In some fileconst circle = require('./circle.js');console.log( `The area of a circle of radius 4 is ${circle.area(4)}`);
Un soir à Joyent, lorsque j’ai mentionné être un peu frustré par une demande ridicule pour une fonctionnalité que je savais être une idée terrible, il m’a dit : « Oubliez CommonJS. Il est mort. Nous sommes des JavaScript côté serveur. » – Isaac Z. Schlueter, créateur de NPM, citant Ryan Dahl, créateur de Node.js
Il existe des abstractions au-dessus du système de modules de Node.js sous la forme de bibliothèques qui comblent le fossé entre les modules de Node.js et CommonJS. Pour les besoins de ce post, nous ne montrerons que les fonctionnalités de base qui sont principalement les mêmes.
Dans les modules de Node et de CommonJS, il y a essentiellement deux éléments pour interagir avec le système de modules : require
et exports
. require
est une fonction qui peut être utilisée pour importer des symboles d’un autre module dans la portée actuelle. Le paramètre passé à require
est l’id du module. Dans l’implémentation de Node, c’est le nom du module à l’intérieur du répertoire node_modules
(ou, s’il n’est pas à l’intérieur de ce répertoire, le chemin vers celui-ci). exports
est un objet spécial : tout ce qui y est mis sera exporté comme un élément public. Les noms des champs sont préservés. Une différence particulière entre Node et CommonJS apparaît dans la forme de l’objet module.exports
. Dans Node, module.exports
est le véritable objet spécial qui est exporté, tandis que exports
est juste une variable qui est liée par défaut à module.exports
. CommonJS, d’autre part, n’a pas d’objet module.exports
. L’implication pratique est que dans Node, il n’est pas possible d’exporter un objet entièrement préconstruit sans passer par module.exports
:
// This won't work, replacing exports entirely breaks the binding to// modules.exports.exports = (width) => { return { area: () => width * width };}// This works as expected.module.exports = (width) => { return { area: () => width * width };}
Les modules CommonJS ont été conçus avec le développement de serveurs en tête. Naturellement, l’API est synchrone. En d’autres termes, les modules sont chargés au moment et dans l’ordre où ils sont requis à l’intérieur d’un fichier source.
Pros
- Simple : un développeur peut saisir le concept sans regarder la docs.
- La gestion des dépendances est intégrée : les modules nécessitent d’autres modules et sont chargés dans l’ordre nécessaire.
-
require
peut être appelé n’importe où : les modules peuvent être chargés de manière programmatique. - Les dépendances circulaires sont supportées.
Cons
- L’API synchrone fait qu’il n’est pas adapté à certaines utilisations (côté client).
- Un fichier par module.
- Les navigateurs nécessitent une bibliothèque de chargement ou une transpilation.
- Pas de fonction constructeur pour les modules (Node le supporte cependant).
- Difficile à analyser pour les analyseurs de code statique.
Implémentations
Nous avons déjà parlé d’une implémentation (sous forme partielle) : Node.js.
Pour le client, il existe actuellement deux options populaires : webpack et browserify. Browserify a été explicitement développé pour analyser les définitions de modules de type Node (de nombreux paquets Node fonctionnent prêts à l’emploi avec lui !) et regrouper votre code plus le code de ces modules dans un seul fichier qui porte toutes les dépendances. Webpack, quant à lui, a été développé pour gérer la création de pipelines complexes de transformations de sources avant la publication. Cela inclut le regroupement de modules CommonJS.
Définition de modules asynchrones (AMD)
AMD est né d’un groupe de développeurs mécontents de la direction adoptée par CommonJS. En fait, AMD a été séparé de CommonJS au début de son développement. La principale différence entre AMD et CommonJS réside dans la prise en charge du chargement asynchrone des modules.
//Calling define with a dependency array and a factory functiondefine(, function (dep1, dep2) { //Define the module value by returning a value. return function () {};});// Or:define(function (require) { var dep1 = require('dep1'), dep2 = require('dep2'); return function () {};});
Le chargement asynchrone est rendu possible par l’utilisation de l’idiome de fermeture traditionnel de JavaScript : une fonction est appelée lorsque les modules demandés ont fini de se charger. La définition des modules et l’importation d’un module sont portées par la même fonction : lorsqu’un module est défini, ses dépendances sont rendues explicites. Par conséquent, un chargeur AMD peut avoir une image complète du graphe de dépendances pour un projet donné au moment de l’exécution. Les bibliothèques qui ne dépendent pas les unes des autres pour leur chargement peuvent ainsi être chargées en même temps. Ceci est particulièrement important pour les navigateurs, où les temps de démarrage sont essentiels à une bonne expérience utilisateur.
Pros
- Chargement asynchrone (meilleurs temps de démarrage).
- Les dépendances circulaires sont supportées.
- Compatibilité pour
require
etexports
. - Gestion des dépendances entièrement intégrée.
- Les modules peuvent être divisés en plusieurs fichiers si nécessaire.
- Les fonctions constructrices sont supportées.
- Support des plugins (étapes de chargement personnalisées).
Conséquences
- Légèrement plus complexe sur le plan syntaxique.
- Les bibliothèques de chargement sont nécessaires à moins d’être transpilées.
- Difficile à analyser pour les analyseurs de code statique.
Mise en œuvre
À l’heure actuelle, les mises en œuvre les plus populaires d’AMD sont require.js et Dojo.
Utiliser require.js est assez simple : inclure la bibliothèque dans votre fichier HTML et utiliser l’attribut data-main
pour indiquer à require.js quel module doit être chargé en premier. Dojo a une configuration similaire.
Modules ES2015
Heureusement, l’équipe ECMA derrière la normalisation de JavaScript a décidé de s’attaquer à la question des modules. Le résultat est visible dans la dernière version de la norme JavaScript : ECMAScript 2015 (anciennement connu sous le nom d’ECMAScript 6). Le résultat est syntaxiquement agréable et compatible avec les modes de fonctionnement synchrones et asynchrones.
//------ lib.js ------export const sqrt = Math.sqrt;export function square(x) { return x * x;}export function diag(x, y) { return sqrt(square(x) + square(y));}//------ main.js ------import { square, diag } from 'lib';console.log(square(11)); // 121console.log(diag(4, 3)); // 5
Exemple tiré du blog d’Axel Rauschmayer
La directive import
peut être utilisée pour faire entrer des modules dans l’espace de nom. Cette directive, contrairement à require
et define
n’est pas dynamique (c’est-à-dire qu’elle ne peut pas être appelée à n’importe quel endroit). La directive export
, en revanche, peut être utilisée pour rendre explicitement des éléments publics.
La nature statique de la directive import
et export
permet aux analyseurs statiques de construire un arbre complet de dépendances sans exécuter le code. ES2015 supporte effectivement le chargement dynamique des modules :
System.import('some_module') .then(some_module => { // Use some_module }) .catch(error => { // ... });
En vérité, ES2015 ne spécifie que la syntaxe des chargeurs de modules dynamiques et statiques. En pratique, les implémentations ES2015 ne sont pas tenues de faire quoi que ce soit après l’analyse de ces directives. Les chargeurs de modules tels que System.js sont toujours nécessaires jusqu’à ce que la prochaine spécification ECMAScript soit publiée.
Cette solution, en vertu de son intégration au langage, laisse les runtimes choisir la meilleure stratégie de chargement pour les modules. En d’autres termes, lorsque le chargement asynchrone donne des avantages, il peut être utilisé par le runtime.
Pros
- Chargement synchrone et asynchrone supporté.
- Syntaxiquement simple.
- Support des outils d’analyse statique.
- Intégré au langage (éventuellement supporté partout, pas besoin de bibliothèques).
- Dépendances circulaires supportées.
Cons
- Pas encore supporté partout.
Implémentations
Malheureusement, aucun des principaux runtimes JavaScript ne supporte les modules ES2015 dans leurs branches stables actuelles. Cela signifie aucun support pour Firefox, Chrome ou Node.js. Heureusement, de nombreux transpilers prennent en charge les modules et un polyfill est également disponible. Actuellement, le préréglage ES2015 de Babel peut gérer les modules sans problème.
La solution tout-en-un : System.js
Vous pouvez vous retrouver à essayer de vous éloigner du code hérité en utilisant un système de modules. Ou vous voulez peut-être vous assurer que quoi qu’il arrive, la solution que vous avez choisie fonctionnera toujours. Entrez dans System.js : un chargeur de modules universel qui prend en charge les modules CommonJS, AMD et ES2015. Il peut fonctionner en tandem avec des transpilers tels que Babel ou Traceur et peut prendre en charge Node et les environnements IE8+. Son utilisation consiste à charger System.js dans votre code, puis à le faire pointer vers votre URL de base :
<script src="system.js"></script> <script> // set our baseURL reference path System.config({ baseURL: '/app', // or 'traceur' or 'typescript' transpiler: 'babel', // or traceurOptions or typescriptOptions babelOptions: { } }); // loads /app/main.js System.import('main.js'); </script>
Comme System.js fait tout le travail à la volée, l’utilisation des modules ES2015 doit généralement être laissée à un transpilateur pendant l’étape de construction en mode production. Lorsqu’il n’est pas en mode production, System.js peut appeler le transpilateur pour vous, offrant une transition transparente entre les environnements de production et de débogage.
A part : Ce que nous utilisons à Auth0
À Auth0, nous utilisons fortement JavaScript. Pour notre code côté serveur, nous utilisons des modules Node.js de style CommonJS. Pour certains codes côté client, nous préférons AMD. Pour notre bibliothèque de verrouillage sans mot de passe basée sur React, nous avons opté pour des modules ES2015.
Vous aimez ce que vous voyez ? Inscrivez-vous et commencez à utiliser Auth0 dans vos projets dès aujourd’hui.
Vous êtes un développeur et vous aimez notre code ? Si oui, postulez pour un poste d’ingénieur dès maintenant. Nous avons une équipe géniale !
Conclusion
Construire des modules et gérer les dépendances était lourd dans le passé. Des solutions plus récentes, sous la forme de bibliothèques ou de modules ES2015, ont enlevé la plupart des douleurs. Si vous envisagez de lancer un nouveau module ou projet, ES2015 est la bonne solution. Elle sera toujours prise en charge et la prise en charge actuelle à l’aide de transpilers et de polyfills est excellente. D’autre part, si vous préférez vous en tenir au code ES5 pur, la répartition habituelle entre AMD pour le client et CommonJS/Node pour le serveur reste le choix habituel. N’oubliez pas de nous laisser vos réflexions dans la section des commentaires ci-dessous. Hack on!