Les modules ECMAScript (ou modules ES) sont un système de modules, standard et officiel, pour JavaScript. Ce système est le fruit d’un travail de standardisation qui a duré 10 ans.

Nous pourrons bientôt profiter de tout cela avec l’arrivée des modules dans Firefox 60 en mai (cette version est actuellement en bêta). Les navigateurs principaux prennent tous en charge cette fonctionnalité et le groupe de travail pour les modules Node contribue à l’ajout des modules ES dans Node.js. L’intégration des modules ES dans WebAssembly a également démarré.

De nombreux développeurs JavaScript savent que les modules ES ont été à l’origine de différentes controverses mais peu comprennent réellement le fonctionnement des modules ES.

Voyons ici les problèmes résolus par les modules ES et leurs différences avec les autres systèmes de modules.

Les problèmes résolus par les modules

Quand on y pense, coder en JavaScript revient principalement à gérer des variables. On leur affecte des valeurs, on leur ajoute des nombres, on combine deux variables pour construire une nouvelle variable.

Étant donné la proportion de code consacrée à la modification des variables, l’organisation de ces variables aura un impact non-négligeable sur la qualité du code… mais aussi sur la facilité à le maintenir.

S’occuper d’un nombre de variables limité permet de simplifier les choses. En JavaScript, on peut s’aider des portées (scope) pour limiter le périmètre des variables qu’on doit gérer. En JavaScript, les portées formées par les fonctions empêchent ces dernières d’accéder à des variables qui seraient définies dans d’autres fonctions.

C’est une bonne chose. Cela signifie que lorsqu’on travaille sur une fonction, on peut ne penser qu’à cette fonction. Il n’est pas nécessaire de s’encombrer l’esprit avec l’impact potentiel des autres fonctions sur les variables de la fonction courante.

Toutefois, cela a un prix : il est plus difficile de partager des variables entre différentes fonctions.

Comment partager une variable en dehors de sa portée ? Pour répondre au problème, on utilise généralement une portée plus grande… comme la portée globale.

Peut-être vous souvenez vous de l’époque où jQuery était utilisé. Avant de pouvoir utiliser un plugin jQuery, il fallait s’assurer que jQuery faisait bien partie de la portée globale.

On a répondu à ce problème de partage des variables mais cela n’est pas sans conséquence.

Tout d’abord, il faut que les balises de script soient dans le bon ordre. Ensuite, il faut s’assurer que personne ne vienne chambouler cet ordre.

Si cet ordre vient à être chamboulé, l’application pourra causer une belle erreur au plein milieu de l’exécution. Lorsque la fonction qui va chercher jQuery où elle s’attend à l’avoir (c’est-à-dire dans la portée globale) et ne le trouve pas, elle déclenchera une erreur et arrêtera son exécution.

La maintenance du code devient plus épineuse. Retirer du vieux code ou des balises <script> s’apparente à un pari risqué. Personne ne peut savoir ce qui va être cassé. Les dépendances entre les différentes parties du code sont implicites. N’importe quelle fonction peut manipuler n’importe quoi appartenant à la portée globale et on ne sait plus de quels scripts dépendent ces fonctions.

Ce n’est pas tout, les variables qui sont dans la portée globale peuvent très bien être modifiées par n’importe quel code situé au sein de cette portée. Du code malveillant pourrait modifier cette variable globale à dessein afin de détourner l’exécution. Du code a priori innocent pourrait également, accidentellement, modifier la variable.

Qu’apportent les modules ?

Les modules permettent de mieux organiser les variables et les fonctions. Avec les modules, on peut regrouper les variables et les fonctions de façon pertinente.

Ainsi, ces fonctions et variables font partie de la portée d’un module et cette portée peut être utilisée afin de partager des variables entre les fonctions du module.

Cependant, à la différence des portées créées par les fonctions, les portées des modules permettent de rendre leurs variables disponibles pour d’autres modules. On peut indiquer, explicitement, les variables, les classes ou les fonctions qui seront disponibles pour être utilisées ailleurs.

