Les ESM (modules ES)
Par Christophe Porteneuve • Publié le 21 juillet 2022 • 12 min

Bienvenue dans notre quatorzième et dernier article de la série JS idiomatique.

On termine en beauté avec un vaste sujet : les ESM, pour ES Modules, c’est-à-dire les modules natifs à ECMAScript, qui constituent aujourd’hui le standard de la modularité en JavaScript, dans Node.js comme dans le navigateur, au moins en termes de syntaxe, mais de plus en plus souvent en termes de sémantique et de chargement.

Tu préfères une vidéo ?

Si tu es du genre à préférer regarder que lire pour apprendre, on a pensé à toi :

Dès ES2015

Le TC39, le comité à l’ECMA qui standardise ECMAScript (le standard officiel pour “JavaScript”), a senti dès le début des années 2000 (après la version ES3, en 1999) que les usages sans cesse plus complexes de JS nécessitaient un système de modules avancé, intégré directement au langage.

Hélas, les ambitions d’ES4 étaient telles qu’il s’est écroulé sous son propre poids, pour être finalement oblitéré par un ES5 beaucoup plus resserré en 2009, et il aura fallu attendre ES2015 (longtemps appelé “ES6”, mais il a inauguré le passage à des versions annuelles) pour voir apparaître une bonne partie des chantiers entamés au début du millénaire. (D’ailleurs, on trouve aux stades 1 à 3 du processus de standardisation des idées qui ont émergé pour ES4, c’est dire comme le périmètre était foisonnant…)

Concepts

Quelques mots importants avant d’entrer dans le détail de la syntaxe et de la sémantique associée.

Les ESM reprennent la correspondance 1:1 entre fichiers et modules (qu’on trouve dans CommonJS notamment) : dans un contexte ESM, le fichier est le module, et réciproquement, sans avoir à utiliser une déclaration explicite au sein du fichier. Un fichier ne constitue qu’un module, un module n’est constitué que d’un fichier.

Un ESM constitue une sorte de portée qui lui est propre (son environment record), et tout ce qui y est déclaré (par un des mots-clés déclaratifs : var, function, let, const ou class) est par défaut invisible de l’extérieur.

Les ESM sont par ailleurs automatiquement en mode strict, dans la mesure où ils constituent un nouveau contexte d’évaluation du code, comme par exemple les corps de classes ES2015. Il est donc impossible d’y déclarer par inadvertance une globale en « oubliant » le mot-clé déclaratif lors de l’initialisation.

Un ESM déclare des bindings d’import (ses propres dépendances à des modules tiers) et d’export (ce que lui met à disposition des autres modules). Il utilise pour ce faire les deux mots-clés — plutôt explicites — import et export.

Les imports entraînent, à la première résolution du module importé, son initialisation et, à terme, la mise en cache du module ainsi obtenu, sur base de son chemin résolu (son “chemin canonique”, en quelque sorte). C’est le même principe que pour le require(…) de CommonJS ou le require_once de PHP, par exemple.

Exports

Les exports sont forcément à la racine lexicale du module (“top-level”), c’est-à-dire qu’ils ne peuvent pas figurer au sein d’un bloc (structure de contrôle ou fonction) ou d’une expression ; par conséquent, ils ne peuvent pas être conditionnels ou dynamiques, ce qui entraîne une propriété fondamentale des ESM : leur structure d’export est statique.

C’est très pratique pour les analyser statiquement et ouvrir la voie à tout un outillage de type tree-shaking, élimination de code mort (DCE), etc. qu’on va retrouver notamment dans les bundlers pour du code front (Webpack, Rollup et consorts).

La seule syntaxe incontournable, celle qui suffirait, est l’export nommé a posteriori :

const VERSION = '1.0'

class Worker {
// …
}

function internal() {
// …
}

function visible() {
// …
}

// Export nommé a posteriori, qui pourrait suffire
export { VERSION, visible, Worker }

Toutefois, le TC39 avait à cœur de nous fournir des syntaxes de confort pour tout un tas de cas. On trouve donc diverses autres options.

L’export nommé à la volée, devant le mot-clé déclaratif :

export const VERSION = 1.0

export class Worker {
// …
}

export function visible() {
// …
}

L’export renommé a posteriori, notamment pour se conformer à une exigence d’API exposée (si on est un plugin, par exemple) tout en gardant des noms internes spécifiques, plus explicites :

