Retour au cours

frontend / javascript

Design patterns : module, observer, singleton

Leçon 171 exercice

Explication

Ce que vous allez apprendre

  • Encapsuler un état privé avec le pattern module (closure)
  • Mettre en place un observer pour découpler un émetteur de ses écouteurs
  • Reconnaître les risques d'un singleton et savoir quand l'éviter
  • Remplacer un pattern module par un module ES natif, souvent plus simple
  • Transformer une cascade de if/else en table de stratégies interchangeables

Dans quel contexte ?

Un développeur reprend un module de configuration Config.getInstance() implémenté en singleton, utilisé partout dans l'application. Lors de l'écriture des tests unitaires, il découvre que l'état de la configuration "fuit" d'un test à l'autre : comme il n'existe qu'une seule instance partagée pour toute l'exécution du programme, modifier Config dans un test affecte les tests suivants. C'est le piège classique du singleton — pratique en apparence, mais qui introduit un état global implicite difficile à isoler, notamment en tests.

1. Un changement de niveau

Après seize leçons consacrées au langage et à ses mécanismes, celle-ci change de registre : elle ne présente plus une fonctionnalité du langage, mais des façons éprouvées d'organiser du code pour résoudre des problèmes qui reviennent sans cesse.

2. Le pattern module : encapsuler avec une closure

Le pattern "module" encapsule un état privé dans une closure, exposant uniquement une API publique contrôlée. C'était la seule façon d'obtenir une vraie encapsulation avant les modules ES et les champs privés de classe.

Prérequis

Cette leçon suppose une bonne compréhension des closures (leçon 2) et des modules ES (leçon 14) : les deux patterns "module" et "singleton" reposent directement sur ces notions.

3. L'observer : découpler émetteur et récepteurs

L'"observer", très présent dans les frameworks UI et dans Node.js (via EventEmitter), découple celui qui déclenche un événement de ceux qui y réagissent. L'émetteur n'a aucune idée de qui l'écoute, ce qui rend le système extensible sans modifier le code existant.

4. Le singleton : une seule instance partagée

Le "singleton" garantit qu'une seule instance d'une chose partagée (une configuration, une connexion) existe dans toute l'application. C'est un pattern à manier avec prudence, car il introduit un état global implicite.

Piège fréquent

