JavaScript
Tutorial JavaScript: Clases y objetos
JavaScript clases objetos: creación y uso. Domina la creación y uso de clases y objetos en JavaScript con ejemplos prácticos y detallados.
Aprende JavaScript y certifícateFundamentos de la programación orientada a objetos en JavaScript: Sintaxis y semántica de clases
JavaScript ha evolucionado significativamente desde sus inicios, incorporando características de programación orientada a objetos (POO) que permiten estructurar el código de manera más organizada y reutilizable. Aunque JavaScript utiliza un sistema basado en prototipos, con la llegada de ES6 (ECMAScript 2015) se introdujo una sintaxis de clases que facilita la implementación de conceptos de POO de forma más intuitiva y familiar para desarrolladores provenientes de otros lenguajes.
Definición de clases
En JavaScript, una clase es esencialmente una plantilla para crear objetos. La sintaxis básica para definir una clase utiliza la palabra clave class
seguida del nombre de la clase (por convención en PascalCase) y un bloque de código entre llaves:
class Person {
// Contenido de la clase
}
Constructor
El constructor es un método especial que se ejecuta automáticamente cuando se crea una nueva instancia de la clase. Se utiliza para inicializar las propiedades del objeto:
class Person {
constructor(firstName, lastName, age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
}
El constructor recibe los parámetros necesarios y utiliza la palabra clave this
para referirse a la instancia actual del objeto que se está creando.
Instanciación de objetos
Para crear un objeto a partir de una clase, utilizamos el operador new
seguido del nombre de la clase:
const john = new Person('John', 'Doe', 30);
console.log(john.firstName); // Output: John
console.log(john.lastName); // Output: Doe
console.log(john.age); // Output: 30
Métodos de clase
Los métodos son funciones definidas dentro de la clase que describen el comportamiento de los objetos:
class Person {
constructor(firstName, lastName, age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
getFullName() {
return `${this.firstName} ${this.lastName}`;
}
greet() {
return `Hello, my name is ${this.getFullName()} and I am ${this.age} years old.`;
}
celebrateBirthday() {
this.age++;
return `Happy Birthday! Now I am ${this.age} years old.`;
}
}
const jane = new Person('Jane', 'Smith', 25);
console.log(jane.getFullName()); // Output: Jane Smith
console.log(jane.greet()); // Output: Hello, my name is Jane Smith and I am 25 years old.
console.log(jane.celebrateBirthday()); // Output: Happy Birthday! Now I am 26 years old.
Métodos estáticos
Los métodos estáticos pertenecen a la clase en sí, no a las instancias individuales. Se definen utilizando la palabra clave static
y se invocan directamente desde la clase, sin necesidad de crear una instancia:
class MathUtils {
static add(a, b) {
return a + b;
}
static multiply(a, b) {
return a * b;
}
static square(x) {
return x * x;
}
}
console.log(MathUtils.add(5, 3)); // Output: 8
console.log(MathUtils.multiply(4, 2)); // Output: 8
console.log(MathUtils.square(3)); // Output: 9
Los métodos estáticos son útiles para funcionalidades que están relacionadas conceptualmente con la clase pero no dependen del estado de una instancia específica.
Propiedades estáticas
De manera similar, podemos definir propiedades estáticas que pertenecen a la clase y no a las instancias:
class Config {
static API_URL = 'https://api.example.com';
static MAX_RETRY_ATTEMPTS = 3;
static TIMEOUT_MS = 5000;
}
console.log(Config.API_URL); // Output: https://api.example.com
console.log(Config.MAX_RETRY_ATTEMPTS); // Output: 3
Getters y setters
JavaScript permite definir métodos de acceso (getters) y métodos de modificación (setters) para controlar el acceso a las propiedades de un objeto:
class Rectangle {
constructor(width, height) {
this._width = width;
this._height = height;
}
// Getter para width
get width() {
return this._width;
}
// Setter para width
set width(value) {
if (value <= 0) {
throw new Error('Width must be positive');
}
this._width = value;
}
// Getter para height
get height() {
return this._height;
}
// Setter para height
set height(value) {
if (value <= 0) {
throw new Error('Height must be positive');
}
this._height = value;
}
// Getter calculado
get area() {
return this._width * this._height;
}
}
const rect = new Rectangle(10, 5);
console.log(rect.width); // Output: 10
console.log(rect.height); // Output: 5
console.log(rect.area); // Output: 50
rect.width = 20;
console.log(rect.width); // Output: 20
console.log(rect.area); // Output: 100
// Esto lanzará un error
// rect.height = -5; // Error: Height must be positive
Los getters y setters se comportan como propiedades normales desde fuera de la clase, pero permiten ejecutar código cuando se accede a ellas o se modifican.
Campos de clase
Las versiones más recientes de JavaScript (a partir de ES2022) soportan la declaración de campos de clase directamente, sin necesidad de inicializarlos en el constructor:
class Product {
id = Math.random().toString(36).substr(2, 9);
name;
price;
constructor(name, price) {
this.name = name;
this.price = price;
}
getInfo() {
return `${this.name}: $${this.price} (ID: ${this.id})`;
}
}
const laptop = new Product('Laptop', 999.99);
console.log(laptop.getInfo()); // Output: Laptop: $999.99 (ID: x7f3e2q1z)
Campos privados
JavaScript también ha incorporado campos privados que solo son accesibles dentro de la clase. Se definen con un prefijo #
:
class BankAccount {
#balance = 0;
#accountNumber;
constructor(accountNumber, initialDeposit = 0) {
this.#accountNumber = accountNumber;
if (initialDeposit > 0) {
this.deposit(initialDeposit);
}
}
deposit(amount) {
if (amount <= 0) {
throw new Error('Deposit amount must be positive');
}
this.#balance += amount;
return this.#balance;
}
withdraw(amount) {
if (amount <= 0) {
throw new Error('Withdrawal amount must be positive');
}
if (amount > this.#balance) {
throw new Error('Insufficient funds');
}
this.#balance -= amount;
return this.#balance;
}
get balance() {
return this.#balance;
}
get accountNumber() {
// Mostrar solo los últimos 4 dígitos por seguridad
return `xxxx-xxxx-xxxx-${this.#accountNumber.slice(-4)}`;
}
}
const account = new BankAccount('1234567890123456', 1000);
console.log(account.balance); // Output: 1000
console.log(account.accountNumber); // Output: xxxx-xxxx-xxxx-3456
account.deposit(500);
console.log(account.balance); // Output: 1500
account.withdraw(200);
console.log(account.balance); // Output: 1300
// Estos intentos de acceso directo generarían errores
// console.log(account.#balance); // Error: Private field '#balance' must be declared in an enclosing class
// console.log(account.#accountNumber); // Error: Private field '#accountNumber' must be declared in an enclosing class
Los campos privados proporcionan un verdadero encapsulamiento en JavaScript, impidiendo el acceso directo desde fuera de la clase.
Métodos privados
De manera similar, podemos definir métodos privados que solo son accesibles dentro de la clase:
class Calculator {
#precision = 10;
constructor(precision = 10) {
this.#precision = precision;
}
add(a, b) {
return this.#round(a + b);
}
subtract(a, b) {
return this.#round(a - b);
}
multiply(a, b) {
return this.#round(a * b);
}
divide(a, b) {
if (b === 0) {
throw new Error('Division by zero');
}
return this.#round(a / b);
}
// Método privado
#round(value) {
const factor = Math.pow(10, this.#precision);
return Math.round(value * factor) / factor;
}
}
const calc = new Calculator(2);
console.log(calc.add(0.1, 0.2)); // Output: 0.3 (en lugar de 0.30000000000000004)
console.log(calc.multiply(0.1, 0.3)); // Output: 0.03
// Esto generaría un error
// calc.#round(5.678); // Error: Private method '#round' must be declared in an enclosing class
Expresiones de clase
Las clases en JavaScript también pueden definirse como expresiones, lo que permite crear clases anónimas o asignarlas a variables:
// Clase anónima asignada a una variable
const Vehicle = class {
constructor(type, speed) {
this.type = type;
this.speed = speed;
}
accelerate(increment) {
this.speed += increment;
return `${this.type} accelerating to ${this.speed} km/h`;
}
};
const car = new Vehicle('Car', 0);
console.log(car.accelerate(50)); // Output: Car accelerating to 50 km/h
Esta característica es útil para crear clases dinámicamente o cuando se necesita pasar una clase como argumento a una función.
La sintaxis de clases en JavaScript proporciona una forma más clara y estructurada de implementar la programación orientada a objetos, aunque es importante recordar que bajo el capó sigue funcionando el sistema de prototipos que ha sido parte fundamental del lenguaje desde sus inicios.
Herencia y polimorfismo: Extensión de clases y organización jerárquica de comportamiento
La herencia es uno de los pilares fundamentales de la programación orientada a objetos que permite crear nuevas clases basadas en clases existentes. En JavaScript, la herencia se implementa mediante la palabra clave extends
, que establece una relación jerárquica entre clases, donde la clase derivada (hija) hereda propiedades y métodos de la clase base (padre).
Herencia básica con extends
Para crear una clase que herede de otra, utilizamos la sintaxis extends
:
class Animal {
constructor(name) {
this.name = name;
this.energy = 100;
}
eat(amount) {
this.energy += amount;
return `${this.name} is eating. Energy: ${this.energy}`;
}
sleep(hours) {
this.energy += hours * 10;
return `${this.name} is sleeping. Energy: ${this.energy}`;
}
makeSound() {
return `${this.name} makes a generic sound`;
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // Llamada al constructor de la clase padre
this.breed = breed;
}
makeSound() {
return `${this.name} barks loudly!`;
}
fetch() {
this.energy -= 10;
return `${this.name} is fetching. Energy: ${this.energy}`;
}
}
En este ejemplo, Dog
hereda todas las propiedades y métodos de Animal
, y además añade su propia propiedad breed
y método fetch()
. La palabra clave super
se utiliza para llamar al constructor de la clase padre.
La palabra clave super
El uso de super
es crucial en la herencia por dos razones principales:
- Llamar al constructor padre: Debe ser la primera instrucción en el constructor de la clase hija.
constructor(name, breed) {
super(name); // Llama al constructor de Animal
this.breed = breed;
}
- Acceder a métodos del padre: Permite invocar métodos de la clase base desde la clase derivada.
class Cat extends Animal {
constructor(name, furColor) {
super(name);
this.furColor = furColor;
}
makeSound() {
// Llamamos al método makeSound() de la clase Animal
const baseSound = super.makeSound();
return `${baseSound}, but then meows`;
}
groom() {
this.energy -= 5;
return `${this.name} is grooming its ${this.furColor} fur. Energy: ${this.energy}`;
}
}
const fluffy = new Cat('Fluffy', 'white');
console.log(fluffy.makeSound()); // Output: Fluffy makes a generic sound, but then meows
console.log(fluffy.groom()); // Output: Fluffy is grooming its white fur. Energy: 95
Cadenas de herencia
JavaScript permite crear cadenas de herencia donde una clase puede heredar de otra que a su vez hereda de una tercera:
class Mammal extends Animal {
constructor(name, bodyTemperature) {
super(name);
this.bodyTemperature = bodyTemperature;
}
regulateTemperature() {
return `${this.name} maintains a body temperature of ${this.bodyTemperature}°C`;
}
}
class Wolf extends Mammal {
constructor(name, packName) {
super(name, 38);
this.packName = packName;
}
howl() {
this.energy -= 15;
return `${this.name} howls to the moon! Energy: ${this.energy}`;
}
makeSound() {
return `${this.name} growls and howls`;
}
}
const alpha = new Wolf('Luna', 'Northern Pack');
console.log(alpha.regulateTemperature()); // Output: Luna maintains a body temperature of 38°C
console.log(alpha.howl()); // Output: Luna howls to the moon! Energy: 85
console.log(alpha.eat(20)); // Output: Luna is eating. Energy: 105
En este ejemplo, Wolf
hereda de Mammal
, que a su vez hereda de Animal
, formando una jerarquía de clases que refleja relaciones naturales.
Polimorfismo en JavaScript
El polimorfismo permite que objetos de diferentes clases respondan al mismo método de manera distinta. En JavaScript, esto se logra principalmente mediante la sobrescritura de métodos:
// Usando las clases definidas anteriormente
const animals = [
new Animal('Generic Animal'),
new Dog('Rex', 'German Shepherd'),
new Cat('Whiskers', 'tabby'),
new Wolf('Timber', 'Mountain Pack')
];
// Polimorfismo en acción
animals.forEach(animal => {
console.log(animal.makeSound());
});
// Output:
// Generic Animal makes a generic sound
// Rex barks loudly!
// Whiskers makes a generic sound, but then meows
// Timber growls and howls
Cada clase implementa el método makeSound()
de manera diferente, pero podemos invocar el mismo método en todos los objetos independientemente de su tipo específico. Esta es la esencia del polimorfismo.
Verificación de instancias
Para determinar si un objeto es una instancia de una clase específica, utilizamos el operador instanceof
:
const rex = new Dog('Rex', 'German Shepherd');
console.log(rex instanceof Dog); // Output: true
console.log(rex instanceof Animal); // Output: true
console.log(rex instanceof Object); // Output: true
console.log(rex instanceof Cat); // Output: false
El operador instanceof
verifica toda la cadena de prototipos, por lo que un objeto es instancia no solo de su clase directa sino también de todas sus clases ancestras.
Herencia de métodos estáticos
Los métodos estáticos también se heredan en la jerarquía de clases:
class MathHelper {
static PI = 3.14159;
static square(x) {
return x * x;
}
static cube(x) {
return x * x * x;
}
}
class AdvancedMathHelper extends MathHelper {
static EULER = 2.71828;
static squareRoot(x) {
return Math.sqrt(x);
}
static power(base, exponent) {
return Math.pow(base, exponent);
}
}
console.log(AdvancedMathHelper.PI); // Output: 3.14159 (heredado)
console.log(AdvancedMathHelper.EULER); // Output: 2.71828
console.log(AdvancedMathHelper.square(4)); // Output: 16 (heredado)
console.log(AdvancedMathHelper.power(2, 3)); // Output: 8
Composición vs. Herencia
Aunque la herencia es poderosa, en JavaScript a menudo se prefiere la composición sobre la herencia para relaciones complejas:
// Enfoque de composición
class Engine {
start() {
return "Engine started";
}
stop() {
return "Engine stopped";
}
}
class Radio {
turnOn() {
return "Radio turned on";
}
turnOff() {
return "Radio turned off";
}
changeStation(station) {
return `Changed to station ${station}`;
}
}
class Car {
constructor(make, model) {
this.make = make;
this.model = model;
this.engine = new Engine();
this.radio = new Radio();
}
startCar() {
return `${this.make} ${this.model}: ${this.engine.start()}`;
}
stopCar() {
return `${this.make} ${this.model}: ${this.engine.stop()}`;
}
turnOnRadio() {
return this.radio.turnOn();
}
changeRadioStation(station) {
return this.radio.changeStation(station);
}
}
const myCar = new Car('Toyota', 'Corolla');
console.log(myCar.startCar()); // Output: Toyota Corolla: Engine started
console.log(myCar.turnOnRadio()); // Output: Radio turned on
En este ejemplo, Car
compone objetos Engine
y Radio
en lugar de heredar de ellos, lo que proporciona mayor flexibilidad y evita problemas asociados con jerarquías de herencia profundas.
Herencia múltiple y mixins
JavaScript no soporta herencia múltiple directamente, pero podemos simularla mediante mixins:
// Definimos mixins como objetos con métodos
const swimMixin = {
swim() {
return `${this.name} is swimming`;
},
dive() {
return `${this.name} is diving underwater`;
}
};
const flyMixin = {
fly() {
return `${this.name} is flying`;
},
soar() {
return `${this.name} is soaring high in the sky`;
}
};
// Función para aplicar mixins a una clase
function applyMixins(targetClass, ...mixins) {
mixins.forEach(mixin => {
Object.getOwnPropertyNames(mixin).forEach(name => {
targetClass.prototype[name] = mixin[name];
});
});
}
// Clase base
class Bird extends Animal {
constructor(name, wingSpan) {
super(name);
this.wingSpan = wingSpan;
}
makeSound() {
return `${this.name} chirps`;
}
}
// Aplicamos mixins a la clase Bird
applyMixins(Bird, flyMixin);
// Clase que necesita múltiples comportamientos
class Duck extends Bird {
constructor(name) {
super(name, 30);
}
makeSound() {
return `${this.name} quacks`;
}
}
// Aplicamos el mixin de natación a Duck
applyMixins(Duck, swimMixin);
const donald = new Duck('Donald');
console.log(donald.makeSound()); // Output: Donald quacks
console.log(donald.fly()); // Output: Donald is flying
console.log(donald.swim()); // Output: Donald is swimming
Los mixins permiten compartir funcionalidad entre clases sin necesidad de establecer relaciones de herencia, lo que resulta especialmente útil en JavaScript donde cada objeto solo puede tener un prototipo directo.
Herencia y el contexto de this
Es importante entender cómo funciona el contexto de this
en la herencia:
class Logger {
log(message) {
console.log(`[LOG] ${message}`);
}
info(message) {
this.log(`INFO: ${message}`);
}
error(message) {
this.log(`ERROR: ${message}`);
}
}
class DateLogger extends Logger {
log(message) {
const timestamp = new Date().toISOString();
super.log(`[${timestamp}] ${message}`);
}
}
const logger = new DateLogger();
logger.info("Application started");
// Output: [LOG] [2023-05-15T14:30:45.123Z] INFO: Application started
Cuando info()
se llama en una instancia de DateLogger
, el this
dentro de info()
se refiere a esa instancia, por lo que this.log()
invoca la versión sobrescrita de log()
en DateLogger
, no la versión original en Logger
.
La herencia y el polimorfismo son herramientas poderosas que permiten crear código más organizado, reutilizable y mantenible. Sin embargo, es importante utilizarlas juiciosamente, considerando alternativas como la composición cuando sea apropiado para evitar jerarquías de clases demasiado complejas o rígidas.
Patrones de diseño y técnicas avanzadas: Encapsulación, propiedades privadas y métodos de composición
La programación orientada a objetos en JavaScript moderno ofrece técnicas avanzadas que permiten crear código más robusto, mantenible y escalable. En esta sección exploraremos patrones de diseño esenciales y mecanismos de encapsulación que elevan la calidad del código orientado a objetos.
Encapsulación efectiva
La encapsulación es un principio fundamental que consiste en ocultar los detalles internos de implementación y exponer solo lo necesario. JavaScript ofrece varias técnicas para lograr este objetivo:
Campos privados con # (ES2022+)
La forma más moderna y recomendada de implementar encapsulación es mediante el uso de campos privados con el prefijo #
:
class BankAccount {
#balance = 0;
#transactionHistory = [];
constructor(initialBalance = 0) {
if (initialBalance > 0) {
this.deposit(initialBalance);
}
}
deposit(amount) {
if (amount <= 0) throw new Error("Deposit amount must be positive");
this.#balance += amount;
this.#recordTransaction("deposit", amount);
return this.#balance;
}
withdraw(amount) {
if (amount <= 0) throw new Error("Withdrawal amount must be positive");
if (amount > this.#balance) throw new Error("Insufficient funds");
this.#balance -= amount;
this.#recordTransaction("withdrawal", amount);
return this.#balance;
}
#recordTransaction(type, amount) {
this.#transactionHistory.push({
type,
amount,
date: new Date(),
balance: this.#balance
});
}
getBalance() {
return this.#balance;
}
getTransactionHistory() {
// Devolvemos una copia para evitar modificaciones externas
return [...this.#transactionHistory];
}
}
const account = new BankAccount(1000);
console.log(account.getBalance()); // Output: 1000
account.deposit(500);
account.withdraw(200);
console.log(account.getBalance()); // Output: 1300
console.log(account.getTransactionHistory().length); // Output: 3
// Estos intentos generarían errores
// console.log(account.#balance); // Error: Private field '#balance' must be declared in an enclosing class
// account.#recordTransaction("hack", 1000); // Error: Private method is not accessible
Los campos privados con #
proporcionan un verdadero encapsulamiento a nivel de lenguaje, impidiendo completamente el acceso desde fuera de la clase.
Closures para encapsulación (patrón de módulo)
Antes de la introducción de campos privados, una técnica común era utilizar closures para crear variables privadas:
function createCounter() {
// Variable privada mediante closure
let count = 0;
return {
increment() {
return ++count;
},
decrement() {
return --count;
},
getValue() {
return count;
},
reset() {
count = 0;
return count;
}
};
}
const counter = createCounter();
console.log(counter.getValue()); // Output: 0
counter.increment();
counter.increment();
console.log(counter.getValue()); // Output: 2
counter.reset();
console.log(counter.getValue()); // Output: 0
// La variable count no es accesible directamente
// console.log(counter.count); // Output: undefined
Este patrón, conocido como patrón de módulo, sigue siendo útil en ciertos contextos y para compatibilidad con navegadores antiguos.
Convención de nombres con guion bajo
Aunque no proporciona una verdadera protección, la convención de nombres con guion bajo sigue siendo común para indicar que una propiedad debe tratarse como privada:
class User {
constructor(username, email) {
this._username = username;
this._email = email;
this._lastLoginTimestamp = null;
}
login() {
this._lastLoginTimestamp = Date.now();
return `${this._username} logged in successfully`;
}
getLastLoginDate() {
if (!this._lastLoginTimestamp) return "Never logged in";
return new Date(this._lastLoginTimestamp).toLocaleString();
}
}
Esta convención es puramente semántica y depende de la disciplina del equipo de desarrollo.
Patrones de diseño comunes en JavaScript
Los patrones de diseño son soluciones probadas a problemas recurrentes. Veamos algunos patrones particularmente útiles en JavaScript:
Patrón Singleton
El patrón Singleton garantiza que una clase tenga una única instancia y proporciona un punto de acceso global a ella:
class DatabaseConnection {
static #instance = null;
#connectionString;
#isConnected = false;
constructor(connectionString) {
if (DatabaseConnection.#instance) {
return DatabaseConnection.#instance;
}
this.#connectionString = connectionString;
DatabaseConnection.#instance = this;
}
connect() {
if (this.#isConnected) {
return "Already connected";
}
// Lógica para conectar a la base de datos
console.log(`Connecting to: ${this.#connectionString}`);
this.#isConnected = true;
return "Connection established";
}
disconnect() {
if (!this.#isConnected) {
return "Not connected";
}
// Lógica para desconectar
this.#isConnected = false;
return "Disconnected successfully";
}
executeQuery(query) {
if (!this.#isConnected) {
throw new Error("Must connect before executing queries");
}
console.log(`Executing: ${query}`);
return `Results for: ${query}`;
}
}
// Ambas variables referencian la misma instancia
const db1 = new DatabaseConnection("mongodb://localhost:27017/myapp");
const db2 = new DatabaseConnection("postgres://user:pass@localhost:5432/db");
console.log(db1 === db2); // Output: true
db1.connect();
console.log(db2.executeQuery("SELECT * FROM users")); // Funciona porque es la misma instancia
Este patrón es útil para recursos compartidos como conexiones a bases de datos, configuraciones globales o caches.
Patrón Factory
El patrón Factory encapsula la lógica de creación de objetos, permitiendo crear diferentes tipos de objetos basados en parámetros:
// Clases de productos
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
this.type = "rectangle";
}
getArea() {
return this.width * this.height;
}
getPerimeter() {
return 2 * (this.width + this.height);
}
}
class Circle {
constructor(radius) {
this.radius = radius;
this.type = "circle";
}
getArea() {
return Math.PI * this.radius * this.radius;
}
getPerimeter() {
return 2 * Math.PI * this.radius;
}
}
class Triangle {
constructor(a, b, c, height) {
this.a = a;
this.b = b;
this.c = c;
this.height = height;
this.type = "triangle";
}
getArea() {
return (this.b * this.height) / 2;
}
getPerimeter() {
return this.a + this.b + this.c;
}
}
// Factory
class ShapeFactory {
createShape(shapeType, ...args) {
switch(shapeType.toLowerCase()) {
case "rectangle":
return new Rectangle(args[0], args[1]);
case "circle":
return new Circle(args[0]);
case "triangle":
return new Triangle(args[0], args[1], args[2], args[3]);
default:
throw new Error(`Shape type "${shapeType}" not supported`);
}
}
}
// Uso
const factory = new ShapeFactory();
const shapes = [
factory.createShape("rectangle", 10, 5),
factory.createShape("circle", 7),
factory.createShape("triangle", 5, 8, 7, 4)
];
shapes.forEach(shape => {
console.log(`${shape.type} - Area: ${shape.getArea()}, Perimeter: ${shape.getPerimeter()}`);
});
Este patrón facilita la creación de objetos complejos y permite añadir nuevos tipos sin modificar el código cliente.
Patrón Observer
El patrón Observer establece una relación uno-a-muchos entre objetos, de modo que cuando un objeto cambia de estado, todos sus dependientes son notificados:
class EventEmitter {
#events = new Map();
on(eventName, listener) {
if (!this.#events.has(eventName)) {
this.#events.set(eventName, []);
}
this.#events.get(eventName).push(listener);
return this; // Para encadenamiento
}
off(eventName, listener) {
if (!this.#events.has(eventName)) return this;
const listeners = this.#events.get(eventName);
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
return this;
}
emit(eventName, ...args) {
if (!this.#events.has(eventName)) return false;
const listeners = this.#events.get(eventName);
listeners.forEach(listener => {
listener(...args);
});
return true;
}
once(eventName, listener) {
const onceWrapper = (...args) => {
listener(...args);
this.off(eventName, onceWrapper);
};
return this.on(eventName, onceWrapper);
}
}
// Ejemplo de uso
class ShoppingCart extends EventEmitter {
#items = [];
addItem(item) {
this.#items.push(item);
this.emit("itemAdded", item);
this.emit("cartUpdated", this.#items);
}
removeItem(index) {
const removedItem = this.#items.splice(index, 1)[0];
this.emit("itemRemoved", removedItem, index);
this.emit("cartUpdated", this.#items);
}
getItems() {
return [...this.#items]; // Devolvemos una copia
}
}
// Uso
const cart = new ShoppingCart();
cart.on("itemAdded", item => {
console.log(`Added: ${item.name} - $${item.price}`);
});
cart.on("cartUpdated", items => {
console.log(`Cart now has ${items.length} items, total: $${
items.reduce((sum, item) => sum + item.price, 0).toFixed(2)
}`);
});
cart.addItem({ name: "Laptop", price: 999.99 });
cart.addItem({ name: "Headphones", price: 149.50 });
cart.removeItem(0);
Este patrón es la base de muchas implementaciones de eventos en JavaScript, incluido el sistema de eventos del DOM.
Composición sobre herencia
Aunque la herencia es útil, la composición ofrece mayor flexibilidad y evita problemas como la herencia múltiple o jerarquías demasiado profundas:
// Componentes reutilizables
class Database {
constructor(connectionString) {
this.connectionString = connectionString;
}
query(sql) {
console.log(`Executing query: ${sql}`);
return [`Result 1`, `Result 2`];
}
}
class Logger {
log(message) {
console.log(`[${new Date().toISOString()}] ${message}`);
}
error(message) {
console.error(`[ERROR] ${message}`);
}
}
class EmailService {
sendEmail(to, subject, body) {
console.log(`Sending email to ${to}: ${subject}`);
return true;
}
}
// Clase que utiliza composición
class UserService {
constructor() {
this.db = new Database("users_db_connection");
this.logger = new Logger();
this.emailService = new EmailService();
}
createUser(userData) {
try {
this.logger.log(`Creating user: ${userData.email}`);
const result = this.db.query(`INSERT INTO users VALUES (${JSON.stringify(userData)})`);
this.emailService.sendEmail(
userData.email,
"Welcome to our platform",
"Your account has been created successfully!"
);
return { success: true, userId: 123 };
} catch (error) {
this.logger.error(`Failed to create user: ${error.message}`);
return { success: false, error: error.message };
}
}
getUserById(id) {
this.logger.log(`Fetching user with ID: ${id}`);
return this.db.query(`SELECT * FROM users WHERE id = ${id}`)[0];
}
}
// Uso
const userService = new UserService();
const newUser = {
name: "John Doe",
email: "john@example.com",
role: "user"
};
const result = userService.createUser(newUser);
console.log(result);
En este ejemplo, UserService
compone funcionalidades de diferentes clases en lugar de heredar de ellas, lo que proporciona mayor flexibilidad y facilita los cambios futuros.
Mixins para compartir comportamiento
Los mixins son una forma de compartir métodos entre clases sin usar herencia:
// Definición de mixins
const timestampMixin = {
getCreatedAt() {
return this._createdAt;
},
getUpdatedAt() {
return this._updatedAt;
},
markAsUpdated() {
this._updatedAt = new Date();
}
};
const serializableMixin = {
toJSON() {
const json = {};
// Convertimos solo propiedades no privadas (sin _)
Object.keys(this).forEach(key => {
if (!key.startsWith('_')) {
json[key] = this[key];
}
});
return json;
},
fromJSON(json) {
Object.keys(json).forEach(key => {
this[key] = json[key];
});
return this;
}
};
// Función para aplicar mixins
function applyMixins(targetClass, ...mixins) {
mixins.forEach(mixin => {
Object.getOwnPropertyNames(mixin).forEach(name => {
Object.defineProperty(
targetClass.prototype,
name,
Object.getOwnPropertyDescriptor(mixin, name)
);
});
});
}
// Clase que utiliza mixins
class Task {
constructor(title, description) {
this.title = title;
this.description = description;
this.completed = false;
this._createdAt = new Date();
this._updatedAt = new Date();
}
complete() {
this.completed = true;
this.markAsUpdated(); // Método del mixin
}
}
// Aplicamos los mixins
applyMixins(Task, timestampMixin, serializableMixin);
// Uso
const task = new Task("Learn JavaScript", "Study advanced OOP concepts");
console.log(task.getCreatedAt()); // Método del mixin timestampMixin
task.complete();
console.log(task.getUpdatedAt()); // La fecha se actualizó al llamar a markAsUpdated()
const json = task.toJSON(); // Método del mixin serializableMixin
console.log(json); // No incluye propiedades que empiezan con _
Los mixins son especialmente útiles para compartir comportamientos transversales como logging, serialización o validación.
Propiedades computadas y métodos de acceso
Las propiedades computadas permiten definir valores que se calculan dinámicamente:
class Product {
#price;
#taxRate = 0.21; // 21% IVA
constructor(name, price) {
this.name = name;
this.#price = price;
}
get price() {
return this.#price;
}
set price(newPrice) {
if (newPrice < 0) throw new Error("Price cannot be negative");
this.#price = newPrice;
}
get taxRate() {
return this.#taxRate;
}
set taxRate(rate) {
if (rate < 0 || rate > 1) throw new Error("Tax rate must be between 0 and 1");
this.#taxRate = rate;
}
// Propiedad computada
get priceWithTax() {
return this.#price * (1 + this.#taxRate);
}
// Propiedad computada con formato
get formattedPrice() {
return `$${this.#price.toFixed(2)}`;
}
get formattedPriceWithTax() {
return `$${this.priceWithTax.toFixed(2)}`;
}
}
const laptop = new Product("MacBook Pro", 1299);
console.log(laptop.price); // Output: 1299
console.log(laptop.priceWithTax); // Output: 1571.79
console.log(laptop.formattedPriceWithTax); // Output: $1571.79
laptop.price = 1399;
console.log(laptop.priceWithTax); // Output: 1692.79
laptop.taxRate = 0.10; // 10%
console.log(laptop.priceWithTax); // Output: 1538.90
Los getters y setters permiten validar datos y calcular valores derivados de forma transparente para el usuario de la clase.
Métodos de cadena (Method Chaining)
El encadenamiento de métodos mejora la legibilidad del código permitiendo llamar a múltiples métodos en secuencia:
class QueryBuilder {
#table = '';
#conditions = [];
#fields = ['*'];
#orderBy = '';
#limit = null;
from(table) {
this.#table = table;
return this; // Retornamos this para permitir encadenamiento
}
select(...fields) {
if (fields.length > 0) {
this.#fields = fields;
}
return this;
}
where(condition) {
this.#conditions.push(condition);
return this;
}
orderBy(field, direction = 'ASC') {
this.#orderBy = `${field} ${direction}`;
return this;
}
limit(count) {
this.#limit = count;
return this;
}
build() {
let query = `SELECT ${this.#fields.join(', ')} FROM ${this.#table}`;
if (this.#conditions.length > 0) {
query += ` WHERE ${this.#conditions.join(' AND ')}`;
}
if (this.#orderBy) {
query += ` ORDER BY ${this.#orderBy}`;
}
if (this.#limit !== null) {
query += ` LIMIT ${this.#limit}`;
}
return query;
}
}
// Uso con encadenamiento de métodos
const query = new QueryBuilder()
.select('id', 'name', 'email')
.from('users')
.where('status = "active"')
.where('last_login > "2023-01-01"')
.orderBy('name')
.limit(10)
.build();
console.log(query);
// Output: SELECT id, name, email FROM users WHERE status = "active" AND last_login > "2023-01-01" ORDER BY name ASC LIMIT 10
Este patrón es común en bibliotecas de JavaScript como jQuery, Lodash o frameworks de bases de datos como Knex.js.
Inmutabilidad y objetos congelados
La inmutabilidad es un concepto importante en programación funcional que también se puede aplicar en POO:
class ImmutablePoint {
#x;
#y;
constructor(x, y) {
this.#x = x;
this.#y = y;
// Congelamos la instancia para prevenir la adición de propiedades
Object.freeze(this);
}
get x() {
return this.#x;
}
get y() {
return this.#y;
}
// En lugar de modificar el objeto, creamos uno nuevo
translate(dx, dy) {
return new ImmutablePoint(this.#x + dx, this.#y + dy);
}
scale(factor) {
return new ImmutablePoint(this.#x * factor, this.#y * factor);
}
toString() {
return `(${this.#x}, ${this.#y})`;
}
}
const p1 = new ImmutablePoint(10, 20);
console.log(p1.toString()); // Output: (10, 20)
// Las operaciones devuelven nuevos objetos
const p2 = p1.translate(5, -3);
console.log(p1.toString()); // Output: (10, 20) - el original no cambia
console.log(p2.toString()); // Output: (15, 17)
// No podemos añadir propiedades
p1.z = 30;
console.log(p1.z); // Output: undefined
La inmutabilidad facilita el razonamiento sobre el código, especialmente en entornos concurrentes o aplicaciones complejas.
Delegación de comportamiento
La delegación es un patrón donde un objeto delega operaciones a otro objeto:
class InputValidator {
static validate(value, rules) {
const errors = [];
if (rules.required && !value) {
errors.push("This field is required");
}
if (rules.minLength && value.length < rules.minLength) {
errors.push(`Minimum length is ${rules.minLength} characters`);
}
if (rules.maxLength && value.length > rules.maxLength) {
errors.push(`Maximum length is ${rules.maxLength} characters`);
}
if (rules.pattern && !rules.pattern.test(value)) {
errors.push(rules.patternMessage || "Invalid format");
}
return errors;
}
}
class FormField {
#value = "";
#validator;
#validationRules;
constructor(name, validationRules = {}) {
this.name = name;
this.#validationRules = validationRules;
this.#validator = InputValidator; // Delegamos la validación
}
setValue(value) {
this.#value = value;
return this;
}
getValue() {
return this.#value;
}
validate() {
// Delegamos la validación a otra clase
return this.#validator.validate(this.#value, this.#validationRules);
}
}
// Uso
const emailField = new FormField("email", {
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
patternMessage: "Please enter a valid email address"
});
emailField.setValue("invalid-email");
const errors = emailField.validate();
console.log(errors); // Output: ["Please enter a valid email address"]
emailField.setValue("user@example.com");
console.log(emailField.validate()); // Output: [] (sin errores)
La delegación permite separar responsabilidades y reutilizar código sin necesidad de herencia.
Los patrones de diseño y técnicas avanzadas presentados en esta sección proporcionan herramientas poderosas para crear código JavaScript orientado a objetos que sea mantenible, extensible y robusto. La elección entre herencia, composición, mixins o delegación dependerá del contexto específico y de los requisitos del sistema que estés desarrollando.
Ejercicios de esta lección Clases y objetos
Evalúa tus conocimientos de esta lección Clases y objetos con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.
Funciones flecha
Polimorfismo
Array
Transformación con map()
Gestor de tareas con JavaScript
Manipulación DOM
Funciones
Funciones flecha
Async / Await
Creación y uso de variables
Excepciones
Promises
Funciones cierre (closure)
Herencia
Herencia
Estructuras de control
Selección de elementos DOM
Modificación de elementos DOM
Filtrado con filter() y find()
Funciones cierre (closure)
Funciones
Mapas con Map
Reducción con reduce()
Callbacks
Manipulación DOM
Promises
Async / Await
Eventos del DOM
Async / Await
Promises
Filtrado con filter() y find()
Callbacks
Creación de clases y objetos Restaurante
Reducción con reduce()
Filtrado con filter() y find()
Reducción con reduce()
Conjuntos con Set
Herencia de clases
Eventos del DOM
Clases y objetos
Modificación de elementos DOM
Mapas con Map
Introducción a JavaScript
Funciones
Tipos de datos
Clases y objetos
Array
Conjuntos con Set
Array
Encapsulación
Clases y objetos
Uso de operadores
Uso de operadores
Estructuras de control
Excepciones
Transformación con map()
Funciones flecha
Selección de elementos DOM
Encapsulación
Mapas con Map
Creación y uso de variables
Polimorfismo
Tipos de datos
Estructuras de control
Todas las lecciones de JavaScript
Accede a todas las lecciones de JavaScript y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.
Introducción A Javascript
Introducción Y Entorno
Tipos De Datos
Sintaxis
Variables
Sintaxis
Operadores
Sintaxis
Estructuras De Control
Sintaxis
Funciones
Sintaxis
Funciones Cierre (Closure)
Sintaxis
Arrays Y Métodos
Estructuras De Datos
Conjuntos Con Set
Estructuras De Datos
Mapas Con Map
Estructuras De Datos
Funciones Flecha
Programación Funcional
Filtrado Con Filter() Y Find()
Programación Funcional
Transformación Con Map()
Programación Funcional
Reducción Con Reduce()
Programación Funcional
Clases Y Objetos
Programación Orientada A Objetos
Excepciones
Programación Orientada A Objetos
Encapsulación
Programación Orientada A Objetos
Herencia
Programación Orientada A Objetos
Polimorfismo
Programación Orientada A Objetos
Manipulación Dom
Dom
Selección De Elementos Dom
Dom
Modificación De Elementos Dom
Dom
Eventos Del Dom
Dom
Callbacks
Programación Asíncrona
Promises
Programación Asíncrona
Async / Await
Programación Asíncrona
Certificados de superación de JavaScript
Supera todos los ejercicios de programación del curso de JavaScript y obtén certificados de superación para mejorar tu currículum y tu empleabilidad.
En esta lección
Objetivos de aprendizaje de esta lección
- Comprender el concepto de clases y objetos en JavaScript.
- Conocer la sintaxis para declarar una clase utilizando la palabra clave
class
. - Aprender a definir un constructor dentro de una clase para inicializar propiedades del objeto.
- Entender cómo se definen los métodos dentro de una clase y cómo se invocan desde los objetos.
- Saber cómo crear objetos a partir de una clase utilizando la palabra clave
new
. - Comprender la diferencia entre campos estáticos y no estáticos en las clases.
- Conocer la importancia de encapsulación en la programación orientada a objetos.
- Aprender a acceder a las propiedades y métodos de un objeto utilizando la notación de punto.
- Saber cómo cada objeto creado a partir de una clase es independiente y tiene su propio conjunto de propiedades y métodos.
- Comprender cómo los objetos permiten organizar el código de manera estructurada y modular en la programación.