ES6 en détails est une série d’articles décrivant les nouvelles fonctionnalités ajoutées au langage de programmation JavaScript avec la sixième édition du standard ECMAScript (ES6 en abrégé).

En 2007, lorsque j’ai intégré l’équipe Mozilla pour le moteur JavaScript, nous avions une blague récurrente : un programme JavaScript tient généralement sur une ligne et c’est tout.

C’était deux ans après le lancement de Google Maps. Peu avant, JavaScript était en grande partie utilisé pour valider des formulaires et il était à peu près certain qu’un gestionnaire <input onchange=…> tiendrait sur une ligne.

Les choses ont changé. Les tailles des projets JavaScript sont désormais démentielles et la communauté a développé des outils pour travailler à ce niveau. Un des éléments de base nécessaire est un système de modules : une façon de partager le code pour l’utiliser entre plusieurs fichiers et plusieurs dossiers – tout en garantissant que chaque partie de votre code puisse accéder à une autre lorsque c’est nécessaire – et en étant capable de charger tout ce code efficacement. Il était donc naturel que JavaScript se dote d’un système de modules. Plusieurs, en fait. Il y a également plusieurs gestionnaires de paquets : des outils qui permettent d’installer tous les logiciels et de gérer les dépendances de haut niveau. Vous pourriez penser qu’ES6, avec sa nouvelle syntaxe pour les modules, arrive un peu après la cavalerie.

Soit, aujourd’hui nous allons voir si ES6 ajoute quelque chose à ces systèmes existants et si oui ou non les futurs outils et standard pourront s’appuyer sur lui. Mais pour le moment, voyons à quoi ressemblent les modules ES6.

Les bases

Un module ES6 est un fichier contenant du code JavaScript. Il n’y a pas de mot-clé spécifique module, un module est lu comme un script. Il y a deux différences :

  • Les modules ES6 sont considérés comme écrits en mode strict, même si vous n’écrivez pas "use strict";
  • Vous pouvez utiliser import et export dans les modules.

Parlons d’abord d’export. Par défaut, tout ce qui est déclaré dans un module lui est local. Si vous voulez que quelque chose de déclaré dans un module soit public, afin que les autres modules puissent l’utiliser, vous devez l’exporter. Il existe plusieurs façons de faire. La plus simple est d’utiliser le mot-clé export.

// kittydar.js - Trouve tous les chats d'une image.
// (Cette librairie existe vraiment et a été écrite
// par Heather Arthur, mais elle n'utilisait pas les
// modules, car nous étions en 2013)

export function detectCats(canvas, options) {
  var kittydar = new Kittydar(options);
  return kittydar.detectCats(canvas);
}

export class Kittydar {
  //... plusieurs méthodes de traitement d'image ...
}

// Cette fonction auxiliaire n'est pas exportée. 
function resizeCanvas() {
  ...
}
...

Et c’est vraiment tout ce dont vous avez besoin pour écrire un module ! Vous n’avez rien à mettre dans un IIFE ou une fonction de rappel (callback). Allez-y et déclarez simplement ce qui est nécessaire. Puisque le code est un module et non un script, toutes les déclarations seront encapsulées dans ce module et ne seront pas visibles de façon globale pour les autres scripts et modules. Il suffit d’exporter les déclarations qui forment l’API publique de votre module.

En dehors des exportations, le code d’un module est tout à fait normal. On peut utiliser des objets globaux comme Object et Array. Si votre module est lancé dans un navigateur, celui-ci pourra utiliser document et XMLHttpRequest.

Dans un autre fichier, on peut importer et utiliser la fonction detectCats() :

// demo.js - Démo de Kittydar
import {detectCats} from "kittydar.js";
function go() {
    var canvas = document.getElementById("catpix");
    var cats = detectCats(canvas);
    drawRectangles(canvas, cats);
}

Pour importer plusieurs noms à partir d’un module, on écrira :

import {detectCats, Kittydar} from "kittydar.js";

Quand vous exécutez un module contenant une déclaration import, les modules qu’il importe sont chargés en premier, puis le corps de chaque module est exécuté selon un parcours en profondeur du graphe de dépendance, cela évite les cycles et permet de passer tout ce qui a déjà été exécuté.

