Aprende leyendo en orden

Composición y herencia — Object.assign y mixins

Divide las funcionalidades en piezas y combínalas en un solo objeto. Verás Object.assign, funciones que devuelven piezas, funciones mixin y cuándo conviene la herencia.

El sistema de documentos de tu empresa necesita exportar a PDF las actas de reunión, aprobar los contratos y hacer ambas cosas con los presupuestos. Después de extends solo puedes poner un padre, así que terminas escribiendo una clase para cada combinación de funcionalidades.

En este artículo verás la composición, que divide las funcionalidades en piezas y las agrega a los objetos, y las funciones mixin, que apilan funcionalidades sobre una clase.

Agregar solo las piezas que necesita cada documento — Object.assign

Supón que creaste una clase ApprovableDocument que permite aprobar y, como los presupuestos también necesitan exportarse a PDF, otra clase ExportableDocument que hereda de ella. Las actas solo necesitan el PDF, así que o heredan una aprobación que nunca usan, o extienden Document por separado y copian el código del PDF.

Con la composición (armar un objeto a partir de grupos de métodos separados, un grupo por funcionalidad), le pasas las piezas a Object.assign(destino, pieza1, pieza2). Copia en el primer argumento las propiedades de todos los demás y devuelve ese mismo primer objeto.

class Document {
  constructor(title) { this.title = title; }
}

// Crea un grupo de métodos (una pieza) por funcionalidad
const approvable = {
  approve(name) { return `${this.title}: aprobado por ${name}`; },
};
const exportable = {
  toPdfName() { return `${this.title}.pdf`; },
};

// Copia en cada documento solo las piezas que necesita
const minutes = Object.assign(new Document("Acta de la reunión semanal"), exportable);
const estimate = Object.assign(new Document("Presupuesto de mantenimiento"), approvable, exportable);

console.log(Object.keys(estimate).join(", "));   // title, approve, toPdfName
console.log(estimate.approve("Ana"));           // Presupuesto de mantenimiento: aprobado por Ana
console.log(minutes.toPdfName());               // Acta de la reunión semanal.pdf
console.log(typeof minutes.approve);            // undefined (el acta no tiene aprobación)
El orden en que copia Object.assign
new Document("Presupuesto…")Por ahora su únicaclave es titleCopia approvede approvableapprovable nocambiaCopia toPdfNamede exportableDevuelve la mismainstancia destino
Las propiedades de cada pieza se copian en la misma instancia, de izquierda a derecha. No se crea ningún objeto nuevo: el valor devuelto es el propio destino.

En estimate.approve("Ana"), estimate está a la izquierda del punto, así que this dentro de approve es estimate y this.title es el título del presupuesto. El destino sigue siendo una instancia de new Document, por lo que instanceof Document también da true.

En el chat del equipo, vas a agregar a los mensajes la opción de fijarlos. messages y pinnable ya están declarados.

① Copia pinnable en el primer mensaje y guarda el valor devuelto en una variable.

② Fija el mensaje a través de la variable de ① y muestra el resultado y la propiedad pinned del primer mensaje.

③ Muestra si el valor devuelto en ① es el mismo objeto que el primer mensaje.

④ Muestra el tipo de pin del segundo mensaje.

(Si se ejecuta correctamente, aparecerá una explicación.)

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado

Comentarios propios para cada documento — funciones que devuelven piezas

Ahora quieres agregar también una lista de comentarios, así que conviertes { comments: [], addComment(text) { ... } } en una pieza y la copias en el acta y en el presupuesto con Object.assign. Lo que se copia es solo la referencia al array, así que el comments de ambos documentos apunta al mismo array, y un comentario agregado al acta también aparece en la lista del presupuesto.

Si cambias la pieza de comentarios por commentable, una función que crea y devuelve una pieza nueva cada vez que la llamas, el [] de comments es un array distinto en cada llamada. Convierte también approvable en función y escribe { ...approvable(), ...commentable() }: la sintaxis spread copia las claves y los valores de cada pieza en un objeto nuevo.

// Una función que devuelve una pieza: cada llamada crea un objeto y un array nuevos
const commentable = () => ({
  comments: [],
  addComment(text) { this.comments.push(text); },
});
const approvable = () => ({
  approvedBy: "pendiente",
  approve(name) { this.approvedBy = name; },
});