function runLogPlugin(context) {
// …
}

export { runLogPlugin as run }

L’export par défaut, qui est forcément unique au sein du module et peut être a posteriori :

class Worker {
// …
}

export default Worker

…ou à la volée :

export default class Worker {
// …
}

Lorsqu’il exporte une déclaration, un export par défaut n’est possible que pour les fonctions et les classes (après tout, une classe ES2015 est une fonction sous le capot), mais pas les constantes (const) et variables (let, var).

Un export par défaut peut également publier une valeur littérale :

// users.js
// En attendant les JSON modules, exportons un tableau…
export default [
{ name: 'Alice', age: 21 },
{ name: 'Bob', age: 28 },
]

// CustomButton.stories.js
// Le Component Story Format de Storybook utilise
// l'export par défaut d'un objet :
export default {
title: 'Buttons / CustomButton',
component: CustomButton,
parameters: { /* … */ },
}

// version.js
// Allez, on exporte une primitive
export default '1.0'

L’export par défaut, un export nommé comme les autres

Contrairement à ce qui se passe en CommonJS (on y reviendra), l’export par défaut reste un export nommé, en l’occurrence nommé default, on a simplement une syntaxe concise qui nous évite de faire un truc du genre :

// Ugh 🤮 — ne fais pas ça !
export { Worker as default }

Un ESM n’a que des exports nommés, contrairement à CommonJS ; c’est d’ailleurs pour ça que lorsqu’une interopérabilité permet à du CJS de charger un module ESM (transpilé ou non), il doit pour récupérer l’export par défaut faire require('./transpiled/esm.js').default au lieu de s’arrêter au require seul, comme il le ferait pour un « export par défaut » CJS (module.exports = …).

Où placer les exports ?

Dans la mesure où on peut exporter à la volée, les exports peuvent être éparpillés un peu partout dans le module, mais toujours en racine lexicale.

En revanche, il est de coutume de placer l’éventuel export par défaut tout en bas du module. Personnellement, j’ai tendance à le mettre avant le code interne éventuel, que je précède le plus souvent d’un commentaire bannière, comme ceci :

export function blah() {
// …
}

// …

export default Worker

// Internal helpers
// ----------------

function computeRange(from, to) {
// …
}

function someOtherInternalStuff() {
// …
}

Imports

Les imports sont eux aussi forcément à la racine lexicale du module, mais en plus de ça ils doivent être explicitement hoisted, c’est-à-dire ramenés en début de portée, et donc au tout début du module (exception faite des commentaires, qui peuvent apparaître avant ou entre deux imports).

C’est là aussi une grosse différence avec CommonJS, ou les appels require(…) peuvent figurer n’importe où. On verra toutefois qu’on a cette possibilité pour les imports dynamiques, que nous détaillerons tout à l’heure.

La syntaxe incontournable d’import, qui suffirait pour tout, est l’import nommé :

import { addSeconds, subDays } from 'date-fns'

Note déjà que c’est une syntaxe parfaitement statique : le spécificateur du module est « en dur », ainsi que les bindings que tu en importes. Là aussi, c’est du pain béni pour l’outillage d’analyse statique et d’optimisation.

Il est possible de renommer un import, notamment lorsque son nom est trop générique pour rester explicite dans la suite de ton code, ou lorsque tu importes deux bindings homonymes depuis des modules distincts :

import { join as joinPaths } from 'node:path'
import { run as runLogs } from './plugins/log.js'
import { run as runMark } from './plugins/mark.js'

On pourrait s’en servir pour importer de façon lisible un export par défaut, mais il y a une syntaxe plus agréable, sans les accolades :

// Ugh 🤮
import { default as Worker } from './worker.js'

// FTW 😎
import Worker from './worker.js'

On peut bien sûr combiner les syntaxes d’import (export par défaut et autres exports nommés), auquel cas il faut importer l’export par défaut en premier :

// « J'ai toujours pas compris que Les Hooks C'est La Vie™ »
import React, { PureComponent } from 'react'

Parfois importer un module sert juste à exécuter son initialisation, qui se suffit à elle-même ; on n’a pas besoin d’en importer des bindings, peut-être même n’en a-t-il pas. On fait alors un bare import :

import './db/register-models.js'
import './utils/monkeypatch-router.js'

