ESM et CJS - le match

#ESM# CJS# Javascript

Une bataille a lieu depuis quelques années, et sur le ring, on ne trouve ni Elon ni Mark, mais deux standards Javascript : ESM et CJS.


Note: il existe une version en anglais de cet article.

L'affrontement de ces deux standards entraine régulièrement des débats parfois enflammés. J'ai tenté d'y voir plus clair.

En cause donc, ESM (pour "ECMAScript Modules") et CJS (pour "CommonJs"), deux standards de format pour diviser du code ("code splitting"). Ce qui amène notre première question :

1. Pourquoi Javascript possède deux standards pour une même fonctionnalité ?

Plusieurs articles reviennent sur l'histoire de cette cohabitation, mais après une lecture attentive de ceux-ci et surtout une plongée passionnante dans le Google group à la fondation de CommonJS, je résumerais les choses ainsi :

Après 12 ans d'existence environ, Javascript prend de l'importance, Javascript a besoin de nouvelles ressources que celles en place pour gérer du code, notamment parce que le besoin d'utiliser Javascript pour faire du serveur se fait sentir. C'est l'appel que lance Kevin Dangoor le 29 janvier 2009 dans "What Server Side Javascript Needs?" et qui permet la mise en place d'un Google groupe pour créer un standard de modularisation : CommonJs.

Le + de Dre Drey

CommonJS ou ServerJS ?

Quand Kevin Dangoor lance son fameux appel, l'idée est de nommer ce standard ServerJS. Cependant, au moment de la constitution du groupe de travail, la réflexion amène les dev à changer le nom en CommonJS, dans l'idée que ce standard ne soit pas applicable qu'au serveur...

CommonJS permet donc de modulariser son code - aka le séparer en parties qui peuvent être importées et exportées vers et depuis d'autres fichiers. Malgré les envies du début, ce standard sera surtout développé dans l'objectif de faciliter l'utilisation de Javascript comme langage serveur. D'ailleurs, on trouve aussi dans le groupe de travail de CommonJS Ryan Dahl, qui se saisit de cette opportunité pour présenter la première version de Nodejs.

Mais CJS possède quelques désavantages fondamentaux qui ne rendent pas son utilisation optimale du côté des navigateurs, comme le résume Andy Jiyang :

  • module loading is synchronous. Each module is loaded and executed one by one, in the order that they are required.
  • difficult to tree-shake, which can remove unused modules and minimize bundle size.
  • not browser native. You need bundlers and transpilers to make all of this code work client-side. With CommonJS, you are stuck with either big build steps or writing separate code for client and server."