Quelque chose qui est rendu disponible pour un autre module est appelé un export. Lorsqu’on dispose d’un export, les autres modules peuvent, de façon explicite, indiquer qu’ils dépendent de cette variable, classe ou fonction.

Cette relation étant explicite, on est cette fois en mesure de déterminer ce qui va casser si on retire un des modules.

Une fois capable d’exporter et d’importer des variables entre les modules, on peut diviser le code en petits fragments qui peuvent fonctionner de façon indépendante. On peut alors combiner et recombiner ces fragments, tels des briques de Lego, afin de créer différentes applications à partir d’un même ensemble de modules.

L’organisation en module apportant de nombreux bénéfices, plusieurs tentatives ont été lancées afin d’incorporer ces modules dans JavaScript. Il existe aujourd’hui deux systèmes de module activement utilisés : * CommonJS (CJS), utilisé par Node.js jusqu’à présent. * ESM (ECMAScript modules), un système plus récent qui a été ajouté à la spécification JavaScript.

Les navigateurs prennent déjà en charge les modules ES et Node travaille à leur implémentation.

Voyons ensuite le détail du fonctionnement de ce nouveau système de modules.

Le fonctionnement des modules ES

Lorsqu’on développe en utilisant des modules, on construit un graphe de dépendances. Les liens entre ces dépendances proviennent des instructions import utilisées.

Ces instructions import indiquent, au moteur du navigateur ou à celui de Node, les fragments de code à charger. Il suffit de donner le nom d’un fichier comme point d’entrée pour le graphe puis le moteur suivra chacune des instructions import afin de récupérer l’ensemble du code nécessaire.

Mais le navigateur ne peut pas utiliser les fichiers directement. Il faut qu’il analyse ces fichiers afin de les transformer en structures de données appelées Module Records. Ainsi, il peut savoir ce qui se passe dans chaque fichier.

Après cette analyse, le module record doit être converti en une instance de module. Une instance représente deux choses : le code et un état.

Le code est, pour résumer, un ensemble d’instructions (à la manière d’une recette). Mais le code seul ne permet pas de faire grand-chose, il lui faut de la matière première à traiter avec ces instructions.

L’état est cette matière première. L’état permet de stocker les valeurs des variables à un instant donné. Quant aux variables, ce ne sont en réalité que des alias pour les espaces mémoire qui contiennent ces valeurs.

Reformulons : l’instance d’un module combine le code (la liste des instructions) avec un état (l’ensemble des valeurs des variables).

Il nous faut donc une instance de module pour chaque module. Le processus de chargement d’un module part du point d’entrée fourni par le fichier jusqu’à avoir un graphe complet avec l’ensemble des instances de module nécessaires.

Pour les modules ES, ce processus se décompose en trois étapes :

  1. Construction  — le moteur trouve, télécharge et analyse l’ensemble des fichiers pour le convertir en module records.
  2. Instanciation — le moteur détermine les zones mémoire dans lesquelles il va placer les valeurs exportées (il ne remplit pas ces zones pour l’instant). Ainsi, les exports et les imports peuvent pointer vers ces zones mémoire. C’est ce qu’on appelle aussi l’édition de lien ou linking.
  3. Évaluation — le code est exécuté afin de remplir ces espaces mémoire avec les valeurs réelles des variables.

On peut entendre dire que les modules ES sont asynchrones. En fait, cette asynchronicité vient du fait que chacune de ces trois étapes peut être exécutée séparément.

Cela signifie que la spécification introduit un niveau d’asynchronicité qui n’existait pas avec les modules CommonJS. Nous y reviendrons ensuite, mais il faut retenir qu’avec CJS, un module et ses dépendances sont chargées, instanciées et évaluées d’un coup, sans interruption entre ces étapes.

