Apprenez en lisant dans l'ordre

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)
L'ordre dans lequel Object.assign copie
new Document("Devis serveur")Seule clé pourl'instant : titleCopie approvedepuis approvableapprovablelui-même intactCopie toPdfNamedepuis exportableRenvoie cettemême instance
Les clés de chaque brique sont copiées dans la même instance, de gauche à droite. Aucun nouvel objet n'est créé : la valeur renvoyée est la cible elle-même.

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.

Dans la messagerie interne de ton équipe, tu ajoutes l'épinglage aux messages reçus. messages et pinnable sont déjà déclarés.

① Copie pinnable dans le premier message et range la valeur renvoyée dans une variable.

② Épingle le message via la variable de ①, puis affiche le résultat et la propriété pinned du premier message.

③ Affiche si la valeur renvoyée en ① est le même objet que le premier message.

④ Affiche le type de pin sur le deuxième message.

(Si tout s'exécute correctement, une explication apparaîtra.)

Éditeur JavaScript / TypeScript

Exécuter le code pour voir le résultat

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
Les commentaires du compte rendu arrivent-ils sur le devis ?
Object.assign :même brique aux 2comments : mêmetableau partagéminutes.addComment("Point le 10")estimate.commentsen a 1 aussicommentable()par documentcomments : nouveau[] à chaque appelminutes.addComment("Point le 10")estimate.commentsen a 0
Dans la rangée du haut, le commentaire ajouté au compte rendu apparaît aussi sur le devis. C'est une référence au tableau qui est copiée : recrée donc la brique pour chaque document.

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.

Dans la messagerie interne, tu crées des messages avec des réactions et des accusés de lecture. reactable est déjà déclaré.

① Définis une fonction qui renvoie une nouvelle brique à chaque appel. Cette brique ajoute à un tableau le nom de chaque personne qui a lu le message, une seule fois par nom.

② Assemble les deux briques pour créer un message d'Alice et un message de Léa.

③ Ajoute une réaction au message d'Alice et marque-le comme lu trois fois, dont deux fois par la même personne.

④ Affiche le nombre de réactions et de lecteurs pour les deux messages.

Éditeur JavaScript / TypeScript

Exécuter le code pour voir le résultat

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)
Les deux classes empilées au-dessus d'Estimate
class Estimateau corps videdescribe cherchéà partir d'iciClasse renvoyéepar Commentablecomments etaddCommentClasse renvoyéepar Approvableapprove, describetrouvés iciclass Documentsuper.describe()→ "Devis serveur"
Approvable, l'appel le plus intérieur, se place juste au-dessus de Document. Même avec un Estimate vide, la recherche traverse les quatre classes dans l'ordre.

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
Les fonctionnalités du compte rendu : héritage ou composition
oldMinutes est unCommentableDocumentSon parent :ApprovableDocumentapprove est dansApprovableDocumenttypeof approvevaut functionminutes estun MinutesSon parent : laclasse CommentableAucun approvejusqu'à Documenttypeof approvevaut undefined
Avec l'héritage, le compte rendu reçoit même la méthode approve de son parent ApprovableDocument. Avec le mixin, il ne reçoit que les fonctionnalités de Commentable.

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.

ApprocheAjout des fonctionnalitésUtile pour
extends ParentHérite de tout le parentUne relation « est un »
Object.assign(cible, brique)Copie dans un objet existantCompléter un objet reçu
{ ...brique() }Copie dans un nouvel objetAssembler sans classe
extends Mixin(Parent)Empile les fonctions choisiesEnrichir une classe

Dans la messagerie interne, tu crées trois types de messages : épinglage seul, réactions seules, et les deux. Message et Reactable sont déjà déclarés.

① Définis une fonction mixin qui renvoie une classe avec l'épinglage en plus.

② Définis les trois classes.

③ Crée un message de chaque type et affiche les types de pin et de react.

④ Ajoute une réaction au message qui a les deux, puis affiche le résultat de l'épinglage et le nombre de réactions.

Éditeur JavaScript / TypeScript

Exécuter le code pour voir le résultat
QUIZ

Vérification des connaissances

Répondez à chaque question une par une.

Question 1Que renvoie instanceof Document pour minutes, dans lequel des briques ont été copiées avec Object.assign ?

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 ?