Voilà les bases des modules. C’est aussi simple que ça. ;-)

Listes d’export

Plutôt que de marquer chaque fonctionnalité exportée, vous pouvez écrire une liste de tous les noms à exporter, entre accolades :

export {detectCats, Kittydar};
// pas besoin d'utiliser le mot-clé `export` ensuite
function detectCats(canvas, options) { ... }
class Kittydar { ... }

Une liste export ne doit pas nécessairement être placée en début de fichier, elle peut apparaître n’importe où tant qu’elle est dans la portée de plus haut niveau du module. Il est possible d’avoir plusieurs listes export ou bien de mélanger les listes export avec d’autres déclarations export. Une condition à respecter : un nom ne doit pas être exporté plus d’une fois.

Renommage des imports et exports

Il arrive parfois qu’un nom importé entre en collision avec un nom déjà utilisé par ailleurs. ES6 vous permet donc de renommer les valeurs importées :

// suburbia.js
// Ces deux modules exportent quelque chose appelé « flip ».
// Pour les importer tous les deux, nous devons en renommer au moins un.
import {pain as painGlace} from "igloo.js";
import {pain as painBlé} from "boulangerie.js";
...

De la même manière, vous pouvez renommer les éléments lorsque vous les exportez. Plutôt pratique si vous voulez exporter la même valeur avec deux noms différents, ce qui peut arriver :

// unlicensed_nuclear_accelerator.js
// – lecture continue de media sans DRM
// (bibliothèque qui n'existe pas mais 
// devrait peut-être...)
function v1() { ... }function v2() { ... }
export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};

Exports par défaut

Le nouveau standard est conçu pour pouvoir opérer avec les modules CommonJS et AMD existants. Si, par exemple, vous avez un projet Node et qu’à un moment vous avez tapé npm install lodash, vous pouvez importer les fonctions individuelles de Lodash dans votre code ES6 :

import {each, map} from "lodash";

each([3, 2, 1], x => console.log(x));

Mais peut-être avez-vous l’habitide de voir _.each plutôt que each et souhaitez-vous continuer à l’écrire de cette façon ? Ou peut-être avez-vous envie d’utiliser _ comme une fonction puisque c’est utile avec Lodash ?

Pour cela, vous pouvez utiliser une syntaxe légèrement différente, en important le module sans les accolades.

import _ from "lodash";

Ce raccourci est équivalent à import{default as _} from lodash";. Tous les modules CommonJS et AMD sont présentés à ES6 avec des exports par défaut qui correspondent à ce que vous auriez obtenu en utilisant require() pour le module souhaité (autrement dit, l’objet exports).

Les modules ES6 sont conçus pour vous permettre d’exporter plusieurs choses. Toutefois, pour les modules CommonJS existants, l’export par défaut sera tout ce que pourrez obtenir. Par exemple, à l’heure où est écrit cet article, le célèbre paquet colors n’est pas particulièrement supporté par ES6. C’est un ensemble de modules CommonJS, comme la plupart des paquets npm et vous pouvez l’utilisez directement dans votre code ES6.