Ceci étant dit, les étapes individuelles ne sont pas nécessairement asynchrones et elles peuvent isolément être exécutées de façon synchrone. Cela dépend du chargement. En fait, la spécification pour les modules ES ne décrit pas l’intégralité du processus. Celui-ci se découpe en deux moitiés dont chacune est décrite par une spécification différente.

La spécification pour les modules ES indique comment analyser les fichiers pour les convertir en module records et comment instancier puis évaluer le module. Toutefois, elle ne dit rien de la façon dont on doit récupérer les fichiers pour commencer.

C’est au chargeur (loader) de récupérer les fichiers. Or, le comportement de ce chargeur est décrit dans une autre spécification. Pour les navigateurs, la spécification utilisée est la spécification HTML. Pour d’autres plates-formes, on pourrait tout à fait avoir d’autres chargeurs qui devraient également être spécifiés.

Le chargeur contrôle exactement la façon dont les modules sont chargés. Il appelle les méthodes associées aux modules ES :  ParseModule, Module.Instantiate et Module.Evaluate. On pourrait le comparer à un marionnettiste qui tire les ficelles du moteur JavaScript.

Étudions maintenant en détail chacune de ces trois étapes.

Construction

La phase de construction se décompose en trois sous-étapes :

  1. Déterminer où télécharger le fichier contenant le module (aussi appelée la résolution)
  2. Récupérer le fichier (en le téléchargeant via une URL ou en le chargeant depuis le système de fichier)
  3. Analyser le fichier et le convertir en un module record

Trouver et récupérer le fichier

C’est le chargeur qui va déterminer l’emplacement du fichier et le télécharger. Pour commencer, il lui faut un point d’entrée. En HTML, ce point d’entrée est fourni par le document indiqué via un élément <script>.

Mais comment faire pour trouver les modules suivants, ceux dont dépend main.js ?

C’est ici qu’intervient l’instruction import. Une partie de cette instruction est l’indicateur du module (module specifier). Cette partie permet au chargeur de connaître l’emplacement du prochain module.

On notera ici qu’il y a une différence entre Node et les navigateurs pour ces indicateurs. Chaque environnement hôte dispose de sa propre méthode pour interpréter la chaîne de caractères fournissant l’indicateur. Pour cela, chaque plateforme utilise un algorithme de résolution qui lui est propre. À l’heure actuelle, certains indicateurs de module qui fonctionnent avec Node ne fonctionneront pas dans le navigateur (des pistes sont à l’étude pour résoudre ce problème).

Jusqu’à ce que ce problème soit résolu, les navigateurs acceptent uniquement des URL comme indicateurs de module. C’est le fichier vers lequel pointe cette URL qui sera chargé.

Toutefois, la récupération des différents modules ne se fait pas simultanément. On ne peut pas savoir les dépendances dont on a besoin tant qu’on a pas analysé le contenu du module et on ne peut pas analyser le fichier tant qu’on ne l’a pas récupéré.

Cela signifie qu’il faut progresser niveau par niveau : analyser le premier fichier, déterminer ses dépendances, trouver ces dépendances, les charger et ainsi de suite.

S’il fallait que le thread principal patiente le temps que chacun de ces fichiers soit téléchargé, de nombreuses tâches s’ajouteraient à la queue.

En effet, lorsqu’on travaille dans un navigateur, le téléchargement dure longtemps.

Illustration basée sur ce graphique.

Bloquer le thread principal de cette façon ralentirait considérablement une application qui utilise les modules. C’est pour cette raison que la spécification pour les modules ES découpe l’algorithme en plusieurs phases. Placer la construction à part permet aux navigateurs de récupérer les fichiers et de construire le graphe des modules avant de s’attaquer à la tâche, synchrone, d’instanciation.

Cette approche (de découpe d’algorithme) est une des différences fondamentales entre les modules ES et les modules CommonJS.

