← Retour aux tutoriels

Fonctionnalités JavaScript ES6 : guide intermédiaire (let/const, classes, modules)

javascriptes6es2015fonctions fléchéeslet constdestructurationclassesmodulespromessesdéveloppement web

Fonctionnalités JavaScript ES6 : guide intermédiaire (let/const, classes, modules)

Ce tutoriel propose une exploration intermédiaire de trois piliers d’ES6 (ES2015) : let/const, les classes, et les modules. L’objectif est de comprendre non seulement comment les utiliser, mais surtout pourquoi ils existent, quels pièges éviter, et comment les intégrer proprement dans un projet réel.


Prérequis et environnement

Prérequis techniques

Installation et vérification

Dans un terminal :

node -v
npm -v

Si Node.js n’est pas installé, installez-le via le site officiel ou un gestionnaire de versions (recommandé). Par exemple avec nvm (macOS/Linux) :

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm use --lts
node -v

1) let et const : portée, hoisting, TDZ et bonnes pratiques

ES6 introduit let et const pour améliorer la gestion des variables, notamment en corrigeant des comportements historiques de var (portée de fonction, hoisting permissif, fuites de variables dans des blocs).

1.1 Portée de bloc : la différence centrale

Exemple :

if (true) {
  var a = 1;
  let b = 2;
  const c = 3;
}

console.log(a); // 1
console.log(b); // ReferenceError
console.log(c); // ReferenceError

Le fait que b et c soient invisibles hors du bloc réduit les effets de bord et rend le code plus prévisible.

1.2 Hoisting : oui, mais pas comme var

On dit souvent que let et const “ne sont pas hoistés”. En réalité, ils le sont, mais avec une contrainte : ils ne sont pas accessibles avant leur déclaration.

Avec var :

console.log(x); // undefined
var x = 10;

Avec let :

console.log(y); // ReferenceError
let y = 10;

Pourquoi ? À cause de la TDZ.

1.3 TDZ (Temporal Dead Zone) : comprendre l’erreur

La zone morte temporelle est l’intervalle entre le début du bloc et la déclaration effective de la variable let/const. Durant cette période, accéder à la variable déclenche une erreur.

Exemple :

{
  // TDZ pour z commence ici
  // console.log(z); // ReferenceError
  let z = 5; // TDZ se termine ici
  console.log(z); // 5
}

La TDZ empêche des bugs subtils où une variable serait lue avant d’être initialisée.

1.4 const n’implique pas l’immuabilité de l’objet

const signifie : la liaison (binding) ne peut pas être réassignée. Mais si la valeur est un objet (ou un tableau), son contenu reste modifiable.

const user = { name: "Ada", role: "dev" };
user.role = "admin"; // OK
// user = { name: "Ada" }; // TypeError (réassignation interdite)

const arr = [1, 2, 3];
arr.push(4); // OK

Si vous voulez une forme d’immuabilité, vous pouvez utiliser Object.freeze (attention : gel superficiel) :

const config = Object.freeze({ apiUrl: "https://exemple.test" });
// config.apiUrl = "..." // silencieux ou erreur en mode strict selon contexte

Pour un gel profond, il faut une fonction dédiée (ou une bibliothèque), car Object.freeze ne fige pas récursivement les objets imbriqués.

1.5 let vs const : règle pratique

Exemples typiques avec let :

let total = 0;
for (const n of [1, 2, 3]) {
  total += n;
}

Ici, total change, donc let. La variable de boucle n ne change pas, donc const.

1.6 Cas classique : boucles et closures

Avec var, les boucles for et les closures peuvent piéger :

const fns = [];
for (var i = 0; i < 3; i++) {
  fns.push(() => i);
}
console.log(fns.map(fn => fn())); // [3, 3, 3]

Avec let, chaque itération crée un binding distinct :

const fns = [];
for (let i = 0; i < 3; i++) {
  fns.push(() => i);
}
console.log(fns.map(fn => fn())); // [0, 1, 2]

C’est l’un des gains concrets les plus importants de let.

1.7 catch (e) et portée

ES6 a également clarifié la portée de la variable d’erreur :

try {
  throw new Error("Oups");
} catch (e) {
  console.log(e.message);
}

La variable e est limitée au bloc catch, ce qui évite de polluer la portée externe.


2) Classes ES6 : syntaxe, héritage, champs, méthodes et subtilités

Les classes ES6 offrent une syntaxe plus claire pour construire des objets et gérer l’héritage. Important : elles n’introduisent pas un nouveau modèle objet ; elles reposent toujours sur les prototypes.

