8 October 2026
JS moderne : modules, async/await et bonnes pratiques en JavaScript
Imports et exports, async/await, modules cycliques et bonnes pratiques : ce qui change pour lire et écrire du code JavaScript à jour.
JavaScript a cessé d’être un langage de scripts limité. Modules, async/await, tâches asynchrones et outils de build font de lui un langage complet pour le développement frontend et backend. Ce qui change, c’est comment on structure le code et comment on vérifie qu’il fonctionne.
Pourquoi les modules changent le code
Historiquement, quand un script JavaScript était réparti sur plusieurs fichiers, une question se posait : comment le premier fichier donne-t-il accès aux fonctions du second ? Sans modules, les réponses étaient des variables globales ou des librairies tierces. Tout le monde pouvait écrire, tout le monde pouvait modifier.
Les modules apportent une règle simple : les fonctions et les variables ne sont visibles que si le fichier les expose explicitement. Rien de plus.
Un fichier exporte ce qu’il veut partager avec d’autres :
// utils.js
export function formatDate(date, locale) {
return new Intl.DateTimeFormat(locale).format(date);
}
export const MAX_RETRIES = 3;
Un autre fichier l’importe par son chemin :
// app.js
import { formatDate, MAX_RETRIES } from "./utils.js";
console.log(formatDate(new Date(), "fr-FR"));
console.log(MAX_RETRIES);
Le nom de fichier, y compris l’extension, compte dans l’import. Un module ne travaille pas seul : il est chargé par un navigateur, un serveur ou un outil de build.
export et import : plusieurs façons de partager
Un module peut exporter des valeurs, des fonctions, des classes ou un objet groupé.
// api.js
export const API_URL = "https://api.example.com";
const headers = new Headers({
"Content-Type": "application/json",
});
export async function fetchPosts() {
const response = await fetch(API_URL);
return response.json();
}
On peut exporter une fonction directement, regrouper plusieurs valeurs dans un objet, ou utiliser export default pour une seule valeur principale.
// un seul export par défaut
export default class Bus {
constructor() {
this.listeners = new Map();
}
on(name, listener) {
if (!this.listeners.has(name)) {
this.listeners.set(name, []);
}
this.listeners.get(name).push(listener);
}
}
L’import correspond au modèle choisi. import Bus from "./bus.js" récupère le default. import { on } from "./bus.js" récupère le membre nommé. Un fichier peut aussi importer tout un module sous un alias :
import * as busAPI from "./bus.js";
Ce style évite les conflits quand deux modules exposent le même nom.
async/await : la boucle de lecture et d’écriture asynchrone
Lorsqu’une opération attend une réponse réseau, une lecture de fichier ou une base de données, elle ne doit pas bloquer le reste du programme. Avant async/await, la vie se passait avec des callbacks imbriqués ou des promesses chainées.
fetch(API_URL)
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error(error));
async/await rend cette lecture presque linéaire, sans perdre l’asynchronisme.
async function loadPosts() {
try {
const response = await fetch(API_URL);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error(error);
}
}
const posts = await loadPosts();
La clé est async avant la fonction. Elle indique que la fonction renvoie une promesse, et await suspend l’exécution jusqu’à ce que la promesse se résolve. Un await placé dans une fonction async est légal ; dans une fonction synchrone, il lève une erreur.
La bonne pratique courante : ne pas mélanger les deux styles au hasard. Une fonction qui appelle await doit être async. Une fonction qui ne fait que renvoyer une promesse peut rester async et laisser le appelant décider.
Promise.all, Promise.allSettled et la sûreté des parcours
Quand plusieurs opérations se lisent en parallèle, il faut choisir ce qui arrive quand la première échoue.
const [users, posts, config] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchConfig(),
]);
Si une des trois appels échoue, toutes les données partent en erreur. C’est voulu quand les trois données sont interdépendantes. Sinon, Promise.allSettled garde les résultats de chaque appel, réussi ou non :
const results = await Promise.allSettled([
fetchUsers(),
fetchPosts(),
fetchConfig(),
]);
const failures = results.filter((result) => result.status === "rejected");
Les deux appels sont lancés en parallèle, mais la lecture suit une logique explicite. Ce choix doit être décidé avant d’écrire le code, et documenté.
fetch avec des en-têtes et des cookies
fetch est le point d’entrée de la communication réseau dans les navigateurs et Node.js moderne. Il remplace XMLHttpRequest pour la plupart des cas, mais il ne gère pas tout.
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const message = await response.text();
throw new Error(`Request failed: ${response.status} ${message}`);
}
Un fetch qui renvoie 404 ou 401 ne déclenche pas une erreur JavaScript. C’est le ok qui indique si le statut est correct. Ignorer ce détail est une cause fréquente de bugs.
Il faut aussi se méfier des cookies : fetch ne les envoie pas si l’option credentials n’est pas définie, ou seulement si elle vaut same-origin. Pour une API d’un autre domaine, il faut explicitement autoriser credentials: "include".
Interdire les modules cycliques
Un module qui importe un autre, qui importe un troisième, et qui finit par dépendre de lui-même crée un cycle. Les navigateurs et Node.js résolvent les dépendances en plusieurs passes, mais le résultat est parfois surprenant.
// a.js
import { value } from "./b.js";
export const A = value;
// b.js
import { A } from "./a.js";
export const value = 1;
console.log(A);
Ce sujet est discuté plus tard, mais le diagnostic est simple : un tableau d’importations ne doit pas boucler. L’outil de build ou la structure des dossiers doit la prévenir. La mesure la plus fiable est de ne jamais importer entre deux modules en conflit, et de déplacer le problème dans une fonction appelée plus tard.
Les modules existent dès le chargement
Un module est exécuté une seule fois, lors de son premier chargement. Les valeurs exportées sont déterminées lors de l’analyse, avant qu’aucune ligne ne s’exécute. Cela entraîne des règles précises quand un module lit une valeur qu’un autre exporte.
// counter.js
export let count = 0;
export function increment() {
count += 1;
}
Le module est exécuté une seule fois, quel que soit le nombre de fichiers l’important. count est partagé. Modifier count dans un module modifie la valeur que les autres voient. C’est le module qui garde l’état global.
Les scripts classiques sont lents
Un script <script src="..."> bloque le parse du document jusqu’à ce qu’il soit chargé et exécuté. Sur une page lourde, cela retarde l’affichage du contenu.
<script src="app.js"></script>
type="module" change ce comportement : le script est exécuté après le parse du document, en mode dégradé.
<script type="module" src="app.js"></script>
Un module est aussi isolé du monde global : les fonctions et variables définies dans le module n’existent pas en tant que globals. C’est souvent un avantage : moins de conflits.
defer et async pour les scripts non-modulaires
Un script sans type="module" présente trois options pour ne pas bloquer le rendu.
<script src="analytics.js" async></script>
<script src="app.js" defer></script>
async charge le script en parallèle et l’exécute dès sa disponibilité, avant le DOMContentLoaded. Il est adapté à un script indépendant, comme un compteur. defer attend la fin du parse du document, puis exécute les scripts dans l’ordre d’apparition. C’est l’option la sûre pour un script qui manipule le DOM.
Comparer les outils
Les modules ne suffisent plus dans un projet réel. Un outil de build transforme plusieurs fichiers en un ou plusieurs chargés par le navigateur, et le code final n’est plus celui du développeur.
| Outil | Usage courant | Ce qu’il apporte |
|---|---|---|
| Vite | frontend, React, Vue, Svelte | hot reload, config minimale, packagage léger |
| ESBuild | build rapide | transpilation et minification en lignes de commande |
| Webpack | projet complexe | dépendances dynamiques, code splitting, plugins |
| Playwright | tests | navigation complète, attente de réseau, assertions |
Le choix dépend de la taille du projet et de l’équipe. Un site statique peut fonctionner sans build. Un interface utilisateur souvent modifiée gagne à être bundlée.
Éviter les faux problèmes
Un module n’existant pas, un import en conflit, ou un chemin mal écrit qui lève une erreur d’exécution, sont des problèmes différents, mais tous se résolvent de la même façon : plus de dépendances implicites.
| Symptôme | Cause probable | Correction |
|---|---|---|
Cannot find module | Fichier introuvable ou extension manquante | Vérifier le chemin et l’extension .js |
SyntaxError sur un export | Directive de module mal écrite | Vérifier la casse et le nom de l’export |
| Le module ne fait rien | Importé mais jamais exécuté | Vérifier le nom exact de l’import |
| Comportement inattendu | Chaîne de promesse cassée | Ajouter un catch et un log d’erreur |
| Problème de cache | Fichier obsolète servi | Vider le cache du navigateur ou du build |
Avant de changer le code, vérifier les messages d’erreur. Un message Module not found n’est pas un problème de logique : c’est un problème de chemin. Un TypeError sur une fonction indéfinie est un problème d’export.
Tester ce qu’un module exporte
Un module est une unité de vérification. La plus simple est de l’importer dans un test et de vérifier ses fonctions. La bibliothèque de test choisie n’a pas d’importance ; ce qui compte est d’exercer la frontière du module.
import { formatDate, MAX_RETRIES } from "./utils.test.js";
test("formatDate retourne une chaîne", () => {
const result = formatDate(new Date("2026-10-08"), "fr-FR");
expect(result).toBeDefined();
expect(typeof result).toBe("string");
});
test("MAX_RETRIES est un nombre positif", () => {
expect(MAX_RETRIES).toBe(3);
expect(MAX_RETRIES).toBeGreaterThan(0);
});
Un module avec des fonctions pures est plus testé. Une fonction qui ne modifie rien et ne lit que ses entrées est plus facile à comprendre, à tester et à refactorer. Cette règle ne veut pas dire que tout le code doit être fonctionnel : elle dit que les parties critiques doivent être les plus pures possibles.
S’assurer que le code s’exécute
Un jour, le code sera modifié et le fait le couper se perdra. Les vérifications automatiques rétablissent la confiance : un test qui fail, un build qui échoue, une page qui ne se charge pas. Le coût d’une erreur trouvée après la publication est bien plus élevé que celui d’une erreur trouvée avant.
- Exécuter les tests sur les fonctions critiques.
- Valider le build, même si le site est servi sans.
- Naviguer sous plusieurs tailles d’écran.
- Ne pas laisser le debug mode actif en production.
La vérité n’est pas dans le code qui paraît correct en local. C’est dans l’exécution : le script charge, la fonction renvoie, la page affiche.
Le point commun
JavaScript sur le web et dans les back-ends est maintenant un langage de modules, de promesses et d’outils de build. Les bases restent les mêmes : un module expose ce qu’il partage, un module importe ce qu’il utilise, et une promesse représente une valeur qui viendra.
Ce qui change, c’est la manière de construire et de vérifier. Un tableau des règles ci-dessus aide à rester sur ses bases quand le projet grandit.
- Commencer par une structure de modules claire.
- Choisir un seul style d’asynchronisme et le respecter.
- Prévoir un test pour chaque module critique.
- Exécuter un build et une navigation avant de publier.
Le but n’est pas d’être rupturiste, mais de ne pas laisser le passé imposer la structure du futur.