CommonJS peut gérer les choses différemment, car les fichiers proviennent du système de fichier et l’accès à ces ressources prend moins de temps que de les télécharger via Internet. Cela signifie que Node peut bloquer le thread principal pendant qu’il charge le fichier. Lorsque le fichier est chargé, on peut directement l’instancier et l’évaluer (encore une fois, ces phases ne sont pas distinctes avec CommonJS). Cela signifie également qu’on parcourt tout l’arbre des dépendances, qu’on les charge, qu’on les instancie et qu’on les évalue avant de renvoyer l’instance du module.

L’approche adoptée par CommonJS implique différentes choses que nous verrons ensuite. Entre autres, cela signifie qu’avec Node et les modules CommonJS, on peut utiliser des variables dans l’indicateur du module. On exécute l’ensemble du code du module (y compris l’instruction require) avant d’aller consulter le prochain module. Ainsi, la variable utilisée aura une valeur lorsqu’on passera à la résolution de module.

En revanche, avec les modules ES, tout le graphe des dépendances doit être construit avant l’évaluation. Aussi, on ne peut pas avoir de variable au sein des identificateurs, car ces variables n’ont pas encore de valeurs.

Cependant, il est parfois utile d’utiliser des variables pour les chemins des modules. On peut, par exemple, vouloir charger un module ou un autre en fonction de l’état du code ou de l’environnement dans lequel on exécute le code.

Une proposition, dynamic import, vise à rendre cela possible et permettrait d’écrire une instruction import de la forme

import(`${chemin}/toto.js`).

Pour cela, chaque fichier chargé grâce à import() est considéré comme un point d’entrée pour un autre graphe. Le module importé dynamiquement devient ainsi une racine d’un nouveau graphe, traité séparément.

Pour un graphe donné, on aura un cache contenant les instances de ces modules. Aussi, si un module A est présent dans deux graphes, on aura deux caches avec deux instances. Pour chaque module d’une portée globale, il n’y aura qu’une seule instance du module.

Charger une seule instance pour chaque module minimise le travail du moteur JavaScript : le module n’est récupéré qu’une seule fois même lorsque plusieurs modules dépendent de ce premier (une des raisons pour la mise en cache des modules, nous en verrons une autre dans le détail consacré à la phase d’évaluation).

Le chargeur gère ce cache avec ce qu’on appelle la carte des modules (module map). Chaque portée globale maintient le registre de ses modules dans une carte distincte.

Lorsque le chargeur récupère une URL, il place cette URL dans la carte des modules et note qu’il est en train de récupérer le fichier. Il envoie ensuite la requête puis démarre la récupération du prochain fichier.

Que se passe-t-il lorsqu’un autre module dépend du même fichier ? Le chargeur vérifiera la présence de l’URL dans la carte. Si celle-ci est déjà en cours de récupération, il passera à l’URL suivante.

La carte des modules ne sert pas uniquement à indiquer les modules qui sont en train d’être récupérés, elle joue également le rôle de cache pour les modules comme nous le verrons par la suite.

L’analyse (parsing)

Nous avons désormais récupéré ce fichier et il faut le convertir en module record afin que le navigateur comprenne les différentes partie du module.

Une fois le module record créé, il est placé dans la carte des modules. De cette façon, dès que le module est demandé, le chargeur peut le récupérer directement depuis la carte.

Précisons un léger détail qui n’est peut-être pas si léger. Tous les modules sont analysés comme s’ils utilisaient "use strict" au début. D’autres différences (par rapport à l’analyse des scripts « classiques ») sont également présentes : le mot-clé await est réservé au niveau le plus haut d’un module ; la valeur de this vaut undefined.

Cette différence d’analyse est aussi appelée parse goal. Si on analyse le même fichier pour un autre usage, on pourrait obtenir un résultat différent. Aussi, avant l’analyse, il est nécessaire de savoir si on analyse un module ou non.

Pour les navigateurs, c’est plutôt facile. Il suffit de placer l’attribut type="module" dans la balise script. Le navigateur pourra alors savoir que le fichier doit être analysé comme un module. Au sein des scripts, seuls les modules peuvent être importés, le navigateur sait donc que tout ce qui est importé doit être analysé comme un module.