2.1 Déclaration d’une classe et constructeur

class Personne {
  constructor(nom) {
    this.nom = nom;
  }

  saluer() {
    return `Bonjour, je m'appelle ${this.nom}.`;
  }
}

const p = new Personne("Camille");
console.log(p.saluer());

Vérification :

console.log(typeof Personne); // "function"
console.log(Personne.prototype.saluer === p.saluer); // true

2.2 Différences importantes avec les fonctions constructrices

Avec une fonction constructrice “ancienne manière” :

function Personne(nom) {
  this.nom = nom;
}
Personne.prototype.saluer = function () {
  return `Bonjour, je m'appelle ${this.nom}.`;
};

La classe ES6 rend cela plus lisible, mais le mécanisme sous-jacent est similaire.

2.3 Les classes ne sont pas hoistées comme les fonctions

Une déclaration de fonction peut être utilisée avant sa définition (hoisting). Une classe, non :

// const a = new Animal(); // ReferenceError
class Animal {}
const a = new Animal();

C’est cohérent avec la TDZ : la classe existe dans le bloc mais n’est pas utilisable avant la déclaration.

2.4 Méthodes statiques

Les méthodes statiques appartiennent à la classe, pas aux instances :

class MathUtil {
  static clamp(x, min, max) {
    return Math.min(Math.max(x, min), max);
  }
}

console.log(MathUtil.clamp(10, 0, 5)); // 5
// new MathUtil().clamp(...) // TypeError

Cas d’usage : fonctions utilitaires, factories, helpers de validation.

2.5 Getters et setters

Ils permettent de définir une API orientée propriété tout en gardant un contrôle :

class Compte {
  constructor(soldeInitial = 0) {
    this._solde = soldeInitial;
  }

  get solde() {
    return this._solde;
  }

  set solde(valeur) {
    if (valeur < 0) throw new Error("Solde négatif interdit");
    this._solde = valeur;
  }
}

const c = new Compte(100);
c.solde = 50;
console.log(c.solde); // 50

Note : le _ est une convention, pas une protection réelle.

2.6 Champs publics et privés : état interne et encapsulation

Les champs publics (selon l’environnement) :

class Panier {
  items = [];

  ajouter(item) {
    this.items.push(item);
  }
}

Les champs privés utilisent # :

class Coffre {
  #code;

  constructor(code) {
    this.#code = code;
  }

  verifier(code) {
    return code === this.#code;
  }
}

const coffre = new Coffre("1234");
console.log(coffre.verifier("1234")); // true
// console.log(coffre.#code); // SyntaxError

Points importants :

2.7 Héritage avec extends et super

class Vehicule {
  constructor(marque) {
    this.marque = marque;
  }

  demarrer() {
    return `${this.marque} démarre.`;
  }
}

class Voiture extends Vehicule {
  constructor(marque, portes) {
    super(marque);
    this.portes = portes;
  }

  demarrer() {
    return `${super.demarrer()} (${this.portes} portes)`;
  }
}

const v = new Voiture("Renault", 5);
console.log(v.demarrer());

Règles :

2.8 Composition vs héritage : recommandation pratique

L’héritage est utile si vous avez une vraie relation “est un”. Mais la composition est souvent plus flexible.

Exemple de composition :

class Logger {
  info(msg) {
    console.log(`[INFO] ${msg}`);
  }
}

class ServiceUtilisateur {
  constructor(logger) {
    this.logger = logger;
  }

  creer(nom) {
    this.logger.info(`Création de l'utilisateur ${nom}`);
    return { id: Date.now(), nom };
  }
}

const service = new ServiceUtilisateur(new Logger());
service.creer("Nina");

Avantages :

2.9 Pièges autour de this et des méthodes

En JavaScript, this dépend du contexte d’appel. Exemple :

class Bouton {
  constructor() {
    this.compteur = 0;
  }
  incrementer() {
    this.compteur++;
  }
}

const b = new Bouton();
const f = b.incrementer;
f(); // en mode strict, this = undefined => TypeError

Solutions :

  1. Binder dans le constructeur :
class Bouton {
  constructor() {
    this.compteur = 0;
    this.incrementer = this.incrementer.bind(this);
  }
  incrementer() {
    this.compteur++;
  }
}
  1. Méthode sous forme de champ fléché (capture lexicale de this) :
class Bouton {
  compteur = 0;
  incrementer = () => {
    this.compteur++;
  };
}

