Les modules JavaScript et l’éco-conception Web

Les briques LEGO du développement Web.

Les modules JavaScript, apparus avec le standard ECMAScript version 6 de 2015, sont aujourd’hui pleinement utilisables dans tous les navigateurs et un bon choix pour l’éco-conception Web.

Le découpage de code sous forme de modules pouvant être importés les uns dans les autres est une bonne pratique évitant du code trop long dans un même fichier et apportant la possibilité de réutiliser certains modules dans d’autres projets si la conception a été bien pensée. Mais jusqu’à la sortie d’ECMAScript 6, JavaScript n’avait pas nativement cette notion, des solutions comme CommonJS, AMD, RequireJS, etc. sont apparues.

À partir de là, il s’est avéré que référencer beaucoup de fichiers JavaScript dans un fichier HTML était peu performant, le protocole HTTP/1.1 ne s’y prêtant pas et les navigateurs Web n’étant pas optimisés pour cela.

Le regroupement des fichiers en un seul est donc devenu la norme imposant souvant un outillage supplémentaire et complexe pendant le développement.

Mais avec les modules JavaScript natifs (ECMAScript Module : ESM), HTTP/2 et les améliorations réalisées dans les navigateurs Web, il est temps de se poser la question de la pertinence de cette pratique.

Le problème du regroupement du code JavaScript

Le regroupement du code JavaScript constitué de plusieurs fichiers en un seul fichier (paquet / bundle) est une pratique conseillée pour la performance et l’éco-conception Web afin de réduire le nombre de requêtes au serveur. Mais cette pratique souffre d’un gros problème : si un des fichiers JavaScript constituant le paquet change, c’est tout le paquet qui est à changer !

Une autre bonne pratique est de définir un temps long de mise en cache sur le terminal du client pour les fichiers JavaScript afin qu’ils ne soient pas chargés de nouveau lors des visites ultérieures. Mais avec un gros paquet, s’il doit être changé, cela implique que l’ancien va rester sur le disque du terminal du client. Avec par exemple un paquet de 250 ko, c’est 1 Mo d’occupation sur le disque après seulement 3 modifications de peut être quelques lignes dans quelques fichiers JavaScript. Et comme la (mauvaise) tendance actuelle est de générer l’interface utilisateur via du JavaScript, un paquet de 250 ko est un petit paquet ! Pour les utilisateurs ayant un terminal avec peu d’espace disque, c’est la double peine : il faut vider les caches régulièrement mais ce faisant, ils perdent en performance !

Une autre pratique pour un site Web ne fonctionnant pas sur le modèle Single-Page Application (SPA) est de produire un paquet spécifique à chaque page ne contenant que le JavaScript nécessaire à la page. Mais il est probable que les différents paquets ont du code commun qui va donc être chargé plusieurs fois alors qu’il ne pourrait l’être qu’une fois s’il n’était pas dans un paquet.

Il y a des solutions de contournement à ces différents problèmes en jouant par exemple sur la configuration de l’outil qui réalise le regroupement mais ceci ajoute encore de la complexité dans le développement.

Il est donc temps de reconsidérer cette pratique et de tout simplement arrêter de l’utiliser. Nous sommes persuadés qu’elle n’est plus utile avec les modules JavaScript natifs et HTTP/2.

D’aucuns dirons que cela ne fonctionne pas, comme les auteurs de Vite (un des outils du marché permettant de faire du regroupement) :

« Même si l’ESM natif est désormais largement pris en charge, l’envoi d’ESM non groupé en production est toujours inefficace (même avec HTTP/2) en raison des allers-retours supplémentaires sur le réseau causés par les importations imbriquées. »

Sauf que d’autres disent que cela fonctionne très bien, par exemple David Heinemeir Hansson (le créateur du framework Ruby on Rails) et le prouve :

« Le rêve est devenu réalité. Il est désormais possible de créer des applications Web rapides et modernes sans transpiler ni regrouper JavaScript ou CSS. »

Et vous pouvez le constater sur app.hey.com en ouvrant l’inspecteur Web.

Regardons maintenant ce que sont les modules JavaScript natifs et comment les utiliser efficacement.

Les modules ECMAScript

Dans les grandes lignes, un module JavaScript natif :

  • correspond à un fichier,
  • importe des éléments (fonction, classe, module, objet, etc.) d’autres modules avec le mot clé import,
  • exporte des éléments avec le mot clé export,
  • peut importer dynamiquement un autre module en utilisant la fonction import(),
  • s’exécute automatiquement en mode strict et tout ce qui n’est pas exporté est privé au module,
  • est inclus dans le code HTML par un élément <script> ayant l’attribut type à la valeur module et l’attribut defer lui est automatiquement attribué,
  • n’est exécuté qu’une fois même s’il est inclus plusieurs fois dans le code HTML ou importé plusieurs fois par d’autres modules,
  • ne peut pas fonctionner avec le protocole file://, un serveur Web est nécessaire pour le développement.

Pour plus de détails sur les modules JavaScript natifs, vous pouvez consulter le guide de MDN Web Docs.