Pour Node, c’est une autre histoire. Il n’y a pas d’éléments HTML et on ne peut donc pas utiliser un attribut type. Une des solutions utilisées par la communauté a été d’utiliser l’extension .mjs. Avec cette extension, on indique à Node que le fichier est un module. Vous pourrez lire certaines discussions où cette extension est un signal pour le parse goal. La discussion à ce sujet est toujours en cours et le signal qui sera utilisé par la communauté Node n’est pas encore fixé.

Dans tous les cas, le chargeur déterminera s’il faut analyser le fichier comme un module ou non. Si c’est un module et que celui-ci a des imports, le chargeur recommencera le processus jusqu’à ce que l’ensemble des fichiers ait été récupéré et analysé.

Et voilà ! Au début de cette étape, nous avions un fichier comme point d’entrée et maintenant nous avons un ensemble de module records.

La prochaine étape consiste à instancier ce module et à lier toutes ces instances ensembles.

L’instanciation

Comme évoqué plus haut, une instance combine du code avec un état. Cet état est en mémoire. Aussi, l’étape d’instanciation concernera principalement la mémoire.

Pour commencer, le moteur JavaScript crée un environnement pour le module. Cet environnement va gérer les variables pour le module record. Ensuite, il trouve les zones mémoire où placer l’ensemble des exports. C’est l’environnement du module qui maintiendra le registre d’association entre les zones mémoire et les exports.

Ces zones mémoire n’ont pas encore de valeur à l’intérieur. Ce n’est qu’après l’évaluation que les valeurs seront renseignées. Seule une exception échappe à cette règle : les déclarations de fonction sont initialisées pendant cette phase afin de faciliter la phase d’évaluation.

Pour instancier le graphe des modules, le moteur effectue un parcours postfixe (en profondeur) de l’arbre (depth first post-order traversal). Cela signifie qu’il commence par parcourir le bas de l’arbre (les dépendances qui n’ont aucune dépendance) et traite leurs exports.

.

Une fois que le moteur a fini de câbler l’ensemble des exports du niveau inférieur, il monte d’un niveau pour câbler les imports du module correspondant.

On notera qu’un export et un import pointent vers le même emplacement en mémoire. Commencer par câbler les exports garantit la capacité à connecter les imports aux exports associés.

Il y a une ici une différence avec les modules CommonJS. En CommonJS, tout l’objet exporté est copié à l’export. Cela signifie que les valeurs (les nombres par exemple) sont exportées sous forme de copies.

Cela signifie que si le module qui exporte modifie une valeur plus tard, le module qui l’import ne verra pas ce changement.

Les modules ES utilisent à la place des liaisons dynamiques (live bindings). Les deux modules (l’export et l’import) pointent vers le même emplacement en mémoire. Cela signifie que lorsque le module exportant modifie une valeur, cette modification est également présente dans le module important.

Les modules qui exportent des valeurs peuvent modifier ces valeurs à tout moment mais les modules qui importent des valeurs ne peuvent pas modifier les valeurs importées. Ceci étant dit, si un module importe un objet, il peut modifier les valeurs des propriétés de cet objet.

Avec ces liaisons dynamiques, il est possible de câbler tous les modules sans exécuter aucun code. Comme on le verra ensuite, cela facilite l’évaluation lorsqu’on a des dépendances cycliques.

À la fin de cette étape, toutes les instances de modules pour les imports et les exports sont câblées aux zones mémoire.

Nous voilà alors capables d’évaluer le code et de remplir ces zones mémoire avec leurs valeurs.

L’évaluation

L’étape finale consiste à remplir ces zones mémoire. Le moteur JS s’attaque à cette tâche en exécutant le code « de plus haut niveau » (top level), c’est-à-dire le code qui est situé en dehors des fonctions.

En plus de remplir ces espaces en mémoire, l’évaluation du code peut déclencher certains effets de bord. Un module peut, par exemple, communiquer avec un serveur.

