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
- Connaissances de base en JavaScript (fonctions, objets, prototypes,
this, callbacks). - Un terminal (macOS/Linux) ou PowerShell (Windows).
- Node.js récent (idéalement ≥ 18).
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
var: portée fonction (ou globale si déclaré hors fonction).let/const: portée bloc ({ ... }), ce qui inclutif,for,while,try/catch, etc.
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
- Utilisez
constpar défaut. - Utilisez
letuniquement si vous devez réassigner.
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());
constructorest appelé lors denew.- Les méthodes définies dans le corps de la classe sont placées sur
Personne.prototype.
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 :
#codeest réellement privé : inaccessible hors de la classe.- Les champs privés ne sont pas des propriétés “normales” de l’objet ; ils sont gérés par le moteur.
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 :
- Dans une classe dérivée, vous devez appeler
super(...)avant d’accéder àthis. super.methode()appelle la méthode du parent.
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 :
- Plus simple à tester (injection de dépendances).
- Moins de couplage structurel.
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 :
- Binder dans le constructeur :
class Bouton {
constructor() {
this.compteur = 0;
this.incrementer = this.incrementer.bind(this);
}
incrementer() {
this.compteur++;
}
}
- 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 :
- IIFE (fonctions auto-exécutées)
- espaces de noms globaux
- bundlers avec formats historiques (AMD, CommonJS)
Les modules ES6 apportent :
- Imports statiques (analysables par outils).
- Scope de module (pas de variables globales involontaires).
- Exports nommés et export par défaut.
- Meilleure interopérabilité avec l’écosystème moderne.
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 :
- En ESM Node.js, l’extension
.jsdans le chemin est généralement requise. - Les imports sont en lecture seule : vous ne pouvez pas réassigner
addition.
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 :
- Réservez
export defaultà un “principal” par fichier (classe principale, factory principale). - Préférez les exports nommés pour les utilitaires multiples afin de faciliter le renommage et l’autocomplétion.
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 :
- chargement conditionnel
- découpage du code
- plugins
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 :
- Un projet
"type": "module"privilégie ESM. - Importer du CommonJS depuis ESM est souvent possible, mais l’inverse peut nécessiter des adaptations.
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;
}
}
#doneest un état interne protégé.get estTerminee()expose une lecture contrôlée.
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 :
const task: pas de réassignation.let done: compteur réassigné.for (const t of ...):tne change pas, doncconst.logger?.info: appel optionnel (siloggerest absent).
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 :
- Encapsuler l’état avec champs privés
#. - Exposer des méthodes intentionnelles (
terminer,annuler) plutôt que modifier des propriétés librement. - Éviter d’exposer directement des tableaux internes modifiables ; fournir une copie si nécessaire.
5.3 Organiser les modules
Une organisation fréquente :
src/: code applicatifsrc/domain/: règles métier (classes, logique)src/infra/: accès réseau, base de données, système de fichierssrc/ui/: interface (si applicable)
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 :
- Ajouter
"type": "module"danspackage.json - Ou utiliser l’extension
.mjs - Ou exécuter dans un environnement configuré pour ESM
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
-
Remplacez un ancien code utilisant
varparlet/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
- les variables qui doivent rester en
-
Créez une classe
Timer:- champ privé
#start - méthode
demarrer() - getter
dureeMs(durée depuis le démarrage) - exportez-la en
default
- champ privé
-
Créez un module
format.jsavec exports nommés :formatDate(date)formatMoney(amount, currency)Puis importez-les dansindex.jsavec un alias.
-
Ajoutez un import dynamique :
- chargez
format.jsseulement si une option--formatest passée en argument (viaprocess.argv).
- chargez
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.