Enfin, on souhaite parfois récupérer tous les exports nommés d’un module (hors l’export par défaut) sous forme d’un objet qui agit comme un espace de noms local pour ce module :

// ⚠️ Rarement une bonne idée !
import * as client from './api/index.js'

Cette approche est rarement pertinente, même si des cas légitimes existent. Pour du code front en particulier, elle complique la vie aux outils de tree-shaking et d’élimination de code mort (DCE) qui reposent sur l’analyse statique du graphe de dépendances, et peut donc entraîner un bundle inutilement lourd.

Pour l’anecdote, on peut récupérer l’export par défaut au passage, mais c’est vraiment pas joli :

// Meh.
import apiDefault, * as client from './api/index.js'

Note que les fonctionnalités d’import automatique de nos éditeurs, qui s’améliorent en continu pour les codes ES2015+, nous évitent cet écueil éventuel en générant automatiquement des imports nommés directs.

Ré-exports

Il existe certaines formes d’exports que je n’ai pas évoquées tout à l’heure : les ré-exports. Elles permettent à un ESM de ré-exporter des bindings issus d’un autre module, comme s’il s’agissait des siens directement. Le but est généralement de préserver un découpage du code en sous-modules tout en évitant aux modules nous utilisant de devoir importer les éléments de notre API depuis tout un tas de fichiers différents.

On peut ré-exporter de façon ciblée, via un ré-export nommé :

export { Worker, visible } from './worker.js'

Ou carrément ré-exporter tous les exports nommés… à l’exception de celui par défaut :

export * from './worker.js'

Cette subtilité donne des fichiers surprenants au premier abord pour des ré-exports véritablement intégraux, comme dans la sympathique structure de fichiers pour projets React de Josh Comeau :

// FileViewer/index.js
export * from './FileViewer'
export { default } from './FileViewer'

Ainsi, il ré-exporte bien tous les exports du module d’origine. C’est un peu comme une redirection, ou un lien symbolique.

Import dynamique

Jusqu’ici nous avons vu les imports statiques, hoisted en haut de module et dotés de chemins définis en dur.

Il arrive toutefois qu’on souhaite importer un module défini dynamiquement, par exemple pour initialiser une série de plugins qui diffèrent d’un utilisateur à l’autre, ou pour tirer parti du code splitting de grosses bases de code front-end.

À cet effet, les ESM proposent également une fonction import(…), qui accepte un chemin résolu de la même façon que pour les imports statiques, mais renvoie une promesse pour l’espace de noms du module1. On peut donc l’utiliser comme toute promesse : soit avec .then(), soit avec await.

L’espace de noms expose tous les exports du module, y compris default (contrairement à ce qui se passe avec un import * from, par exemple).

Pour récupérer un export nommé, ça ressemblerait donc à ça :

async function loadPlugin(path) {
const { run } = await import(path)
run(pluginRegistry)
}

En revanche, pour récupérer l’export par défaut, contrairement aux imports statiques, on garde une déstructuration :

async function loadEvaluatorBundle() {
const { default } = await import('./evaluator-bundle.8354f63.js')
default()
}

1 si tu veux frimer en soirée 😎 (choisis bien la soirée…), il s’agit techniquement d’un “module namespace exotic object”, nous dit la spec. Je pose ça là.

Initialisation asynchrone

Pour rebondir sur l’import asynchrone, ES022 officialise le top-level await, qui permet aux racines lexicales de modules d’utiliser l’opérateur await, se comportant un peu comme de grosses fonctions async implicites.

C’est transparent pour les modules qui les importent de façon statique : l’initialisation de ceux-ci attendra juste la fin de l’initialisation asynchrone du module pour continuer son exécution.

Avant ça, lorsque l’opérateur await était limité aux fonctions async, les modules ayant besoin d’une initialisation asynchrone devaient l’enrober dans une fonction async et appeler celle-ci, par exemple :

// ❌ Snif. Pas glop.
import { process } from './some-module.mjs'

export let output

init()

async function init() {
const dynamic = await import(computedModuleSpecifier)
const data = await fetch(url)
output = process(dynamic.default, data)
}