À cause de ces effets de bord, il peut être préférable de n’évaluer le module qu’une seule fois. Contrairement à l’édition de lien qui se produit pendant l’instanciation et qui peut être effectuée plusieurs fois en produisant toujours le même résultat, l’évaluation pourra produire des résultats différents selon le nombre de fois qu’on l’a appliquée.

C’est l’une des raisons à l’origine de la carte des modules. La carte des modules met en cache les modules et expose une URL canonique pour chacun des modules. Ainsi, on s’assure que chaque module est exécuté une seule fois. Comme avec l’instanciation, l’exécution est effectuée en suivant un parcours postfixe (en profondeur) de l’arbre.

Mais que se passe-t-il avec les cycles que nous avons évoqués plus tôt ?

Si on a une dépendance cyclique, on aura une boucle dans le graphe. Généralement, cette boucle est plutôt longue. Toutefois, afin de pouvoir expliquer le problème ici, nous allons utiliser un exemple simplifié avec une boucle plus courte.

Voyons ce qui se serait passé avec des modules CommonJS. Tout d’abord, le module principal est exécuté jusqu’à l’instruction require. Ensuite, on charge le module counter.

Le module counter essaie alors d’accéder à la variable message provenant de l’object d’export. Cette variable n’ayant pas encore été initialisée dans le module principal, elle renvoie undefined. Le moteur JavaScript alloue de la mémoire pour la variable locale et indique undefined comme valeur.

L’évaluation continue jusqu’à la fin du code de plus haut niveau pour le module counter. On veut pouvoir voir si la valeur correcte de message y parvient (après que main.js a été évalué) et on utilise donc setTimeout pour patienter. L’évaluation reprend alors du côté de main.js.

La variable message est initialisée et ajoutée à un emplacement mémoire mais aucun lien n’est créé entre cet emplacement et celui utilisé précédemment. Aussi, la valeur utilisée dans counter restera (indéfiniment) undefined.

Si les exports avaient été créés avec des liaisons dynamiques, le module counter aurait fini par voir arriver la bonne valeur pour message. En effet, après que l’évaluation de main.js a eu lieu et remplit la valeur, une fois que le setTimeout se déclenche, il utilise la bonne valeur.

La prise en charge de ces cycles a été une composante majeure lors de la conception des modules ES. C’est ce découpage en trois phases qui permet de gérer les dépendances cycliques.

Quelle est la prise en charge actuelle des modules ES ?

Avec la sortie de Firefox 60 en mai, l’ensemble des principaux navigateurs prendra en charge les modules ES par défaut. Node travaille également sur le sujet et un groupe de travail dédié se concentre sur les problèmes de compatibilité entre les modules CommonJS et les modules ES.

Cela signifie qu’on pourra utiliser la balise script avec type=module ainsi que des imports et des exports. D’autres fonctionnalités relatives aux modules sont dans les tuyaux. La proposition concernant l’import dynamique est au niveau 3 du processus de spécification. Il en va de même avec import.meta qui aidera à gérer certains cas d’utilisation pour Node.js. Enfin, la proposition sur la résolution des modules aidera à atténuer les différences entre les navigateurs et Node.js. En bref, le meilleur reste à venir.

Remerciements

Merci aux différentes personnes qui ont fourni leurs retours sur ce billet ou dont les écrits ou discussions ont aidé à sa rédaction : Axel Rauschmayer, Bradley Farias, Dave Herman, Domenic Denicola, Havi Hoffman, Jason Weathersby, JF Bastien, Jon Coppeard, Luke Wagner, Myles Borins, Till Schneidereit, Tobias Koppers, Yehuda Katz, les membres du groupe communautaire WebAssembly, les membres du groupe de travail pour les modules Node ainsi que les membres du TC39.

À propos de Lin Clark

Lin est ingénieure au sein de l’équipe Mozilla Developer Relations. Elle bricole avec JavaScript, WebAssembly, Rust, Servo et dessine des bandes dessinées à propos du code.