// Combina las piezas con spread para crear un documento nuevo
const minutes = { title: "Acta de la reunión semanal", ...commentable() };
const estimate = { title: "Presupuesto de mantenimiento", ...approvable(), ...commentable() };

minutes.addComment("Próxima: el 10");
estimate.approve("Ana");
console.log(minutes.comments.length, estimate.comments.length);   // 1 0 (arrays distintos)
console.log(estimate.approvedBy);                                  // Ana
console.log(Object.keys(estimate).join(", "));   // title, approvedBy, approve, comments, addComment
¿Los comentarios del acta llegan al presupuesto?
Object.assign dela misma piezaa los doscomments apuntaal mismo arrayminutes.addComment("Próxima: el 10")estimate.commentstambién tiene 1Llama acommentable()por documentocomments es un []nuevo cada vezminutes.addComment("Próxima: el 10")estimate.commentstiene 0
Tras agregar un comentario al acta, también aparece en el presupuesto, pero solo en la fila de arriba. Lo que se copia es la referencia al array, así que crea la pieza de nuevo para cada documento.

El objeto nuevo lo crea el literal { }; la sintaxis spread solo copia en él las claves del valor que devuelve cada pieza. A diferencia de Object.assign, no preparas un destino antes, así que lo que obtienes es un objeto plano, no una instancia de una clase.

Si una clave se repite, gana la última pieza

Tanto con Object.assign como con la sintaxis spread, cuando dos piezas comparten el nombre de una clave, se queda el valor de la pieza que pasaste después. Si approvable() y commentable() tuvieran los dos un describe, solo quedaría el último, sin ningún error, así que evita que los nombres de métodos se repitan entre piezas.

En el chat del equipo, vas a crear mensajes con reacciones y confirmación de lectura. reactable ya está declarado.

① Define una función que devuelva una pieza nueva en cada llamada. La pieza agrega a un array el nombre de cada persona que leyó el mensaje. Agrega cada nombre una sola vez.

② Combina las dos piezas para crear un mensaje de Ana y otro de Carlos.

③ Agrega una reacción al mensaje de Ana y márcalo como leído tres veces, de modo que una misma persona lo lea dos veces.

④ Muestra la cantidad de reacciones y de lectores de los dos mensajes.

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado

Apilar funcionalidades en la definición de una clase — funciones mixin

En otra parte del sistema, los presupuestos se definen como class Estimate y se crean con new Estimate(...) en cada pantalla. Si agregas la aprobación y los comentarios después con Object.assign, tienes que copiarlos después de cada new, y si te olvidas en algún presupuesto, llamar a approve lanza un TypeError.

Una función mixin (una función que recibe una clase y devuelve otra que la extiende con funcionalidades extra) se escribe como (Base) => class extends Base { ... }. class extends Base { ... } es una expresión de clase sin nombre. Después de extends puedes escribir una llamada a esta función, y la clase que devuelve pasa a ser el padre.

class Document {
  constructor(title) { this.title = title; }
  describe() { return this.title; }
}

// Recibe una clase y devuelve una clase que la extiende con funcionalidades extra
const Approvable = (Base) => class extends Base {
  approve(name) { this.approvedBy = name; }
  describe() { return `${super.describe()} (${this.approvedBy ?? "pendiente"})`; }
};
const Commentable = (Base) => class extends Base {
  comments = [];                                  // Un array nuevo para cada instancia
  addComment(text) { this.comments.push(text); }
};

// Las dos se apilan en la definición de la clase, así que no hay nada que copiar tras cada new
class Estimate extends Commentable(Approvable(Document)) {}
const estimate = new Estimate("Presupuesto de mantenimiento");
estimate.approve("Ana");
console.log(estimate.describe());   // Presupuesto de mantenimiento (Ana)
Las dos clases apiladas sobre Estimate
class Estimatecon cuerpo vacíoLa búsqueda dedescribe empiezaaquíClase devueltapor Commentablecomments yaddCommentClase devueltapor Approvableapprove y describese encuentran aquíclass Documentsuper.describe()→ "Presupuesto…"
El Approvable de adentro queda más cerca de Document. Aunque Estimate esté vacía, la búsqueda de métodos recorre las cuatro clases en orden.