Le souci avec ça, c’est que l’initialisation du module n’attendait pas que init ait fini son travail asynchrone : le module devenait immédiatement disponible à l’import, avec un output à undefined jusqu’à nouvel ordre. Pour simplement planifier du travail ultérieur sans impacter le bon fonctionnement des exports, ça passait bien, mais pour le reste, c’était un problème.

Désormais, on peut attendre la fin du traitement, et les imports de notre module suspendront l’initialisation de leur propre module jusqu’à ce que le nôtre ait bouclé son initialisation asynchrone. 😎

// ✅ Tellement mieux.
import { process } from './some-module.mjs'

export const output = await init()

async function init() {
const dynamic = await import(computedModuleSpecifier)
const data = await fetch(url)
output = process(dynamic.default, data)
}

C’est hyper pratique pour plein de cas, par exemple…

// Charger la bonne locale
const i18n = await import(`/locales/${navigator.language}`)

// Garantir la disponibilité *initialisée* d'une ressource externe
const connection = await connectToDB()
export default connection

// Charger une ressource optionnelle
try {
await import('hiredis')
} catch (err) {
// Missing optional dependency: just move on
}

// Avoir une solution de secours pour le chargement d'une dépendance externalisée
let jQuery
try {
jQuery = await import('https://cdn-a.com/jQuery')
} catch {
jQuery = await import('https://cdn-b.com/jQuery')
}

Live bindings

Tu as peut-être été surpris·e de me voir exporter tout à l’heure des let qui auraient très bien pu référencer des primitives (chaînes de caractères, nombres, booléens, etc.). En effet, hors des ESM, les mécanismes de modules exposent les exports par copie, de sorte qu’un changement au sein du module n’est pas reflété dans les imports des modules qui l’utilisent (on en reparlera dans la prochaine section).

Un des trucs vraiment forts des ESM, ce sont les live bindings. Il est possible d’exporter un let de quelque valeur que ce soit, après quoi d’autres ESM l’importent, et si le module d’origine ré-affecte le letles modules importateurs ont leur import à jour.

Par exemple :

// periodical-counter.js
export let counter = 0

setInterval(() => counter++, 1000)

// counter-reader.js
import { counter } from './periodical-counter.js'

setInterval(() => console.log(counter), 1000)
// => Logue 0, puis 1, puis 2…

Vérifie donc par toi-même (pense à ouvrir le panneau de console en bas à droite).

Différences avec CommonJS

Si le TC39 a réalisé dès le début des années 2000 l’importance d’un système de modules, il ne l’a finalement fourni qu’en 2015. L’écosystème n’a pas attendu les bras croisés, tu t’en doutes, et on a vu surgir pas mal d’options. Lorsque la poussière est retombée, c’est CommonJS (plus précisément “CommonJS Modules”), inspiré notamment par le projet Narwhal, qui a gagné la bataille.

Ce format a servi de socle au système de modules de Node.js, puis aux bundlers de code front-end, ainsi qu’à la transpilation des ESM par Babel et TypeScript, entre autres.

CommonJS (“CJS”) étant implémenté en JS, hors du moteur lui-même, il est toutefois naturellement plus limité. Par ailleurs, certaines sémantiques d’export et d’import divergent. Faisons un rapide topo, sans trop entrer dans les détails :

Sujet CJS ESM
Import statique nommé const { … } = require(…) import { … } from '…'
Import statique par défaut const x = require(…) import x from '…'
Bare import require('…') import '…'
Export nommé exports.x = x / module.exports = { x: x } export { x } ou export à la volée (ex. export function x…)
Export par défaut module.exports = x export default x ou export à la volée (classes / fonctions seulement)
Module courant module import.meta
Localisation du module courant __filename, __dirname import.meta.url
Live bindings Impossible Pris en charge
Dépendances circulaires Piège si certains maillons changent module.exports par la suite Pris en charge

Tout est dans la runtime !

Dans cet article, on parle du fonctionnement “natif” des ESM, lorsqu’ils sont pris en charge directement par le moteur, notamment dans Node.js ou dans les navigateurs au travers de <script type="module"> par exemple.

Toutefois, on utilise souvent la syntaxe ESM sans pour autant les exécuter en tant que tels. C’est notamment le cas lorsqu’ils sont transpilés par Babel, TypeScript, ou les bundlers (ex. Webpack, Rollup, Parcel). Certains environnements techniques n’ont par ailleurs (pour le moment) qu’une prise en charge partielle des ESM (on pense à Jest, par exemple), ou ajoutent une couche d’interopérabilité sur les imports / exports avec l’écosystème CommonJS (comme dans Node.js 15+).