(On trouve encore bien d'autres problèmes à CJS quand on creuse un peu : la sécurité par exemple (comme le rappelle Dan Fabulich "(...)in CJS both module and exports can be replaced on the fly within the module itself."))

Le + de Dre Drey

C'est quoi le tree-shaking ?

C'est le processus qui se déroule au moment de l'optimisation du code et qui permet l'élimination du code mort, c'est-à-dire de code inutile, de fonctions inutilisées, etc.

Du coup, l'instance de standardisation de Javascript, ECMAScript, se penche sur la question. ECMAScript, fondé en 1997 et géré par ECMA (l'European association for standardizing information and communication systems) publie régulièrement de nouvelles normes et en 2015, le ECMAScript 6 (ES6) introduit un nouveau standard pour modulariser son code en JS, l'ESM. Ce standard est plus adapté au côté client que ne l'est CommonJS, et répond à certains problèmes posés par CJS (par exemple faire de l'asynchrone, ce qui est quand même plutôt pratique).

S'il ne fallait retenir qu'une chose de cette historique, c'est que depuis 2015 deux grands standards dominent quand il faut modulariser son code en Javascript : CommonJS et ESM. Ils sont pensés les deux pour répondre à un même besoin, modulariser du code, mais :

  • ils ont une syntaxe différente ;
  • ils fonctionnent techniquement différemment, avec chacun leurs avantages et désavantages techniques (en termes de sécurité, de tests, de bindings, etc.) ;
  • ils ont été pensés dans des contextes différents (le besoin d'utiliser Javascript du côté serveur vs du côté client) ;
  • l'un est porté par l'institution de standardisation de Javascript (ECMAScript) mais l'autre est le pilier de l'environnement serveur le plus utilisé en JS: Node ;

Alors on pourrait se dire que l'existence de deux standards, tout aussi étrange qu'elle soit, n'est pas forcément un problème. Les deux pourraient cohabiter, avec chacun leur environnement de prédilection (le navigateur d'un côté et le serveur de l'autre). Ce qui amène notre deuxième question : c'est quoi, le problème au final ?

2. C'est quoi le problème avec le fait d'avoir deux standards ?

Le noeud central du problème, c'est que cette cohabitation est difficile, car l'interopérabilité (c'est-à-dire la capacité à fonctionner ensemble) entre ESM et CommonJs est très, très trèèèès faible.

Les problèmes se voient d'ici et ils se découpent grosso modo en deux :

  1. d'un côté c'est un problème pour les auteur.trice.s de librairies : si ces deux standards ne fonctionnent pas bien ensemble, cela veut dire que les librairies en JS doivent supporter toutes les configurations possibles. Ca peut rapidement devenir un cauchemar :
  1. de l'autre côté c'est un problème pour les dev qui vont "consommer" ces librairies : si tout le code est dans un standard, peu importe lequel, il n'y a aucun problème. Mais ce sera un cauchemar dès qu'il faudra utiliser une librairie qui n'est basée que sur CJS alors que le code est en ESM. Pourquoi ?

2.1. Une syntaxe différente : Require vs Import

Si CJS et ESM possèdent les deux les mêmes types d'import/export (par défaut et nommés), les syntaxes sont différentes. Exemples avec deux fichiers, maths.js et index.js.

En CSJ, l'import/export se reconnaisst par l'utilisation des mots clé module et require :

//dans mon fichier math.js, j'exporte ma fonction avec module.exports :
module.exports = function sum(x, y) {
  return x + y;
};

// puis je l'importe dans mon fichier "index.js" avec require() :
const somme = require("./math.js");

En ESM, lesimport/export se reconnaissent par l'utilisation du mot clé import.

//dans mon fichier maths.js, j'exporte ma fonction avec export.default :
export default function sum(x, y) {
  return x + y;
}

//dans mon fichier main.js, je l'importe avec import:
import somme from "./maths.js";

2.2 Un standard global d'un côté, pas de l'autre

Autre grosse différence : l'utilisation d'ESM ne change pas seulement la façon d'importer et exporter les modules, car c'est tout un standard qui englobe aussi d'autres fonctionnalités.

Ainsi, quand on utilise le standard ESM, ce n'est pas uniquement pour préférer écrire import plutôt que require; par exemple, ESM ne transmet pas les contextes, ce qui implique entre autres un renvoi this différent (undefined ), ESM possède un StrictMode alors que CommonJS non, etc.

2.3. Synchrone vs Asynchrone

Finalement, on l'a déjà dit, mais CJS est synchrone et ESM est asynchrone ! Ce qui rend les deux très non interopérables est donc le fait qu'avec ESM on peut utiliser le mot clé await en top level (en dehors d'une fonction async par exemple), alors que CJS ne peut pas lire ce script ! Donc CJS ne peut pas transpiler un script ESM qui utiliserait un await en top level.

2.4. Une configuration différente :

Il est bon de savoir que, quand on lance un projet avec un npm init, c'est une configuration en CJS "par défaut" qui se lance (tous les fichiers .js sont considérés comme étant en cjs). Il faudrait donc utiliser la syntaxe de CJS dans le projet. Si l'on veut utiliser ESM dans un projet en CJS par défaut, il faut le signaler en ajoutant l'extension .mjs au fichier concerné.

Si l'on veut configurer tout le projet en ESM, il faut modifier la configuration dans le package.json en ajoutant "type":"module" qui indique que les fichiers .js sont en ESM par défaut. A ce moment, pour utiliser du CJS, il faut le signaler en utilisant l'extension .cjs dans les fichiers concernés.

Imaginon que l'on a un bout de code qui utilise le standard ESM et un autre bout du CJS. Il faut appliquer toute une série de règles:

  • On ne va pas pouvoir importer des fonctions du fichier cjs dans le esm en écrivant require() MAIS PIRE,on ne va pas pouvoir utiliser require() pour importer dans le fichier cjs (bien que ce soit son standard a lui) une fonction du fichier exportée en ESM.
  • Dans le même ordre d'idée, CJS ne peut pas utiliser les import statiques MAIS ca peut marcher avec l'import XX from 'colors'.
  • ... et plein d'autres règles que je ne comprends pas, même en relisant trois fois.

Franchement j'ai essayé de comprendre ce qui était faisable ou non, notamment l'explication de Dan Falbulich, mais même après plusieurs relectures, ce que je comprends c'est toujours : Je comprends rien

Super, maintenant qu'on sait ce que c'est CJS et ESM, comment ils en sont arrivés à vivre ensemble et les problèmes qu'ils posent, une dernière question subsiste quand même :

3. Dernière question (le boss de fin) : alors on fait quoi ?

Une fois que l'on s'est bien arraché les cheveux comme moi en restant bloqué.e un jour devant une impossibilité totale de fonctionnement entre deux librairies sur un projet, qu'est-ce qu'on fait concrètement devant ça (j'ai essayé comme Perceval de dire "grelotte ça picote", dans le doute, mais sans surprise ça n'a rien résolu) :

this module can only be referenced with ecmascript

Il y a débat dans la communauté JS sur les avantages et désavantages de chaque standard et sur les bonnes pratiques à adopter. Pour aller plus loin sur ce sujet, Andrea Giammarchi qui a fait partie du NodeJs module working group donne les + et - de chaque position dans un article assez nuancé (ce qui est rare sur ce sujet).

Malheureusement, concrètement, pas de solutions miracles, même si les auteur.trice.s de librairies peuvent s'en sortir en appliquant les quelques règles d'Andrea Giammarchi :

  • Consider shipping only ESM

  • If you’re not shipping only ESM, ship CJS only

  • Test that your CJS works in ESM

  • If needed, add a thin ESM wrapper for your CJS named exports

  • Add an exports map to your package.json.

Pour les les dév qui intègrent ces librairies sur leurs projets, plusieurs écoles s'affrontent :

  • L'école "on fait de l'ESM, car ils vont lâcher CJS" : en effet, depuis Node16, ESM est enfin pris en supporté pas Nodejs. Plusieurs dev adoptent donc comme technique de coder uniquement en ESM et la question "Will CommonJS be deprecated ?" est un serpent de mer qui fait surface assez régulièrement, avec l'espoir pour beaucoup que cela soit bientôt le cas.
  • L'école de "de toute facon Typescript règle ce problème".
  • L'école de "on utilise les deux" : si ESM résouds effectivement certains problèmes posés par CJS, Andrea Giammarchi qui est pourtant défenseur d'ESM dans le Node Modules Working Group, pointe certains avantages de CJS (par exemple, pour les environnements de tests). Par ailleurs, ce dernier argument est renforcé par le fait que les nouveaux bundler prennent en compte les deux standards et résolvent en partie les incompatibilités s'ils sont bien configurés.

Pour aller plus loin

Je vous recommande chaudement de plonger dans les échanges qui donnent naissance à CommonJS et à NodeJS pour comprendre les choix qui ont été faits.

Mais aussi de voir les débats sur différentes communautés et ou plateformes :

Et quelques articles bien résumés, pour entrer davantage dans les détails techniques :