Avant les modules ES, JavaScript n'avait pas de système de modules intégré. Il y avait des balises script dans le bon ordre, des schémas IIFE pour simuler l'encapsulation, puis une décennie de CommonJS dans Node.js. Les modules ES — standardisés dans ES2015 et maintenant supportés partout — sont la vraie solution. Ce guide couvre tout, des bases des exports nommés vs par défaut aux imports dynamiques, au tree-shaking, et aux schémas qui maintiennent les grandes bases de code maintenables.

Exports nommés vs Exports par défaut

Il y a deux façons d'exporter depuis un module. Comprendre la différence — et les compromis — est la base pour tout le reste :

js
// utils/currency.js

// Named exports — explicit names, multiple per file
export function formatPrice(amount, currency = 'USD') {
  return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}

export function parseCents(cents) {
  return cents / 100;
}

export const DEFAULT_CURRENCY = 'USD';
js
// Importing named exports — names must match
import { formatPrice, parseCents } from './utils/currency.js';

// You can rename on import
import { formatPrice as fmt } from './utils/currency.js';

// Import the entire namespace as an object
import * as Currency from './utils/currency.js';
Currency.formatPrice(49.99); // works
js
// components/ProductCard.js

// Default export — one per file, no name required
export default function ProductCard({ product }) {
  return `<div class="card">${product.name}</div>`;
}
js
// Importing a default export — you choose the name
import ProductCard from './components/ProductCard.js';
import Card        from './components/ProductCard.js'; // same thing, different name
Opinion : Les exports nommés sont presque toujours meilleurs pour les modules utilitaires. Ils sont explicites, compatibles avec les IDE (l'autocomplétion et "aller à la définition" fonctionnent parfaitement), et sûrs pour le refactoring — renommer une fonction est détecté par vos outils, pas vos tests. Les exports par défaut ont du sens pour les composants qui sont "la seule chose" qu'un fichier exporte, comme les composants React/Angular ou les fichiers de gestionnaires de routes.

Fichiers barrel — Utiles et dangereux

Un fichier barrel est un index.js qui ré-exporte depuis plusieurs modules, offrant aux consommateurs un chemin d'import plus propre. Ils sont partout dans les grands projets :

js
// utils/index.js — the barrel
export { formatPrice, parseCents, DEFAULT_CURRENCY } from './currency.js';
export { validateEmail, validatePhone }              from './validation.js';
export { debounce, throttle }                        from './timing.js';
export { fetchWithRetry, buildQueryString }          from './http.js';
js
// Consumer — clean import path, no need to know the file structure
import { formatPrice, debounce, fetchWithRetry } from './utils/index.js';
// or with bundlers that resolve index.js automatically:
import { formatPrice, debounce, fetchWithRetry } from './utils';

Le danger : les fichiers barrel peuvent détruire le tree-shaking. Si vous importez une fonction d'un barrel qui ré-exporte 40 modules, certains bundlers tirent les 40 — même ceux que vous n'utilisez pas. La correction est d'être sélectif sur ce que vous mettez dans le barrel, et de vous assurer que votre bundler supporte sideEffects: false dans package.json.

import() dynamique — Découpage de code à la demande

Les imports statiques (import x from '...') sont résolus au moment du bundle. L'import() dynamique est une fonction qui charge un module de manière paresseuse à l'exécution, retournant une Promise. C'est ainsi qu'on implémente le découpage de code basé sur les routes :

js
// Load a heavy chart library only when the user navigates to the analytics page
async function loadAnalyticsDashboard() {
  const { Chart }   = await import('chart.js');
  const { buildDashboardData } = await import('./analytics/data.js');

  const data   = await buildDashboardData();
  const canvas = document.getElementById('dashboard-chart');
  new Chart(canvas, { type: 'bar', data });
}

// Attach to a route change
router.on('/analytics', loadAnalyticsDashboard);
js
// Feature flags — only load a module if the feature is enabled
async function initFeature(featureName) {
  const flags = await getFeatureFlags();

  if (flags[featureName]) {
    const module = await import(`./features/${featureName}.js`);
    module.init();
  }
}

Webpack, Rollup et Vite comprennent tous l'import() dynamique et diviseront automatiquement le module chargé en un chunk séparé. Le résultat : un bundle initial plus petit, un premier chargement plus rapide.

import.meta — Métadonnées du module

import.meta est un objet qui vous donne des métadonnées sur le module actuel. La propriété la plus utile est import.meta.url — l'URL du fichier du module actuel :

js
// Get the directory of the current module (Node.js)
import { fileURLToPath } from 'node:url';
import { dirname, join }  from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname  = dirname(__filename);

// Now you can do path resolution the same way you would with CommonJS
const configPath = join(__dirname, '../config/app.json');
js
// In Vite projects, import.meta.env gives you environment variables
const apiBase = import.meta.env.VITE_API_BASE_URL;
const isProd  = import.meta.env.PROD; // boolean

// import.meta.glob — Vite-specific, import multiple files at once
const modules = import.meta.glob('./pages/*.js');
for (const path in modules) {
  const module = await modules[path]();
  console.log(path, module);
}

Modules ES vs CommonJS

Vous rencontrerez encore CommonJS (require()) dans les anciennes bases de code Node.js et certains packages npm. Voici une référence rapide pour les différences qui piègent les gens :

js
// CommonJS (CJS) — still common in Node.js
const express = require('express');
const { join } = require('path');
module.exports = { myFunction };
module.exports.default = MyClass;

// ES Modules (ESM) — the standard
import express from 'express';
import { join } from 'path';
export { myFunction };
export default MyClass;

Différences clés : les modules ES sont analysables statiquement (les imports sont résolus avant l'exécution du code), tandis que les requires CommonJS s'exécutent à l'exécution. ESM utilise le chargement async par défaut ; CJS est synchrone. Dans Node.js, les fichiers avec l'extension .mjs ou "type": "module" dans package.json sont traités comme ESM.

Piège d'interopérabilité : Vous pouvez import un module CommonJS depuis un fichier ESM (Node.js gère ça), mais vous ne pouvez pas require() un module ESM depuis un fichier CommonJS. Si vous en avez besoin, utilisez un appel import() dynamique à la place — ça fonctionne dans les deux systèmes de modules.

Tree-Shaking et exports nommés

Le tree-shaking est le processus du bundler pour supprimer le code que vous importez mais n'utilisez jamais. Il nécessite des exports nommés et des déclarations d'import statiques — les imports dynamiques et CommonJS sont beaucoup plus difficiles à tree-shaker. Voici comment écrire des modules qui se tree-shakent bien :

js
// ✅ Tree-shakeable — bundler can see exactly what's exported
export function formatDate(date) { /* ... */ }
export function parseDate(str)   { /* ... */ }
export function addDays(date, n) { /* ... */ }

// If you only import formatDate, parseDate and addDays are excluded from the bundle
import { formatDate } from './utils/dates.js';
js
// ❌ Harder to tree-shake — the whole object is one export
const dateUtils = {
  formatDate: (date) => { /* ... */ },
  parseDate:  (str)  => { /* ... */ },
  addDays:    (date, n) => { /* ... */ }
};
export default dateUtils;

// Even if you only use dateUtils.formatDate, the whole object may be bundled

Pour les bibliothèques publiées sur npm, le support du tree-shaking fait la différence entre une bibliothèque que les gens recommandent et une qu'ils évitent. Le guide MDN sur les modules couvre les règles complètes d'analyse statique qui le permettent.

Modules ES natifs dans le navigateur

Vous pouvez utiliser les modules ES directement dans le navigateur — pas de bundler requis. Ajoutez type="module" à votre balise script :

js
<!-- index.html -->
<!-- type="module" enables ES Module semantics -->
<!-- deferred by default — runs after HTML is parsed -->
<script type="module" src="./app.js"></script>
js
// app.js — can use import/export directly
import { initRouter }    from './router.js';
import { setupAuth }     from './auth.js';
import { loadDashboard } from './dashboard.js';

async function main() {
  await setupAuth();
  initRouter();
  await loadDashboard();
}

main();

Les scripts de modules sont toujours différés et s'exécutent en mode strict par défaut. Chaque module n'est exécuté qu'une seule fois, même s'il est importé par plusieurs autres modules — le navigateur met en cache l'instance du module. C'est important à savoir quand vous avez un état partagé dans des modules.

Un exemple de refactoring concret

Voici comment un fichier utils typique devient incontrôlable et est correctement divisé en modules :

js
// Before: one giant utils.js with everything
export function formatPrice(amount) { /* ... */ }
export function validateEmail(email) { /* ... */ }
export function debounce(fn, ms) { /* ... */ }
export function fetchWithRetry(url, opts) { /* ... */ }
export function parseCSV(text) { /* ... */ }
export function buildQueryString(params) { /* ... */ }

// After: split by concern
// utils/
//   currency.js     → formatPrice, parseCents
//   validation.js   → validateEmail, validatePhone
//   timing.js       → debounce, throttle
//   http.js         → fetchWithRetry, buildQueryString
//   csv.js          → parseCSV, formatCSV
//   index.js        → re-exports all of the above (optional barrel)

La division par responsabilité signifie que chaque fichier a une responsabilité unique, les tests sont plus faciles à écrire et à localiser, et les bundlers peuvent tree-shaker au niveau des fichiers. Le fichier barrel garde les imports propres pour les consommateurs qui préfèrent le chemin d'import unique.

Outils utiles

Pour construire du JavaScript modulaire, le Formateur JS garde vos blocs d'import propres et cohérents. Pour comprendre ce qui se trouve dans votre bundle final, des outils comme Webpack Bundle Analyzer ou le visualiseur de Rollup montrent quels modules prennent de la place. La spécification ECMAScript Modules TC39 a la sémantique de résolution exacte si vous devez comprendre les cas limites.

Conclusion

Les modules ES sont la base de l'architecture JavaScript moderne. Préférez les exports nommés pour les fonctions utilitaires — ils sont explicites, tree-shakeable et compatibles IDE. Utilisez l'import() dynamique pour extraire le code lourd de votre bundle initial. Soyez prudent avec les fichiers barrel dans les grandes bases de code. Et si vous êtes encore sur CommonJS dans un nouveau projet Node.js, il est temps de migrer — l'écosystème a entièrement basculé vers ESM.