Quelques exemples

Voici un exemple simple d’un module exportant une fonction showMessage() :

// Module Tools dans le fichier tools.js
export function showMessage(message) {
alert(message);
}

Le deuxième exemple suivant importe la fonction showMessage() du module précédent et l’appelle avec le texte "Hello World" :

// Module Main dans le fichier main.js
import {showMessage} from "/esm/dev/tools.js";

showMessage("Hello World");

Au niveau HTML, nous avons un élément <script> pour référencer le module de l’exemple précédent :

<script type="module" src="/esm/dev/main.js"></script>

Ceci a pour effet, au chargement de la page, que le message "Hello World" est affiché dans la boîte de dialogue d’alerte de JavaScript.

Il est à noter que le fichier tools.js du premier module n’est pas référencé dans le HTML de la page mais est néanmoins chargé par le navigateur à la découverte de l’importation de ce module par le module contenu dans le fichier main.js.

Le deuxième point à noter est que dans le fichier main.js, le module Tools est importé en indiquant le chemin du fichier dans lequel il est (tools.js) et là c’est problématique : il y a une dépendance du code des modules aux chemins d’accès et aux noms des fichiers JavaScript de votre site.

Il y a heureusement une solution élégante : les cartes d’importation.

Les cartes d’importation

Une carte d’importation est un objet JSON qui doit être placé dans un élément <script> de l’élément <head> d’un fichier HTML et avant toutes références à un module.

L’élément <script> doit avoir un attribut type à la valeur importmap et l’objet JSON doit au minimum avoir une propriété imports.

Cette propriété imports est un objet où un propriété définit un nom de module et sa valeur l’URL du module qui peut être un chemin relatif à votre site ou une URL externe.

Pour notre exemple, nous avons donc la carte d’importation suivante :

<script type="importmap">
{
"imports": {
"Tools": "/esm/dev/tools.js",
"Main": "/esm/dev/main.js"
}
}
</script>

Nous pouvons alors écrire le module Main de la manière suivante :

// Module Main dans le fichier main.js
import {showMessage} from "Main";

showMessage("Hello World");

Et nous pouvons également écrire le référencement du module Main au niveau HTML de la manière suivante :

<script type="module">import "Main"</script>

Il est à noter que cela fonctionne aussi avec les importations dynamiques et la fonction import(), il suffit de donner à la fonction le nom du module à importer.

Les cartes d’importation permettent donc de nommer les modules et de gérer où ils sont physiquement dans un endroit unique.
Nous vous encourageons à utiliser systématiquement cette possibilité.

Pour plus d’informations, vous pouvez consulter l’article de MDN Web Docs sur les cartes d’importation.

L’émulateur de cartes d’importation

Les cartes d’importation sont aujourd’hui supportées par tous les navigateurs mais l’ont été après le support des modules, il peut donc y avoir des navigateurs qui supportent les modules mais pas les cartes d’importation. Le pire étant avec Safari qui supporte les modules depuis la version 10.1 mais les cartes d’importation seulement depuis la version 16.4 (par exemple, les iPhone 8, bloqués à iOS 16 en profitent, mais pas les iPhone 7 bloqués à iOS 15).

Heureusement, il y a une solution de contournement nommée ES Module Shims développée par Guy Bedford et disponible sur GitHub. Cette solution émule les cartes d’importation pour les navigateurs qui supportent les modules ESM mais pas les cartes d’importation.

Pour intégrer cette solution, vous pouvez simplement ajouter le code suivant dans l’élément <head> de vos pages HTML après l’élément <script> contenant votre carte d’importation.

<script>
(function() {
if (!(HTMLScriptElement.supports && HTMLScriptElement.supports("importmap"))) {
var s = document.createElement("script");
if ("noModule" in s) {
s.src = "https://ga.jspm.io/npm:es-module-shims@1.10.0/dist/es-module-shims.js";
s.async = true;
document.head.appendChild(s);
}
}
})();
</script>

Ce script teste si le navigateur supporte les cartes d’importation et si ce n’est pas le cas, ajoute ES Module Shims. Ceci évite une requête pour rien si les cartes d’importation sont supportées par le navigateur.

Nous utilisons ES Module Shims en production sans problème comme beaucoup d’autres sites.

Le pré-chargement des modules

Il était déjà possible de pré-charger un script JavaScript (et d’autres types de contenu) en ajoutant un élément <link> dans la partie <head> d’une page HTML avec l’attribut rel à la valeur preload (plus d’informations sur MDN Web Docs).

Pour les modules JavaScript natifs, il y a la même possibilité mais beaucoup plus performante : la valeur modulepreload pour l’attribut rel (plus d’informations sur MDN Web Docs).

En effet, comme avec la valeur preload, le module va être pré-chargé et mis en cache, mais avec la valeur modulepreload, le module va en plus être interprété, compilé et ajouté à la liste des modules. Il est donc alors prêt à être exécuté.