Un singleton complique les tests unitaires : son état persiste entre les tests puisqu'il n'existe qu'une seule instance pour tout le programme. Préférez l'injection de dépendances (passer l'instance en paramètre) quand la testabilité est une priorité.

5. Une alternative souvent plus simple

Un module EST déjà par nature une instance unique partagée entre tous ses importateurs : pas besoin d'une classe dédiée pour obtenir le même effet la plupart du temps.

PatternProblème résoluRisque principal
ModuleEncapsulation d'un état privéAucun si utilisé via un module ES natif
ObserverDécoupler émetteur et récepteursFuites mémoire si les listeners ne sont jamais retirés
SingletonInstance unique partagéeÉtat global difficile à tester et à isoler
StrategyRemplacer une cascade if/elseAucun majeur, surtout des bénéfices

6. Le strategy : remplacer un if/else géant

Enfin, le "strategy" remplace une cascade de if/else par une table de fonctions interchangeables, rendant l'ajout d'un nouveau comportement aussi simple qu'ajouter une entrée dans un objet.

Ces patterns ne sont pas des règles à appliquer partout, mais un vocabulaire commun pour reconnaître des structures que vous rencontrerez constamment en lisant du code d'autres développeurs.

Commandes & code

Design patterns JS : module, observer, singleton

Les patterns les plus utilisés dans les codebases JavaScript réelles.

js
// --- Module pattern (avant les modules ES natifs) : encapsulation via closure ---
const CompteurModule = (function () {
    let compte = 0;              // etat prive, inaccessible depuis l'exterieur
    function notifier() {
        console.log(`Nouveau compte : ${compte}`);
    }
    return {
        incrementer() {
            compte++;
            notifier();
        },
        reinitialiser() {
            compte = 0;
        },
        get valeur() {
            return compte;
        },
    };
})();
CompteurModule.incrementer();
console.log(CompteurModule.valeur);
// console.log(CompteurModule.compte);   // undefined -- veritable encapsulation

// --- Observer pattern : decoupler emetteurs et recepteurs d'evenements ---
class EventEmitter {
    #listeners = new Map();

    on(evenement, callback) {
        if (!this.#listeners.has(evenement)) this.#listeners.set(evenement, []);
        this.#listeners.get(evenement).push(callback);
        return () => this.off(evenement, callback);    // retourne une fonction de desabonnement
    }

    off(evenement, callback) {
        const callbacks = this.#listeners.get(evenement) ?? [];
        this.#listeners.set(evenement, callbacks.filter((cb) => cb !== callback));
    }

    emit(evenement, ...args) {
        (this.#listeners.get(evenement) ?? []).forEach((cb) => cb(...args));
    }

    once(evenement, callback) {
        const desabonner = this.on(evenement, (...args) => {
            desabonner();
            callback(...args);
        });
    }
}

const bus = new EventEmitter();
const desabonner = bus.on("commande:creee", (commande) => console.log("Nouvelle commande :", commande));
bus.emit("commande:creee", { id: 1, total: 99.9 });
desabonner();
bus.emit("commande:creee", { id: 2, total: 50 });    // plus rien n'est affiche

// --- Singleton : une seule instance partagee dans toute l'application ---
class GestionnaireConfiguration {
    static #instance;
    #parametres = {};

    constructor() {
        if (GestionnaireConfiguration.#instance) {
            return GestionnaireConfiguration.#instance;     // retourne l'instance existante
        }
        GestionnaireConfiguration.#instance = this;
    }

    definir(cle, valeur) {
        this.#parametres[cle] = valeur;
    }

    obtenir(cle) {
        return this.#parametres[cle];
    }
}
const config1 = new GestionnaireConfiguration();
const config2 = new GestionnaireConfiguration();
config1.definir("theme", "sombre");
console.log(config2.obtenir("theme"));      // "sombre" -- meme instance

// Alternative moderne, plus idiomatique : un module EST deja un singleton
// fichier configuration.mjs
// let parametres = {};
// export function definir(cle, valeur) { parametres[cle] = valeur; }
// export function obtenir(cle) { return parametres[cle]; }
// -> importer ce module donne toujours le meme etat partage, sans classe

// --- Factory pattern : centraliser la creation d'objets complexes ---
function creerNotification(type, message) {
    const notifications = {
        succes: () => ({ icone: "OK", couleur: "green", message }),
        erreur: () => ({ icone: "X", couleur: "red", message }),
        info: () => ({ icone: "i", couleur: "blue", message }),
    };
    return (notifications[type] ?? notifications.info)();
}
console.log(creerNotification("succes", "Enregistre !"));

// --- Strategy pattern : interchanger un algorithme dynamiquement ---
const strategiesRemise = {
    aucune: (prix) => prix,
    pourcentage: (prix, valeur) => prix * (1 - valeur / 100),
    fixe: (prix, valeur) => Math.max(0, prix - valeur),
};
function appliquerRemise(prix, type, valeur) {
    return (strategiesRemise[type] ?? strategiesRemise.aucune)(prix, valeur);
}
console.log(appliquerRemise(100, "pourcentage", 20));   // 80

Résumé

  • Le module pattern encapsule l'état via une closure ; les modules ES natifs le rendent souvent superflu.
  • L'Observer (EventEmitter) découple émetteurs et récepteurs : la base de nombreuses librairies UI.
  • Un Singleton en JS peut être un objet de classe à instance unique, ou plus simplement un module (déjà singleton par nature).
  • Le Strategy pattern remplace un if/else géant par une table de fonctions interchangeables.

Exercices pratiques

1 disponible
1

Mission : le bus d'événements qui fuit en mémoire

Objectif : Diagnostiquer une fuite mémoire causée par des abonnements Observer jamais désinscrits, et remplacer une cascade if/else par un Strategy pattern.

Contexte

Le tableau de bord de Technologik utilise un EventEmitter pour notifier chaque nouvelle commande à plusieurs widgets. Après quelques heures d'utilisation intensive, l'onglet du navigateur consomme de plus en plus de mémoire :

js
function afficherWidgetCommande(bus) {
  bus.on("commande:creee", (commande) => miseAJourAffichage(commande));
  // le widget est recree et redetruit plusieurs fois par session, mais jamais desabonne
}

Ailleurs, une fonction appliquerRemise(prix, type, valeur) contient une longue cascade if (type === "pourcentage") { ... } else if (type === "fixe") { ... } else if (...) { ... } qui grossit à chaque nouveau type de remise ajouté par le service marketing.

Résoudre l’exercice →