En Approvable, super.describe() llama al describe de Document, que es el Base que recibió. Aunque inviertas el orden y escribas Approvable(Commentable(Document)), igual obtienes la aprobación y los comentarios.

Extender un mixin sin llamarlo lanza un TypeError

extends Approvable convierte en padre a la propia función flecha. Una función flecha no puede ser el padre de una clase, así que se lanza TypeError: Class extends value ... is not a constructor or null. Escribe Approvable(Document) como padre.

Comparar los dos enfoques — mixins frente a extends

A medida que escribes una función mixin por cada funcionalidad, los paréntesis empiezan a anidarse, como en class Estimate extends Commentable(Approvable(Document)) {}. Cuantas más apilas, más difícil es saber, con solo leer esa línea de declaración, de qué tipo es Estimate y qué funcionalidades se le agregaron encima.

Compáralo con una versión con herencia, en la que CommentableDocument (comentarios) extiende ApprovableDocument (aprobación). Con cualquiera de los dos enfoques, instanceof Document da true y puedes usar super. La diferencia está en si puedes elegir las funcionalidades de cada clase.

class Document { constructor(title) { this.title = title; } }
const Commentable = (Base) => class extends Base {   // El mismo mixin de la sección anterior
  comments = []; addComment(text) { this.comments.push(text); }
};

// Herencia: la clase de comentarios se construye sobre la de aprobación
class ApprovableDocument extends Document {
  approve(name) { this.approvedBy = name; }
}
class CommentableDocument extends ApprovableDocument {
  comments = [];
  addComment(text) { this.comments.push(text); }
}
const oldMinutes = new CommentableDocument("Acta de la reunión semanal");
console.log(typeof oldMinutes.approve);    // function (hereda una aprobación que no usa)

// Apilar con un mixin
class Minutes extends Commentable(Document) {}
const minutes = new Minutes("Acta de la reunión semanal");
console.log(typeof minutes.approve);       // undefined (solo se agregan los comentarios)
console.log(minutes instanceof Document, oldMinutes instanceof Document);   // true true
Qué recibe el acta con herencia y con composición
oldMinutes es unCommentableDocumentSu padre esApprovableDocumentapprove está enApprovableDocumenttypeof approvees functionminutes esun MinutesSu padre es laclase CommentableNo hay approveni en Documenttypeof approvees undefined
Con herencia, el acta recibe incluso el approve del padre ApprovableDocument. Con el mixin, solo recibe las funcionalidades de Commentable.

Las funcionalidades que algunos tipos de objeto necesitan y otros no, como la aprobación y los comentarios, ponlas en piezas, y usa extends para las relaciones que forman una sola línea de padre a hijo, como «un presupuesto es un tipo de documento». La tabla de abajo reúne los cuatro enfoques de este artículo.

EnfoqueCómo se agreganÚtil para
extends PadreHereda todo del padreRelación «es un tipo de»
Object.assign(destino, pieza)Copia en un objeto existenteAmpliar un objeto que ya tienes
{ ...pieza() }Copia en un objeto nuevoArmar sin clases
extends Mixin(Padre)Apila lo que eligesAgregar en la definición

En el chat del equipo, vas a crear tres tipos de mensajes: solo fijables, solo con reacciones y con ambas cosas. Message y Reactable ya están declarados.

① Define una función mixin que devuelva una clase con la opción de fijar.

② Define las tres clases.

③ Crea un mensaje de cada tipo y muestra los tipos de pin y react.

④ Agrega una reacción al mensaje que tiene ambas cosas y muestra el resultado de pin y la cantidad de reacciones.

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado
QUIZ

Verificación de conocimientos

Responde cada pregunta una a una.

Pregunta 1¿Qué devuelve instanceof Document para minutes, en el que se copiaron piezas con Object.assign?

Pregunta 2Si copias el comments: [] de una misma pieza en dos documentos con Object.assign y haces push en uno, ¿qué pasa?

Pregunta 3En Commentable(Approvable(Document)), ¿qué clase extiende directamente a Document?