Los patrones de diseño son las mejores prácticas de software utilizadas por los desarrolladores de software para resolver problemas recurrentes en el desarrollo de software. No están relacionados con el código, sino que son un modelo que se utiliza para diseñar una solución para un sinfín de casos de uso.
Demos un pequeño repaso de post anteriores donde hemos hablado de patrones de diseño de forma más global
Patrones de creación
Son patrones que pertenecen a la creación de objetos y a la instanciación de clases que ayudan a la reutilización de un código existente. Los principales patrones de creación son Factory Method, Abstract Factory, Builder, Prototype y Singleton.
Patrones estructurales
Son patrones que ayudan a simplificar el diseño identificando una forma de crear relaciones entre entidades como objetos y clases. Se ocupan de cómo las clases y los objetos pueden ser ensamblados en estructuras más grandes. Algunos de los patrones de diseño que entran en esta categoría son: Adapter, Decorator y Proxy.
Patrones de comportamiento
Son patrones que se ocupan de las responsabilidades entre los objetos para ayudar a aumentar la flexibilidad en la realización de la comunicación. Algunos de estos patrones son Observer, Memento e Iterator.
Si quieres conocer más acerca de estos patrones te dejo el video de una persona que admiramos mucho por este blog y es de Hector de León, quien para mí lo explica de manera práctica, dinámica y sencilla.
Singleton
El patrón Singleton es uno de los patrones de diseño más comunes. Según Refactoring Guru. Singleton es un patrón de diseño de creación que le permite asegurar que una clase tiene sólo una instancia, mientras que proporciona un punto de acceso global a esta instancia.
En un patrón Singleton, una clase u objeto sólo puede ser instanciado una vez, y cualquier llamada repetida al objeto o clase devolverá la misma instancia. Esta única instancia es lo que se denomina Singleton.
Un buen ejemplo de un Singleton en las aplicaciones Frontend es el almacén global en las bibliotecas populares de gestión de estado como Vuex y Redux. El almacén global es un singleton ya que es accesible en varias partes de la aplicación y sólo podemos tener una instancia de él.
Un singleton también se puede utilizar para implementar un Logger para gestionar los registros en una aplicación. El logger es una gran elección como singleton porque siempre querremos tener todos nuestros logs en un solo lugar en caso de que necesitemos rastrearlos.
Veamos cómo podemos implementar un Logger con un Singleton en TypeScript
class Logger {
private static instance: Logger;
private logStore:any = []
private constructor() { }
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
public log(item: any): void{
this.logStore.push(item)
}
public getLog():void{
console.log(this.logStore)
}
}
En el ejemplo anterior hemos creado un logger para registrar elementos en una aplicación. El constructor es privado para evitar que se cree una nueva instancia de la clase con la palabra clave new. El método getInstance
sólo creará una nueva instancia si no hay una instancia existente, obedeciendo así el principio de singleton.
Veamos cómo podemos utilizar el singleton creado
const useLogger = Logger.getInstance()
useLogger.log('Log 1')
const anotherLogger = Logger.getInstance()
anotherLogger.log('Log 2')
useLogger.getLog()
Si ejecutas el programa de arriba te darás cuenta de que anotherLogger
no creó otra instancia sino que utilizó la instancia existente.
Por muy comunes que sean los singletons, tienden a ser considerados como un anti-patrón en algunos círculos debido a su uso excesivo, y al hecho de que introducen un estado global en una aplicación, así que antes de usar un singleton por favor considera si ese será el mejor caso de uso para lo que estás tratando de implementar.
Observador
El patrón Observer es bastante común en TypeScript. Especifica una relación de uno a muchos entre un objeto y sus dependientes, de manera que cuando el objeto cambia de estado, notifica a los demás objetos que dependen de él el cambio de estado. El patrón observador también es común en los principales frameworks y librerías de frontend, ya que todo el concepto de actualización de partes de una interfaz de usuario en respuesta a eventos proviene de él.
En TypeScript, el patrón observador proporciona una manera para que los componentes de la interfaz de usuario se suscriban a los cambios en un objeto sin acoplamiento directo a las clases.
Un ejemplo perfecto del patrón observador es una lista de correo. Si como usuario estás suscrito a una lista de correo, ésta te envía mensajes para que no tengas que comprobar manualmente si hay un nuevo mensaje del asunto.
Veamos cómo implementar esto en TypeScript
interface NotificationObserver {
onMessage(message: Message): string;
}
interface Notify {
sendMessage(message: Message): any;
}
class Message {
message: string;
constructor(message: string) {
this.message = message;
}
getMessage(): string {
return `${this.message} from publication`;
}
}
class User implements NotificationObserver {
element: Element;
constructor(element: Element) {
this.element = element;
}
onMessage(message: Message) {
return (this.element.innerHTML += `<li>you have a new message - ${message.getMessage()}</li>`);
}
}
class MailingList implements Notify {
protected observers: User[] = [];
notify(message: Message) {
this.observers.forEach((observer) => {
observer.onMessage(message);
});
}
subscribe(observer: User) {
this.observers.push(observer);
}
unsubscribe(observer: User) {
this.observers = this.observers.filter(
(subscriber) => subscriber !== observer
);
}
sendMessage(message: Message) {
this.notify(message);
}
}
const messageInput: Element = document.querySelector(".message-input");
const user1: Element = document.querySelector(".user1-messages");
const user2: Element = document.querySelector(".user2-messages");
const u1 = new User(user1);
const u2 = new User(user2);
const subscribeU1: Element = document.querySelector(".user1-subscribe");
const subscribeU2: Element = document.querySelector(".user2-subscribe");
const unSubscribeU1: Element = document.querySelector(".user1-unsubscribe");
const unSubscribeU2: Element = document.querySelector(".user2-unsubscribe");
const sendBtn: Element = document.querySelector(".send-btn");
const mailingList = new MailingList();
mailingList.subscribe(u1);
mailingList.subscribe(u2);
subscribeU1.addEventListener("click", () => {
mailingList.subscribe(u1);
});
subscribeU2.addEventListener("click", () => {
mailingList.subscribe(u2);
});
unSubscribeU1.addEventListener("click", () => {
mailingList.unsubscribe(u1);
});
unSubscribeU2.addEventListener("click", () => {
mailingList.unsubscribe(u2);
});
sendBtn.addEventListener("click", () => {
mailingList.sendMessage(new Message(messageInput.value));
});
En el ejemplo anterior, la interfaz Notify contiene un método para enviar mensajes a los suscriptores.
El NotificationObserver
comprueba si hay algún mensaje y avisa a los usuarios suscritos. La clase Message mantiene el estado de los mensajes y notifica a los usuarios suscritos cada vez que el estado de los mensajes cambia, siguiendo así el patrón del observador. Así, los usuarios pueden elegir suscribirse o desuscribirse de los mensajes. Un ejemplo completo del código está disponible aquí.
Reproducción de sesiones de código abierto
OpenReplay es una suite de reproducción de sesiones de código abierto que le permite ver lo que los usuarios hacen en su aplicación web, ayudándole a solucionar los problemas más rápidamente. OpenReplay es auto-alojado para el control total de sus datos.
Método de fábrica (Factory Method)
El método de fábrica es un patrón de creación que se ocupa de la creación de objetos. Ayuda a encapsular un objeto del código que depende de él. Esto puede ser confuso, así que déjame usar una analogía para explicarlo.
Imagina que tienes una planta de vehículos que produce diferentes vehículos y empiezas produciendo berlinas pero más tarde decides entrar en la producción de camiones, probablemente tendrás que crear un sistema de producción duplicado para los camiones, ahora imaginemos que añades SUVs y minivans a la mezcla. En este punto, el sistema de producción se vuelve desordenado y repetitivo.
En un patrón de fábrica, podemos abstraer el comportamiento común entre los vehículos, como la forma en que se hace el vehículo, en un objeto de interfaz separado llamado Vehículo, y luego permitir que las diferentes implementaciones ejecuten este comportamiento común en sus formas únicas.
En el frontend, un patrón de método de fábrica nos permite abstraer el comportamiento común entre los componentes, imaginemos un componente Toast que tiene un comportamiento diferente en el Móvil y en el Escritorio podemos utilizar TypeScript para crear una interfaz Toast que perfile el comportamiento general del componente Toast
interface Toast {
template: string;
title: string;
body: string;
position: string;
visible: boolean;
hide(): void;
render(title: string, body: string, duration: number, position: string): string
}
Después de crear una interfaz común que contiene el comportamiento general del componente toast, el siguiente paso es crear las diferentes implementaciones (móvil y de escritorio) de la interfaz toast.
class MobileToast implements Toast {
title: string;
body: string;
duration: number;
visible = false;
position = "center"
template = `
<div class="mobile-toast">
<div class="mobile-toast--header">
<h2>${this.title}</h2>
<span>${this.duration}</span>
</div>
<hr/>
<p class="mobile-toast--body">
${this.message}
</p>
</div>
`;
hide() {
this.visible = false;
}
render(title: string, body: string, duration: number, position: string) {
this.title = title,
this.body = body
this.visible = false
this.duration = duration
this.position = "center"
return this.template
}
}
class DesktopToast implements Toast {
title: string;
body: string;
position: string
visible = false;
duration: number;
template = `
<div class="desktop-toast">
<div class="desktop-toast--header">
<h2>${this.title}</h2>
<span>${this.duration}</span>
</div>
<hr/>
<p class="mobile-toast--body">
${this.message}
</p>
</div>
`;
hide() {
this.visible = false;
}
render(title: string, body: string, duration: number, position: string) {
this.title = title,
this.body = body
this.visible = true
this.duration = duration
this.position = position
return this.template
}
}
Como puedes ver en el código, el Móvil y el Escritorio tienen implementaciones ligeramente diferentes, pero mantienen el comportamiento base de la interfaz del tostado. El siguiente paso es crear una clase fábrica con la que el código del Cliente trabaja sin tener que preocuparte por las diferentes implementaciones que hemos creado.
class ToastFactory {
createToast(type: 'mobile' | 'desktop'): Toast {
if (type === 'mobile') {
return new MobileToast();
} else {
return new DesktopToast();
}
}
}
El código de la fábrica devolverá la implementación correcta del componente Toast en función del tipo que se le pase como parámetro. Llegados a este punto, podemos escribir nuestro código cliente para comunicarnos con la ToastFactory
.
class App {
toast: Toast;
factory = new ToastFactory();
render() {
this.toast = this.factory.createToast(isMobile() ? 'mobile' : 'desktop');
if (this.toast.visible) {
this.toast.render('Toast Header', 'Toast body');
}
}
}
Conclusión
Hemos visto algunos patrones de diseño y sus implementaciones en TypeScript. Como se mencionó anteriormente, estos patrones de diseño proporcionan un modelo a seguir cuando se enfrentan a problemas recurrentes en el desarrollo de software. Los ejemplos en el artículo deben ser utilizados como una guía para empezar.