Question 1Que renvoie instanceof Document pour minutes, dans lequel des briques ont été copiées avec Object.assign ?
Composition et héritage — Object.assign et mixins
Assembler un objet à partir de briques : Object.assign, fonctions qui renvoient une brique, mixins, et quand préférer l'héritage.
Dans la gestion documentaire de ton entreprise, les comptes rendus doivent s'exporter en PDF, les contrats doivent pouvoir être approuvés, et les devis ont besoin des deux. Une classe n'a qu'un seul parent après extends : tu te retrouves donc à écrire une classe par combinaison de fonctionnalités.
Cet article présente la composition, qui découpe les fonctionnalités en briques à ajouter aux objets, et les fonctions mixins, qui empilent des fonctionnalités sur une classe.
N'ajouter que les briques utiles à chaque document — Object.assign
Imagine que tu as écrit une classe ApprovableDocument qui gère l'approbation, puis, comme les devis doivent aussi s'exporter en PDF, une classe ExportableDocument qui en hérite. Les comptes rendus n'ont besoin que de l'export PDF : soit ils héritent d'une approbation qui ne leur sert à rien, soit ils héritent directement de Document et tu recopies le code de l'export PDF.
Avec la composition (construire un objet en réunissant des groupes de méthodes, un par fonctionnalité), tu passes les briques à Object.assign(cible, brique1, brique2). Les clés et valeurs de tous les arguments après le premier sont copiées dans le premier, et c'est ce premier objet qui est renvoyé.
class Document {
constructor(title) { this.title = title; }
}
// Un groupe de méthodes (une brique) par fonctionnalité
const approvable = {
approve(name) { return `${this.title} : approuvé par ${name}`; },
};
const exportable = {
toPdfName() { return `${this.title}.pdf`; },
};
// On ne copie dans chaque document que les briques dont il a besoin
const minutes = Object.assign(new Document("Compte rendu hebdo"), exportable);
const estimate = Object.assign(new Document("Devis serveur"), approvable, exportable);
console.log(Object.keys(estimate).join(", ")); // title, approve, toPdfName
console.log(estimate.approve("Alice")); // Devis serveur : approuvé par Alice
console.log(minutes.toPdfName()); // Compte rendu hebdo.pdf
console.log(typeof minutes.approve); // undefined (pas d'approbation sur le compte rendu)
Dans estimate.approve("Alice"), c'est estimate qui se trouve à gauche du point : this vaut donc estimate dans approve, et this.title est le titre du devis. La cible reste une instance créée par new Document, donc instanceof Document vaut aussi true.
Donner à chaque document ses propres commentaires — des fonctions qui renvoient des briques
Tu veux maintenant une liste de commentaires : tu fais de { comments: [], addComment(text) { ... } } une brique et tu la copies dans le compte rendu et dans le devis avec Object.assign. Seule une référence au tableau est copiée : les deux documents partagent donc le même tableau comments, et un commentaire ajouté au compte rendu apparaît aussi dans la liste du devis.
Si tu réécris la brique des commentaires sous la forme de commentable, une fonction qui crée et renvoie une nouvelle brique à chaque appel, le [] de comments est un tableau différent à chaque fois. Fais aussi de approvable une fonction et écris { ...approvable(), ...commentable() } : la syntaxe de décomposition (spread) copie les clés et valeurs de chaque brique dans un nouvel objet.
// Une fonction qui renvoie une brique : chaque appel crée un nouvel objet et un nouveau tableau
const commentable = () => ({
comments: [],
addComment(text) { this.comments.push(text); },
});
const approvable = () => ({
approvedBy: "en attente",
approve(name) { this.approvedBy = name; },
});
// On décompose les briques pour créer un nouveau document
const minutes = { title: "Compte rendu hebdo", ...commentable() };
const estimate = { title: "Devis serveur", ...approvable(), ...commentable() };
minutes.addComment("Point le 10");
estimate.approve("Alice");
console.log(minutes.comments.length, estimate.comments.length); // 1 0 (tableaux distincts)
console.log(estimate.approvedBy); // Alice
console.log(Object.keys(estimate).join(", ")); // title, approvedBy, approve, comments, addComment
C'est le { } qui crée le nouvel objet ; la syntaxe de décomposition ne fait qu'y copier les clés de ce que renvoie chaque brique. Contrairement à Object.assign, tu ne prépares pas de cible à l'avance : tu obtiens un objet ordinaire, et non une instance de classe.
Si deux clés portent le même nom, la dernière brique l'emporte
Avec Object.assign comme avec la syntaxe de décomposition, quand deux briques ont une clé du même nom, c'est la valeur de la brique passée en dernier qui reste. Si approvable() et commentable() avaient toutes deux un describe, seul le dernier resterait, sans aucune erreur : évite donc que les briques partagent des noms de méthodes.
Empiler des fonctionnalités dans la définition d'une classe — les fonctions mixins
Ailleurs dans l'application, les devis sont définis par class Estimate et créés avec new Estimate(...) sur chaque écran. Si tu ajoutes l'approbation et les commentaires après coup avec Object.assign, il faut les copier après chaque new, et au moindre devis oublié, l'appel à approve lève une TypeError.
Une fonction mixin (une fonction qui reçoit une classe et renvoie une classe qui en hérite en ajoutant des fonctionnalités) s'écrit (Base) => class extends Base { ... }. class extends Base { ... } est une expression de classe sans nom. Tu peux écrire un appel à cette fonction après extends : la classe qu'elle renvoie devient le parent.
class Document {
constructor(title) { this.title = title; }
describe() { return this.title; }
}
// Reçoit une classe et renvoie une classe qui en hérite, avec des fonctionnalités en plus
const Approvable = (Base) => class extends Base {
approve(name) { this.approvedBy = name; }
describe() { return `${super.describe()} (${this.approvedBy ?? "en attente"})`; }
};
const Commentable = (Base) => class extends Base {
comments = []; // Un nouveau tableau pour chaque instance
addComment(text) { this.comments.push(text); }
};
// Les deux sont empilées dans la définition : rien à copier après chaque new
class Estimate extends Commentable(Approvable(Document)) {}
const estimate = new Estimate("Devis serveur");
estimate.approve("Alice");
console.log(estimate.describe()); // Devis serveur (Alice)
Dans Approvable, super.describe() appelle le describe de Document, la Base reçue. Même si tu inverses l'ordre d'empilement en Approvable(Commentable(Document)), tu obtiens toujours l'approbation et les commentaires.
Hériter d'un mixin sans l'appeler lève une TypeError
extends Approvable fait de la fonction fléchée elle-même le parent. Une fonction fléchée ne peut pas être le parent d'une classe : une TypeError: Class extends value ... is not a constructor or null est levée. Écris plutôt Approvable(Document) comme parent.
Comparer les deux approches — mixins ou extends
À mesure que tu écris une fonction mixin par fonctionnalité, les parenthèses s'imbriquent, comme dans class Estimate extends Commentable(Approvable(Document)) {}. Plus tu en empiles, plus il devient difficile de lire sur cette seule ligne de déclaration ce qu'est Estimate et quelles fonctionnalités ont été ajoutées par-dessus.
Compare avec une version par héritage, où CommentableDocument (commentaires) hérite de ApprovableDocument (approbation). Dans les deux cas, instanceof Document vaut true et tu peux utiliser super. La différence, c'est la possibilité de choisir les fonctionnalités classe par classe.
class Document { constructor(title) { this.title = title; } }
const Commentable = (Base) => class extends Base { // Le même mixin qu'à la section précédente
comments = []; addComment(text) { this.comments.push(text); }
};
// Héritage : la classe des commentaires hérite de celle de l'approbation
class ApprovableDocument extends Document {
approve(name) { this.approvedBy = name; }
}
class CommentableDocument extends ApprovableDocument {
comments = [];
addComment(text) { this.comments.push(text); }
}
const oldMinutes = new CommentableDocument("Compte rendu hebdo");
console.log(typeof oldMinutes.approve); // function (hérite d'une approbation inutile)
// Empilement avec un mixin
class Minutes extends Commentable(Document) {}
const minutes = new Minutes("Compte rendu hebdo");
console.log(typeof minutes.approve); // undefined (seuls les commentaires sont ajoutés)
console.log(minutes instanceof Document, oldMinutes instanceof Document); // true true
Range dans des briques les fonctionnalités dont certains types d'objets ont besoin et d'autres non, comme l'approbation ou les commentaires, et utilise extends pour les relations qui tiennent en une seule lignée parent-enfant, du type « un devis est une sorte de document ». Le tableau ci-dessous reprend les quatre approches vues dans cet article.
| Approche | Ajout des fonctionnalités | Utile pour |
|---|---|---|
| extends Parent | Hérite de tout le parent | Une relation « est un » |
| Object.assign(cible, brique) | Copie dans un objet existant | Compléter un objet reçu |
| { ...brique() } | Copie dans un nouvel objet | Assembler sans classe |
| extends Mixin(Parent) | Empile les fonctions choisies | Enrichir une classe |
Vérification des connaissances
Répondez à chaque question une par une.
Question 2Tu copies le comments: [] d'une même brique dans deux documents avec Object.assign, puis tu fais un push sur l'un d'eux. Que se passe-t-il ?
Question 3Dans Commentable(Approvable(Document)), quelle classe hérite directement de Document ?