Cours JavaScript et TypeScript · Module 5: JavaScript avancé
Le motif Observer et Pub/Sub
Dans cette leçon7
Au centre de contrôle de Jurassic Park, Robert Muldoon surveille des dizaines d'écrans à la fois. "Le problème", explique-t-il, "c'est que chaque système - clôtures, caméras, détecteurs de mouvement - doit réagir immédiatement aux changements de statut d'un dinosaure. Mais ils ne peuvent pas interroger sans cesse (faire du polling sur) l'état de la base de données !" La solution est le motif Observer - un mécanisme dans lequel les objets avertissent automatiquement les intéressés de leurs changements.
Le problème : un couplage fort entre les composants
Sans le motif Observer, les composants doivent se connaître et s'appeler directement. La fonction qui réagit à une évasion devient alors un standard téléphonique qui appelle à la main chaque système :
1// Problème : le système d'alarme connaît TOUS les autres systèmes
2function onDinosaurEscaped(dinosaur) {
3 // Il doit appeler chaque système à la main
4 fenceSystem.activateEmergency(dinosaur);
5 cameraSystem.trackDinosaur(dinosaur);
6 alarmSystem.soundAlert(dinosaur);
7 gateSystem.lockAllGates();
8 evacuationSystem.startEvacuation();
9 // Et si on ajoute un nouveau système ? Il faut modifier cette fonction !
10}Chaque nouveau système exige de modifier du code existant. Cela viole le principe ouvert-fermé (Open-Closed Principle), selon lequel le code doit être ouvert à l'extension, mais fermé à la modification.
Le motif Observer - les bases
Dans le motif Observer, on a deux types d'objets :
- Subject (Observable) - l'objet qui émet des événements (avertit des changements)
- Observer - l'objet qui écoute les événements et y réagit
Le Subject est la sirène du parc, et les observateurs sont les gardiens qui ont eux-mêmes signalé qu'ils voulaient l'entendre. L'implémentation la plus simple est un EventEmitter, qui garde une liste de fonctions de rappel pour chaque nom d'événement :
1// Un EventEmitter simple - le cœur du motif Observer
2class EventEmitter {
3 constructor() {
4 this.listeners = {};
5 }
6
7 // Enregistrer un observateur (abonnement)
8 on(event, callback) {
9 if (!this.listeners[event]) {
10 this.listeners[event] = [];
11 }
12 this.listeners[event].push(callback);
13 return this; // Permet le chaining
14 }
15
16 // Émettre un événement (avertir les observateurs)
17 emit(event, ...args) {
18 const callbacks = this.listeners[event];
19 if (callbacks) {
20 callbacks.forEach(callback => callback(...args));
21 }
22 return this;
23 }
24
25 // Retirer un observateur
26 off(event, callback) {
27 if (this.listeners[event]) {
28 this.listeners[event] = this.listeners[event].filter(
29 cb => cb !== callback
30 );
31 }
32 return this;
33 }
34
35 // Abonnement unique
36 once(event, callback) {
37 const wrapper = (...args) => {
38 callback(...args);
39 this.off(event, wrapper);
40 };
41 this.on(event, wrapper);
42 return this;
43 }
44}L'objet listeners est un registre : le nom d'un événement mène à un tableau de fonctions. on() ajoute une fonction, et emit() les appelle toutes de façon synchrone, dans l'ordre d'enregistrement. off() compare les fonctions par référence, tu ne pourras donc pas détacher une fonction fléchée anonyme passée directement à on(). once() enveloppe le callback dans une fonction qui se désinscrit d'elle-même après le premier appel.
L'application à Jurassic Park
DinosaurMonitor hérite de EventEmitter, il reçoit donc on(), emit() et off() gratuitement. Lui-même ne s'occupe que des données des dinosaures et de l'annonce des changements :
1// Système de surveillance des dinosaures fondé sur EventEmitter
2class DinosaurMonitor extends EventEmitter {
3 constructor() {
4 super();
5 this.dinosaurs = new Map();
6 }
7
8 registerDinosaur(id, data) {
9 this.dinosaurs.set(id, { ...data, status: "contained" });
10 this.emit("dinosaur:registered", { id, ...data });
11 }
12
13 updateStatus(id, newStatus) {
14 const dino = this.dinosaurs.get(id);
15 if (!dino) return;
16
17 const oldStatus = dino.status;
18 dino.status = newStatus;
19
20 this.emit("status:changed", { id, name: dino.name, oldStatus, newStatus });
21
22 // On émet un événement spécial pour une évasion
23 if (newStatus === "escaped") {
24 this.emit("dinosaur:escaped", { id, name: dino.name, species: dino.species });
25 }
26 }
27}Le moniteur ne sait rien des clôtures, des caméras ni de l'évacuation - il sait seulement qu'il doit annoncer un événement. Lors d'une évasion, il en émet deux : le général status:changed et le spécial dinosaur:escaped.
Maintenant, chaque système se branche lui-même sur le moniteur, et nous simulons l'évasion du T-Rex :
1// Création du système
2const monitor = new DinosaurMonitor();
3
4// Enregistrement des observateurs - chaque système se branche lui-même
5monitor.on("dinosaur:escaped", (data) => {
6 console.log(`ALARME CLÔTURE : ${data.name} s'est échappé ! Activation de la clôture électrique.`);
7});
8
9monitor.on("dinosaur:escaped", (data) => {
10 console.log(`ALARME CAMÉRA : Suivi de ${data.name} (${data.species}) sur les caméras.`);
11});
12
13monitor.on("dinosaur:escaped", (data) => {
14 console.log(`ALARME ÉVACUATION : Début de l'évacuation suite à l'évasion de ${data.name} !`);
15});
16
17monitor.on("status:changed", (data) => {
18 console.log(`LOG : ${data.name} est passé du statut "${data.oldStatus}" à "${data.newStatus}"`);
19});
20
21// Enregistrement et simulation
22monitor.registerDinosaur("TREX-001", { name: "Rexy", species: "T-Rex" });
23monitor.registerDinosaur("RAPTOR-01", { name: "Blue", species: "Velociraptor" });
24
25// Simulation d'une évasion
26monitor.updateStatus("TREX-001", "escaped");
27// LOG : Rexy est passé du statut "contained" à "escaped"
28// ALARME CLÔTURE : Rexy s'est échappé ! Activation de la clôture électrique.
29// ALARME CAMÉRA : Suivi de Rexy (T-Rex) sur les caméras.
30// ALARME ÉVACUATION : Début de l'évacuation suite à l'évasion de Rexy !L'ordre des affichages correspond à l'ordre des emit() dans updateStatus() : d'abord le log, puis les trois alarmes dans l'ordre d'enregistrement. Un nouveau système, par exemple des drones, s'ajoute avec un seul monitor.on(...), sans toucher à la classe du moniteur. C'est justement le principal avantage du motif : un couplage plus faible entre les composants.
Le motif Publish/Subscribe (Pub/Sub)
Pub/Sub est une extension du motif Observer, où la communication passe par des canaux/sujets (topics) au lieu de références directes. L'émetteur publie sur un sujet, et les récepteurs s'abonnent aux sujets, sans rien savoir les uns des autres :
1class PubSub {
2 constructor() {
3 this.topics = {};
4 this.subscriptionId = 0;
5 }
6
7 // S'abonner à un sujet
8 subscribe(topic, callback) {
9 if (!this.topics[topic]) {
10 this.topics[topic] = {};
11 }
12
13 const id = ++this.subscriptionId;
14 this.topics[topic][id] = callback;
15
16 // On renvoie une fonction pour annuler l'abonnement
17 return () => {
18 delete this.topics[topic][id];
19 };
20 }
21
22 // Publier un message
23 publish(topic, data) {
24 if (!this.topics[topic]) return;
25
26 Object.values(this.topics[topic]).forEach(callback => {
27 callback(data);
28 });
29 }
30}Chaque abonnement reçoit son propre numéro, et subscribe() renvoie une fonction qui l'annule. Pas besoin donc de conserver une référence au callback, comme avec off().
Le bus central du parc gère deux sujets - les alertes de sécurité et le planning des repas :
1// Système central de communication du parc
2const parkBus = new PubSub();
3
4// Les systèmes s'abonnent aux sujets qui les intéressent
5const unsubFence = parkBus.subscribe("security:alert", (data) => {
6 console.log(`Clôture : Alerte reçue - ${data.message}`);
7});
8
9parkBus.subscribe("security:alert", (data) => {
10 console.log(`Centre de commandement : Alerte de niveau ${data.level} - ${data.message}`);
11});
12
13parkBus.subscribe("feeding:schedule", (data) => {
14 console.log(`Cuisine : Préparer ${data.food} pour ${data.dinosaur}`);
15});
16
17// Publication d'événements - l'émetteur ne sait pas qui écoute
18parkBus.publish("security:alert", {
19 level: "HIGH",
20 message: "Mouvement détecté près de la clôture du secteur C"
21});
22
23parkBus.publish("feeding:schedule", {
24 dinosaur: "Rexy",
25 food: "200kg de bœuf",
26 time: "14:00"
27});
28
29// Annulation de l'abonnement
30unsubFence(); // La clôture cesse d'écouterL'alerte parvient à la clôture et au centre de commandement, et le message sur le repas uniquement à la cuisine. Après unsubFence(), la clôture ne reçoit plus d'alertes, alors que le centre continue d'écouter. Les noms comme security:alert ne sont qu'une convention qui range les sujets par groupes.
Un exemple pratique : le système de notifications
On peut combiner les deux idées. ParkNotificationSystem étend EventEmitter avec un historique et un canal *, qui entend tout :
1class ParkNotificationSystem extends EventEmitter {
2 constructor() {
3 super();
4 this.history = [];
5 }
6
7 notify(channel, message, priority = "normal") {
8 const notification = {
9 channel,
10 message,
11 priority,
12 timestamp: new Date().toISOString()
13 };
14
15 this.history.push(notification);
16 this.emit(channel, notification);
17 this.emit("*", notification); // Joker - toutes les notifications
18 }
19
20 getHistory(channel) {
21 if (channel) {
22 return this.history.filter(n => n.channel === channel);
23 }
24 return [...this.history];
25 }
26}La méthode notify() émet chaque notification deux fois : sur le canal cible et sur le canal *. getHistory() sans argument renvoie une copie de l'historique, personne ne peut donc l'effacer par accident de l'extérieur.
Le panneau d'administration se branche sur l'astérisque, et les systèmes de sécurité et de maintenance sur leurs propres canaux :
1const notifications = new ParkNotificationSystem();
2
3// Panneau d'administration - écoute tout
4notifications.on("*", (n) => {
5 console.log(`[ADMIN] [${n.priority.toUpperCase()}] ${n.channel}: ${n.message}`);
6});
7
8// Système de sécurité - seulement les alertes
9notifications.on("security", (n) => {
10 if (n.priority === "critical") {
11 console.log(`ALERTE ROUGE : ${n.message}`);
12 }
13});
14
15// Journalisation
16notifications.on("maintenance", (n) => {
17 console.log(`Maintenance : ${n.message}`);
18});
19
20// Utilisation
21notifications.notify("security", "Panne de caméra dans le secteur B", "normal");
22notifications.notify("security", "Le T-Rex a percé la clôture !", "critical");
23notifications.notify("maintenance", "Remplacement des ampoules du laboratoire", "low");
24
25console.log("Historique des alertes :", notifications.getHistory("security"));Le panneau d'administration affichera les trois notifications, l'alerte rouge n'apparaîtra qu'avec la priorité critical, et l'historique des alertes contient deux entrées du canal security.
Observer contre Pub/Sub - quand les utiliser ?
| Caractéristique | Observer | Pub/Sub |
|---|---|---|
| Couplage | Le Subject connaît ses observateurs | L'émetteur et le récepteur ne se connaissent pas |
| Communication | Directe | Par un canal/sujet |
| À utiliser quand | Un objet en avertit plusieurs | De nombreux objets communiquent de façon lâche |
| Exemple | Un formulaire avertit les validateurs | Des microservices échangent des messages |
La différence essentielle est l'intermédiaire : dans l'Observer, le Subject tient lui-même la liste des observateurs, alors que dans le Pub/Sub, les parties ne se rencontrent que sur le canal. Aucun de ces motifs n'est par définition synchrone ou asynchrone - nos implémentations appellent les callbacks de façon synchrone, et dans les systèmes distribués, les messages Pub/Sub circulent généralement de façon asynchrone via un broker.
Résumé
Muldoon résume : "Le motif Observer, c'est comme le système d'alarme du parc - tu n'as pas besoin de vérifier sans arrêt chaque capteur. Les capteurs te préviennent d'eux-mêmes :"
- Observer - un objet (Subject) avertit les observateurs enregistrés de ses changements
- EventEmitter - une implémentation de l'Observer avec les méthodes
on(),emit(),off() - Pub/Sub - un couplage plus lâche via des canaux/sujets, l'émetteur ne connaît pas les récepteurs
- Découplage - les composants n'ont pas besoin de se connaître directement, ils communiquent par des événements
Ces motifs sont le fondement de nombreuses bibliothèques et frameworks - de Node.js (EventEmitter) aux architectures microservices, en passant par React (gestion de l'état).
Dans un vrai projet, n'écris pas d'émetteur à partir de zéro : Node.js a un EventEmitter tout prêt dans le module node:events, et le navigateur a EventTarget avec addEventListener(). Tu découvriras encore dans cette location les observateurs intégrés au navigateur, comme IntersectionObserver. Dans le laboratoire ci-dessous, tu ajouteras à PubSub une méthode once().
Retiens bien : dans l'Observer, ce sont les capteurs qui appellent les gardiens, et non les gardiens qui font le tour des enclos avec une lampe torche.
Code de cette leçon : index.js
1// Patron Observer et Pub/Sub
2// Jurassic Park - Systeme de notifications
3
4console.log("=== EVENT EMITTER ===");
5
6// Implementation simple d'un EventEmitter
7class EventEmitter {
8 constructor() {
9 this.listeners = {};
10 }
11
12 on(event, callback) {
13 if (!this.listeners[event]) {
14 this.listeners[event] = [];
15 }
16 this.listeners[event].push(callback);
17 return this;
18 }
19
20 emit(event, ...args) {
21 const callbacks = this.listeners[event];
22 if (callbacks) {
23 callbacks.forEach(callback => callback(...args));
24 }
25 return this;
26 }
27
28 off(event, callback) {
29 if (this.listeners[event]) {
30 this.listeners[event] = this.listeners[event].filter(
31 cb => cb !== callback
32 );
33 }
34 return this;
35 }
36
37 once(event, callback) {
38 const wrapper = (...args) => {
39 callback(...args);
40 this.off(event, wrapper);
41 };
42 this.on(event, wrapper);
43 return this;
44 }
45}
46
47console.log("\n=== SYSTEME DE SURVEILLANCE ===");
48
49// Systeme de surveillance des dinosaures
50class DinosaurMonitor extends EventEmitter {
51 constructor() {
52 super();
53 this.dinosaurs = new Map();
54 }
55
56 register(id, data) {
57 this.dinosaurs.set(id, { ...data, status: "contained" });
58 this.emit("registered", { id, ...data });
59 }
60
61 updateStatus(id, newStatus) {
62 const dino = this.dinosaurs.get(id);
63 if (!dino) return;
64 const oldStatus = dino.status;
65 dino.status = newStatus;
66 this.emit("status:changed", { id, name: dino.name, oldStatus, newStatus });
67 if (newStatus === "escaped") {
68 this.emit("dinosaur:escaped", { id, name: dino.name, species: dino.species });
69 }
70 }
71}
72
73const monitor = new DinosaurMonitor();
74
75// Enregistrement des observateurs
76monitor.on("dinosaur:escaped", (data) => {
77 console.log(`ALARME : ${data.name} s'est echappe ! Activation de la cloture.`);
78});
79
80monitor.on("dinosaur:escaped", (data) => {
81 console.log(`CAMERAS : suivi de ${data.name} (${data.species}).`);
82});
83
84monitor.on("status:changed", (data) => {
85 console.log(`LOG : ${data.name} : "${data.oldStatus}" -> "${data.newStatus}"`);
86});
87
88monitor.register("TREX-001", { name: "Rexy", species: "T-Rex" });
89monitor.register("RAPTOR-01", { name: "Blue", species: "Velociraptor" });
90
91console.log("\n--- Simulation d'une evasion ---");
92monitor.updateStatus("TREX-001", "escaped");
93
94console.log("\n=== PUB/SUB ===");
95
96// Patron Publish/Subscribe
97class PubSub {
98 constructor() {
99 this.topics = {};
100 this.subId = 0;
101 }
102
103 subscribe(topic, callback) {
104 if (!this.topics[topic]) this.topics[topic] = {};
105 const id = ++this.subId;
106 this.topics[topic][id] = callback;
107 return () => { delete this.topics[topic][id]; };
108 }
109
110 publish(topic, data) {
111 if (!this.topics[topic]) return;
112 Object.values(this.topics[topic]).forEach(cb => cb(data));
113 }
114}
115
116const parkBus = new PubSub();
117
118parkBus.subscribe("security:alert", (data) => {
119 console.log(`Cloture : alerte - ${data.message}`);
120});
121
122parkBus.subscribe("security:alert", (data) => {
123 console.log(`Centre : niveau ${data.level} - ${data.message}`);
124});
125
126const unsubFeeding = parkBus.subscribe("feeding", (data) => {
127 console.log(`Cuisine : ${data.food} pour ${data.dinosaur}`);
128});
129
130parkBus.publish("security:alert", {
131 level: "HIGH",
132 message: "Mouvement pres de la cloture du secteur C"
133});
134
135parkBus.publish("feeding", {
136 dinosaur: "Rexy",
137 food: "200 kg de boeuf"
138});
139
140// Annulation de l'abonnement
141unsubFeeding();
142console.log("\nApres annulation de l'abonnement feeding :");
143parkBus.publish("feeding", { dinosaur: "Blue", food: "50 kg de viande" });
144// Rien ne s'affichera
145
146// TODO : ajoute une methode once() a PubSub
147// TODO : cree un systeme de notifications avec un historique des evenements
148console.log("\n=== Exercice ===");
149console.log("Etends PubSub avec une methode once() - voir le TODO");Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Quel est le principal avantage du patron Observer par rapport aux appels de fonction directs ?
2. Quelle méthode d'EventEmitter sert à enregistrer un observateur (écouter un événement) ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Crée un EventEmitter avec les méthodes on(), emit(), off() et un système de notifications du parc.
- Clique dans l'ordre
Ordonne les opérations du patron Observer :