Et ceci est fait par le navigateur en parallèle des autres traitements donc sans bloquage. Et quand le navigateur a terminé l’interprétation du HTML et des CSS, il peut démarrer l’exécution des modules qui sont déjà prêts.

Les éléments <link> avec la valeur modulepreload doivent être placés après l’élément <script> contenant la carte d’importation sinon une erreur est générée par certains navigateurs.

En reprenant l’exemple de nos deux modules, nous avons donc le code HTML complet suivant (carte d’importation, émulateur de cartes d’importation, pré-chargement des modules et importation du module principal) :

<script type="importmap">
{
"imports": {
"Tools": "/esm/dev/tools.js",
"Main": "/esm/dev/main.js"
}
}
</script>
<script>
(function() {
if (!(HTMLScriptElement.supports && HTMLScriptElement.supports("importmap"))) {
var s = document.createElement("script");
if ("noModule" in s) {
s.src = "https://ga.jspm.io/npm:es-module-shims@1.10.0/dist/es-module-shims.js";
s.async = true;
document.head.appendChild(s);
}
}
})();
</script>
<link rel="modulepreload" href="/esm/dev/tools.js">
<link rel="modulepreload" href="/esm/dev/main.js">
<script type="module">import "Main"</script>

Tous les navigateurs supportent aujourd’hui le pré-chargement des modules JavaScript natifs, le dernier à l’avoir implémenté est Safari version 17, les versions précédentes l’ignorent donc.

Le pré-chargement des modules JavaScript natifs n’est efficace qu’avec HTTP/2, ne l’utilisez pas si votre serveur ne supporte qu’HTTP/1.1, les tests que nous avons réalisés sans HTTP/2 montrent même une dégradation des performances.

Ceci est sans doute lié au fait qu’avec HTTP/2, le navigateur profite du multiplexage. Avec une seule connexion TCP il peut réaliser plusieurs requêtes dans le même flux, là où avec HTTP/1.1, il faut une connexion TCP par requête et qu’en plus le nombre de connexions TCP est limité.

Par ailleurs, il ne faut pré-charger que les modules qui sont réellement utilisés dans la page. Si vous pré-chargez un module qui n’est pas utilisé, une alerte est envoyée dans la console par le navigateur et ce n’est pas éco-responsable, vous faites consommer pour rien de la bande passante, de la CPU et donc de l’énergie à l’utilisateur.

La minification des modules

Si le regroupement du code JavaScript n’est plus nécessaire, la minification reste par contre une bonne pratique.

Dans la phase de développement elle ne sert à rien, nous optons donc pour la réalisation de cette étape dans la phase de déploiement en production.

Pendant le développement nous avons donc juste besoin à minima d’un serveur Web (contrainte des modules ESM), d’un éditeur de texte (IDE) et d’un navigateur Web. Donc un environnement de travail léger qui peut fonctionner sur un ordinateur de moyenne gamme et très productif. Par exemple, pas d’attente d’un build après la modification d’une ligne de JavaScript, la modification est immédiatement disponible dans le navigateur après le rechargement de la page.

Dans la phase de déploiement en production, il faut par contre prévoir des scripts pour automatiser la minification des modules.

Les fichiers minifiés sont renommés et mis dans un autre répertoire, ainsi par exemple le fichier tools.js de notre exemple précédent devient tools-n9flb5.js et placé dans un répertoire /esm/prd/. L’élément ajouté dans le nom du fichier peut par exemple être un checksum du fichier minifié et permet une mise en cache longue sur le terminal du client. Si une modification est apportée au fichier, son nom changera et le navigateur chargera cette nouvelle version.

Le changement de nom et la mise dans un autre répertoire à un impact sur le code HTML des pages au niveau de la carte d’importation et des liens de pré-chargement. Il faut donc prévoir éventuellement des scripts dans un site purement statique pour modifier ces parties dans le HTML statique ou une variante, suivant l’environnement, dans le code serveur délivrant dynamiquement le HTML.

Par exemple, le code complet précédent doit devenir en production quelque chose comme ceci :

<script type="importmap">
{
"imports": {
"Tools": "/esm/prd/tools-n9flb5.js",
"Main": "/esm/prd/main-1a1os4g.js"
}
}
</script>
<script>
// ES Module Shims : pas de changement sauf si vous hébergez le script
</script>
<link rel="modulepreload" href="/esm/prd/tools-n9flb5.js">
<link rel="modulepreload" href="/esm/prd/main-1a1os4g.js">
<script type="module">import "Main"</script>

Conclusion

Les modules JavaScript sont une opportunité pour revoir la façon de développer des sites ou des applications Web et particulièrement dans une optique d’éco-conception grâce à la non nécessité de regrouper le code JavaScript :

  • cela règle tous les problèmes de mise en cache de paquets trop gros sur les terminaux des clients,
  • permet de simplifier et d’accélérer le développement par l’abandon d’outillage complexe,
  • et permet du coup d’utiliser longtemps des ordinateurs de moyenne gamme (pas besoin de passer au PC dernier cri).

Nous avons fait le saut, d’autres aussi, alors pourquoi pas vous ?