Attention : cette seconde approche crée une fonction par instance (coût mémoire), contrairement aux méthodes sur prototype.


3) Modules ES6 : import/export, exécution, résolution et usage avec Node.js

Les modules ES6 standardisent l’organisation du code : séparation en fichiers, dépendances explicites, encapsulation, et chargement statique.

3.1 Pourquoi les modules ES6 sont importants

Avant ES6, on utilisait souvent :

Les modules ES6 apportent :

3.2 Créer un mini-projet en modules (Node.js)

On va créer un projet minimal utilisant les modules ES6 natifs.

Étape 1 : initialiser le projet

mkdir es6-modules-demo
cd es6-modules-demo
npm init -y

Étape 2 : activer ESM dans Node.js

Éditez package.json et ajoutez :

{
  "type": "module"
}

Vous pouvez le faire manuellement, ou via une commande si vous avez jq :

jq '. + {"type":"module"}' package.json > package.tmp.json && mv package.tmp.json package.json

Étape 3 : créer des fichiers

Structure :

mkdir src
touch src/index.js src/math.js src/logger.js

3.3 Exports nommés

src/math.js :

export function addition(a, b) {
  return a + b;
}

export function multiplication(a, b) {
  return a * b;
}

Import dans src/index.js :

import { addition, multiplication } from "./math.js";

console.log(addition(2, 3));
console.log(multiplication(2, 3));

Exécution :

node src/index.js

Points clés :

3.4 Renommer un import (alias)

import { multiplication as mult } from "./math.js";
console.log(mult(4, 5));

Utile pour éviter des collisions de noms ou pour améliorer la lisibilité.

3.5 Export par défaut

src/logger.js :

export default class Logger {
  info(msg) {
    console.log(`[INFO] ${msg}`);
  }
  error(msg) {
    console.error(`[ERREUR] ${msg}`);
  }
}

Import :

import Logger from "./logger.js";

const logger = new Logger();
logger.info("Application démarrée");

Bonnes pratiques :

3.6 Importer tout un module

import * as math from "./math.js";
console.log(math.addition(1, 2));

Cela crée un objet “namespace” figé (les bindings restent vivants, mais l’objet ne doit pas être modifié).

3.7 Les bindings sont vivants (live bindings)

Les exports ES6 ne copient pas les valeurs : ils exposent des liaisons vivantes.

src/state.js :

export let compteur = 0;

export function incrementer() {
  compteur++;
}

src/index.js :

import { compteur, incrementer } from "./state.js";

console.log(compteur); // 0
incrementer();
console.log(compteur); // 1

Ce comportement est différent d’approches où l’on exporterait une valeur figée. Ici, compteur reflète l’état actuel du module.

3.8 Ordre d’exécution et effets de bord

Un module est évalué une seule fois (mise en cache), puis réutilisé.

src/config.js :

console.log("Chargement du module config");
export const API_URL = "https://exemple.test";

Si plusieurs modules importent config.js, le message s’affiche une seule fois.

Implication : évitez les effets de bord lourds au chargement (requêtes réseau, accès disque). Préférez une fonction d’initialisation explicite.

3.9 Imports dynamiques

Les imports statiques doivent être en haut du fichier (en pratique) et sont résolus à l’analyse. Mais on peut charger dynamiquement :

const module = await import("./math.js");
console.log(module.addition(10, 20));

Cas d’usage :

Note : await au niveau supérieur est supporté en ESM dans Node.js moderne.

3.10 Modules ES6 et CommonJS : interopérabilité

Dans Node.js, CommonJS utilise require et module.exports. ESM utilise import/export.

Règles générales :

Exemple : importer un module CommonJS (hypothétique) :

import pkg from "un-paquet-commonjs";

Selon le paquet, vous pouvez devoir accéder à des propriétés spécifiques. L’interopérabilité varie selon la manière dont le paquet exporte ses symboles.

Si vous devez absolument utiliser require en ESM :

import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const cjs = require("un-paquet-commonjs");

À utiliser avec parcimonie : mieux vaut rester cohérent sur un format.


4) Exemple complet : mini-application modulaire avec classes et const/let

On va assembler un exemple réaliste : un petit système de gestion de tâches.

4.1 Structure

mkdir -p src
touch src/index.js src/task.js src/taskManager.js src/logger.js

Assurez-vous que package.json contient "type": "module".

4.2 Classe Task (avec champ privé)

src/task.js :

export default class Task {
  #done = false;