Ces cas de figure feront l’objet d’articles et vidéos complémentaires à l’avenir. Mais dans tous les cas, bien comprendre la sémantique de fond des ESM reste indispensable, quel que soit le contexte technique dans lequel tu t’en sers.

ESM et performance front

Si les ESM sont devenus très populaires, pour du code front-end, ils entraînent nécessairement une cascade de requêtes HTTP au fil de la découverte, par le moteur, des imports statiques utilisés par chaque module obtenu.

Cette approche emporte le plus souvent un impact très négatif sur la performance de chargement, et la performance perçue par l’utilisateur, et ce même avec des coups de pouce de HTTP/2 voire HTTP/3. Ce n’est pas pour rien que le format AMD, conçu pour les cascades de chargement asynchrones, avait perdu la guerre face à CommonJS : on s’est vite rendu compte que sur mobile notamment, il était largement préférable de charger des modules plus gros mais nettement moins nombreux, d’où le succès des bundlers.

Toutefois, dans un environnement de développement, reconstruire à chaque sauvegarde de module le bundle, fut-ce en mémoire seulement, pour ensuite calculer une portion de graphe de modules à renvoyer au client (“hot update”) et l’appliquer à la mano côté client, n’est pas si rapide que ça. Sur des applications clients suffisamment riches, c’est même assez lent.

Ce constat a conduit à l’émergence d’outils comme Vite, basés le plus souvent sur ESBuild, qui adoptent une double approche :

  • En développement, ils ne bundlent pas et se contentent de traiter l’ESM modifié seul (de façon ultra-rapide) et de le recharger seul côté client (via un <script type="module">), ce qui est pratiquement instantané et ne nécessite qu’une minuscule runtime côté client. On retrouve une réactivité éclair lors de nos devs !
  • En production, ils s’appuient sur les bundlers ayant fait leurs preuves, comme Webpack ou Rollup, pour produire les bundles appropriés, soigneusement optimisés.

Cette approche gagne rapidement du terrain, et l’outillage ne cesse de grandir et d’évoluer pour couvrir divers types de besoins, garde un œil attentif là-dessus !

Prochainement…

Le sujet des ESM n’est pas clos, et le TC39 continue à travailler sur plusieurs mécanismes d’extension :

  • Les Import assertions sont au stade 3 depuis novembre 2020 et permettent de mieux sécuriser les imports en explicitant le type de contenu attendu (JSON, JS, etc.). C’est notamment pris en charge pour les imports statiques et dynamiques dans v8 depuis juin 2021 déjà (dans Node depuis la 16.14 / 17.1).
  • Les JSON modules, au stade 3 depuis janvier 2021, s’appuient là-dessus pour pouvoir considérer des fichiers JSON comme des ESM à part entière. On les trouve actuellement à titre expérimental dans Node.js (drapeau de ligne de commande --experimental-json-modules de Node 12 à Node 17, sans drapeau depuis Node 18).

Côté web, des travaux comme les import maps visent à faciliter l’écriture de code JS universel (ex. browser + Node.js) en permettant l’utilisation d’aliases dans les chemins d’import plutôt que d’URL complètes systématiques. Là aussi, v8 mène la bataille avec une prise en charge depuis Chrome / Edge 89 notamment. Node.js a par ailleurs des travaux expérimentaux sur les Policies, qui permettent entre autres de simuler les import maps.

Pour en savoir plus…

En attendant les prochains articles / vidéos sur le sujet, voici déjà plein de compléments à te mettre sous la dent pour explorer le sujet (outre les liens de la section précédente) :

Ça t’a plu ? As-tu vu le reste de la série ?

Il y a plein de sujets merveilleux dans cette série JS idiomatique, va donc vérifier si tu n’en aurais pas raté quelques-uns !

Pour être sûr·e de ne rater aucun de nos tutos et articles, le mieux est encore de t’abonner à notre newsletter et à notre chaîne YouTube. Tu peux aussi nous suivre sur Twitter.

Et bien entendu, n’hésite pas à jeter un œil à nos formations ! Si explorer l’intégralité des recoins du langage t’intéresse, la ES Total notamment est faite pour toi !