// Équivalent ES6 de `var colors = require("colors/safe");
`import colors from "colors/safe";

Si vous voulez que votre module ES6 ait un export par défaut, c’est simple à faire. Il n’y a rien de spécial au sujet de l’export par défaut, c’est un export comme un autre, excepté qu’il est appelé default. Vous pouvez donc utiliser la syntaxe de renommage dont nous avons déjà parlé :

let monObjet = {
  champ1: valeur1,
  champ2: valeur2
};
export {monObjet as default};

Encore mieux, vous pouvez utiliser la notation raccourcie :

export default {
  champ1: valeur1,
  champ2: valeur2
};

Les mots-clés export default peuvent être suivis par n’importe quelle valeur : une fonction, une classe, un littéral objet, à vous de choisir

Les objets module

Désolé, ça commence à faire un peu long. Cela dit, JavaScript n’est pas un cas isolé. Pour certaines raisons, les systèmes de modules des différents langages ont tendance à avoir plein de petites fonctionnalités utilitaires un peu ennuyantes. Heureusement, il ne reste qu’un seul point à voir… enfin deux.

import * as vaches from "vaches";

Quand vous écrivez import *, vous importez un objet d’espace de noms pour le module. Les propriétés de cet objet sont les valeurs exportées par le module. Ainsi, si le module vache exporte une fonction appelée meugle(), après avoir importé vache de cette façon, vous pourrez écrire vache.meugle().

Agréger des modules

Parfois, le module principal d’un paquet importera des paquets d’autres modules pour les exporter ensuite de façon unifiée. Pour simplifier ce genre de code, il existe un raccourci d’importation-exportation.

// monde.js - des saveurs de toute la planète

// importer "sri-lanka" et réexporter 
// certains de ses exports
export {Thé, Cannelle} from "sri-lanka";

// importer "guinée-équatoriale" et réexporter
// certains de ses exports
export {Café, Cacao} from "guinée-équatoriale";

// importer "singapour" et exporter tous ses exports
export * from "singapour";

Chacune de ces instructions export from est similaire à une instruction import from suivie d’un export. À la différence d’un import « réel », cela n’ajoute pas les éléments réexportés à la portée du module. Il ne faut donc pas utiliser cette notation raccourcie si vous souhaitez utiliser Thé dans votre monde.js. Il ne sera pas accessible de cette façon.

Si un des noms exporté par singapour entrait en collision avec un autre élément exporté par ailleurs, cela entraînerait une erreur. Attention donc quand vous utilisez export *.

Pfiou, on en a enfin fini avec la syntaxe. Passons aux choses sérieuses.

Qu’est-ce que fait import ?

Et si je vous disais qu’import ne fait… rien ?

Vous ne seriez pas si crédule… Eh bien, me croiriez-vous si je vous disais que le standard, pour une grande partie, ne dit pas ce que fait import ? Et que c’est une bonne chose ?

En ce qui concerne le chargement des modules, ES6 laisse carte blanche aux implémentations. Le reste sur l’exécution des modules est quant à lui spécifié en détails.

Grossièrement, lorsque vous demandez au moteur JS de lancer un module, il doit se comporter de la façon suivante, avec ces quatre étapes :

  1. Analyse (parsing) : l’implémentation lit le code source du module et vérifie la présence d’erreurs de syntaxe
  2. Chargement : l’implémentation charge tous les modules importés (récursivement). C’est cette partie qui n’est pas encore standardisée.
  3. Édition des liens (linking) : pour chaque module chargé, l’implémentation crée une portée pour ce module et la remplit avec les liaisons déclarées dans ce module, y compris celles qui utilisent des données importées d’autres modules. C’est cette partie qui fait que vous aurez une erreur si vous utilisez import {gâteau} from "mensonge", mais que le module "mensonge" n’exporte rien qui s’appelle gâteau. Dommage, vous n’étiez vraiment pas loin d’avoir du code JavaScript fonctionnel… et un peu de gâteau.
  4. Exécution (runtime) : pour finir, l’implémentation exécute les instructions présentes dans les corps de chacun des modules qui viennent d’être chargés. À ce moment, le processus d’import est déjà fini. Quand l’exécution atteint une ligne de code utilisant une valeur importée, il n’y a donc rien qui se produit.

Vous voyez, je vous avais bien dit que rien ne se passait !

Et c’est là qu’on arrive à la partie intéressante. Il y a une astuce : puisque le système ne spécifie pas le fonctionnement du chargement et parce que vous pouvez deviner en avance en regardant les déclarations import dans le code source, une implémentation ES6 peut donc faire tout le travail lors d’une compilation pour empaqueter tous les modules dans un seul fichier et l’envoyer sur le réseau ! Certains outils comme webpack utilisent cette technique.

Ça n’a l’air de rien comme ça mais c’est très important : charger des scripts via le réseau prend du temps, à chaque fois que vous allez chercher un script, il se peut qu’il ait besoin d’en importer une douzaine en plus. Un outil de chargement simpliste utiliserait beaucoup d’allers-retours réseau. Avec webpack, non seulement vous pouvez utiliser les modules ES6 dès aujourd’hui mais vous n’avez aussi aucune pénalité sur la performance lors de l’exécution.

En ce qui concerne le chargement des modules, une spécification détaillée était prévue et elle fut construite. Une des raisons pour lesquelles elle ne fait pas partie du standard final est qu’il n’y avait pas de consensus sur la façon de gérer l’empaquetage (bundling). J’espère que quelqu’un trouvera une solution parce que, comme nous allons le voir, le chargement des modules devrait vraiment être standardisé. L’empaquetage est trop délicieux pour qu’on puisse s’en passer.

« Statique contre dynamique » ou encore « les règles et comment les casser »

Alors que JavaScript est un langage dynamique, le système de modules que nous avons décrit jusqu’à présent est étonnament statique.

  • Toutes les variantes d’import et d’export sont permises uniquement au plus haut niveau dans un module. Il n’existe pas d’import ou d’export conditionnel et on ne peut pas importer dans la portée d’une fonction.
  • Tous les identifiants exportés doivent être explicitement exportés par leurs noms dans le code source. Il n’est pas possible de programmer une boucle parcourant un tableau pour exporter un ensemble de noms en fonction de données.
  • Les objets module sont gelés. Il n’y a aucune façon d’ajouter une nouvelle fonctionnalité à un module comme avec les prothèses.
  • Toutes les dépendances d’un module doivent être chargées, analysées et liées avant que le code du module ne puisse être exécuté. Il n’y a pas d’élément de syntaxe pour qu’un import puisse être chargé à la demande.
  • Il n’y a aucun moyen de récupérer suite à une erreur d’import. Une application pourrait avoir des centaines de modules qui la composent, si un seul échoue à charger, rien ne fonctionnera. Il est impossible d’importer dans un bloc try/catch (un avantage ici : le système est tellement statique que webpack peut détecter ces erreurs pour vous lors de la compilation).
  • Il n’y a pas de mécanisme pour permettre à un module d’exécuter du code avant que ses dépendances soient chargées. Cela signifie qu’un module n’a aucun contrôle sur la façon dont ses dépendances sont chargées.

Ce système fonctionne plutôt bien si vous avez des besoins statiques. Mais ce n’est pas inimaginable que d’avoir quelques besoins particuliers de temps en temps, pas vrai ?

C’est pour ça que n’importe quel système de chargement de module possède une API avec laquelle vous pouvez programmer pour aller de pair avec la syntaxe ES6 statique import/export. Par exemple, webpack inclut une API qui vous permet de « découper » du code, de charger certains paquets de modules à la demande. Cette même API vous permet de passer outre la plupart des règles évoquées dans la liste ci-avant.

La syntaxe des modules ES6 est très statique et c’est bien car cela permet d’utiliser des outils puissants pour la compilation. Mais cette syntaxe statique a été conçue pour être combinée avec une API de chargement riche, dynamique et à venir.

Quand puis-je utiliser les modules ES6 ?

À l’heure actuelle, pour utiliser les modules, vous aurez besoin d’un transpileur tel que Traceur ou Babel. Dans un article précédent de la série, Gastón I. Silva a montré comment utiliser Babel et Broccoli pour compiler du code ES6. Suite à cet article, Gastón a construit un exemple utilisant les modules ES6. Ce billet d’Axel Rauschmayer contient un exemple utilisant Babel et webpack.

Le système de modules ES6 a principalement été conçu par Dave Herman et Sam Tobin-Hochstadt qui, pendant plusieurs années, ont défendu les composants statiques du système contre tout ceux qui arrivaient pour se joindre à cette controverse (dont moi). Jon Coppeard est en train d’implémenter les modules dans Firefox. Des travaux supplémentaires sont en cours pour standardiser le chargement des modules. La suite devrait se traduire par un ajout à HTML devant vraisemblablement ressembler à <script type="module">.

Et voilà ES6.

Cette série fut tellement intéressante que je n’ai pas envie qu’elle s’arrête. Encore un épisode ? Comme ça, nous pourrons parler des points divers et variés de la spécification ES6 qui n’étaient pas suffisamment conséquents pour mériter leur propre article. Nous en profiterons pour évoquer le futur d’ECMAScript. À la semaine prochaine donc, pour la formidable conclusion d’ES6 en détails.