  constructor(titre) {
    if (!titre || typeof titre !== "string") {
      throw new Error("Le titre est obligatoire");
    }
    this.titre = titre;
  }

  terminer() {
    this.#done = true;
  }

  get estTerminee() {
    return this.#done;
  }
}

4.3 Logger

src/logger.js :

export default class Logger {
  info(msg) {
    console.log(`[INFO] ${msg}`);
  }
  warn(msg) {
    console.warn(`[WARN] ${msg}`);
  }
}

4.4 TaskManager (composition + let/const)

src/taskManager.js :

import Task from "./task.js";

export class TaskManager {
  constructor(logger) {
    this.logger = logger;
    this.tasks = [];
  }

  ajouter(titre) {
    const task = new Task(titre);
    this.tasks.push(task);
    this.logger?.info(`Tâche ajoutée : ${titre}`);
    return task;
  }

  terminerTout() {
    for (const t of this.tasks) {
      t.terminer();
    }
    this.logger?.info("Toutes les tâches ont été terminées");
  }

  resume() {
    let done = 0;
    for (const t of this.tasks) {
      if (t.estTerminee) done++;
    }
    return {
      total: this.tasks.length,
      terminees: done,
      restantes: this.tasks.length - done
    };
  }
}

Points pédagogiques :

4.5 Point d’entrée

src/index.js :

import Logger from "./logger.js";
import { TaskManager } from "./taskManager.js";

const logger = new Logger();
const manager = new TaskManager(logger);

manager.ajouter("Écrire le tutoriel ES6");
manager.ajouter("Relire et corriger");
manager.ajouter("Publier");

console.log("Résumé initial :", manager.resume());

manager.terminerTout();

console.log("Résumé final :", manager.resume());

Exécution :

node src/index.js

Vous devriez voir des logs et un résumé avant/après.


5) Bonnes pratiques de conception (intermédiaire)

5.1 Préférer const et des fonctions pures quand possible

Quand une fonction ne dépend pas d’un état externe mutable, elle est plus simple à tester et à réutiliser.

Exemple :

export function total(items) {
  return items.reduce((acc, x) => acc + x, 0);
}

5.2 Limiter l’état mutable dans les classes

Les classes sont utiles, mais un excès d’état mutable rend le comportement difficile à suivre. Stratégies :

5.3 Organiser les modules

Une organisation fréquente :

Le but : réduire les dépendances croisées et clarifier les responsabilités.

5.4 Éviter les effets de bord au chargement des modules

Préférez :

export function init() { /* ... */ }

plutôt que d’exécuter des actions lourdes dès l’import.


6) Dépannage : erreurs fréquentes

6.1 ReferenceError: Cannot access 'x' before initialization

Cause : TDZ avec let/const.

Solution : déclarer/initialiser avant usage, ou réorganiser le code.

6.2 SyntaxError: Cannot use import statement outside a module

Cause : Node.js n’est pas en mode ESM.

Solutions :

6.3 Problèmes de chemins d’import

En Node.js ESM, utilisez des chemins relatifs corrects et incluez l’extension :

import { addition } from "./math.js";

Pas :

import { addition } from "./math";

(sauf cas particuliers avec résolution configurée via outils, bundlers, ou exports de paquet)


7) Exercices recommandés

  1. Remplacez un ancien code utilisant var par let/const, puis identifiez :

    • les variables qui doivent rester en let
    • les variables qui peuvent passer en const
    • les bugs révélés par la TDZ
  2. Créez une classe Timer :

    • champ privé #start
    • méthode demarrer()
    • getter dureeMs (durée depuis le démarrage)
    • exportez-la en default
  3. Créez un module format.js avec exports nommés :

    • formatDate(date)
    • formatMoney(amount, currency) Puis importez-les dans index.js avec un alias.
  4. Ajoutez un import dynamique :

    • chargez format.js seulement si une option --format est passée en argument (via process.argv).

Conclusion

let et const apportent une gestion de portée plus sûre et réduisent les erreurs silencieuses, notamment grâce à la TDZ. Les classes ES6 offrent une syntaxe claire au-dessus des prototypes, avec des outils modernes comme les champs privés et les méthodes statiques, tout en demandant une attention particulière à this. Les modules ES6 structurent le code, rendent les dépendances explicites, et s’intègrent nativement à Node.js via le mode ESM.

Si vous souhaitez aller plus loin, les prochaines étapes naturelles sont : l’asynchronisme moderne (async/await), les itérateurs/générateurs, et l’outillage (linting, tests, bundling) autour d’un projet modulaire.