Patrones de diseño en JavaScript y Node JS

Los patrones de diseño son soluciones probadas a problemas típicos y recurrentes que nos podemos encontrar a la hora de desarrollar una aplicación.

Ante un problema que nos podamos encontrar en el desarrollo de nuestra aplicación, si podemos solucionarlo con un patrón, lo aplicaremos, en lugar de reinventar la rueda.

Algunas ventajas de aplicar patrones de diseño:

  • Simplificar los problemas
  • Estructurar mejor el código
  • Hacer el código más predecible

Addy Osmani ha hecho una clasificación de los diferentes patrones en tres grupos:

  • Patrones creacionales
  • Patrones de estructura
  • Patrones de comportamiento

Patrones creacionales

Este tipo de patrones nos ayudan a crear instancias de objetos.

Patrón Constructor

Nos permite crear objetos en base a una clase. Es común en lenguajes de programación orientados a objetos.

En javascript con ECMAScript 6 podemos aplicar este patrón con la palabra reservada new. De esta manera podemos crear nuevas instancias de un objeto las cuales tendrán las propiedades y funciones de la clase que estamos instanciando.

class MyClass {
    constructor(param1) {
        this.prop = param1;
        this.myMethod = () => {
            console.log(`Value of prop: ${this.prop}`);
        }
    }
}

const myObject = new MyClass('hola');
myObject.myMethod();
// output: Value of prop: hola

Patrón Constructor con prototipo

Difiere del patrón constructor anterior en que los métodos o propiedades que asignemos al prototipo no se copiarán en los objetos que se instancien. Es decir los métodos y propiedades se compartirán entre todas las instancias.

Una ventaja de el patrón prototipo es que utiliza menos espacio en memoria.

En JavaScript el concepto de clase de la POO no existe como tal. Lo que existen son los prototipos. Un prototipo es un objeto especial que poseen todos los objetos en el cual se almacenan las propiedades. Cuando se accede a una propiedad de un objeto (objeto.propiedad) se busca en el propio objeto, si no existe, se busca en su prototipo y así continúa en cadena.

En el ejemplo anterior sacaremos el método myMethod del constructor para crear un método del prototipo. Podemos comprobar como el método myMethodno pertenece a la instancia myObject.

class MyClass {
    constructor(param1) {
        this.prop = param1;
    }
    myMethod() {
        // método de prototipo
    }
}

const myObject = new MyClass('hola');
console.log(`myMethod ${myObject.hasOwnProperty('myMethod') ? 'SI' : 'NO'} pertenece a la instancia myObject`);
// output: myMethod NO pertenece a la instancia myObject

Ejemplos prácticos de patrón Constructor con prototipo

Podemos extender clases existentes (funciones con prototipo) para añadir nuevas funcionalidades.

Extender la clase Object para añadir la función log que nos permitirá imprimir los valores de un objeto:

Object.prototype.log = function () {
    console.log(this);
}

const x = { a: 1 }
x.log();
// output: { a: 1 }

Extender la clase String para añadir la función trim que eliminará los espacios al inicio y final de una cadena.

if (!String.prototype.trim) {
    String.prototype.trim = function () {
        try {
            return this.replace(/^\s|\s+$/g, "")
        } catch (e) {
            return this
        }
    }
}

const text = "   example test    ".trim()
console.log(text)
// output: example test

Todos los objetos de javascript tienen la clase Object.

Patrón Módulo

Se basa en los objetos literales de javascript ({}). Cada vez que definimos un objeto con sus propiedades y métodos, estamos definiendo un módulo.

const modulo = {
    cache: false,
    setCache: () => {
        modulo.cache = true
    },
    isCacheEnabled: () => {
        return modulo.cache
    }
}

Patrón Módulo Revelador

El patrón módulo revelador tiene una API pública y privada a diferencia del patrón módulo en el que todo es público.

No utilizaremos la sintaxis {} de objetos literales para definir un módulo si no que lo haremos con una función ejecutada inmediatamente (IIFE).

const resultado = (() => {
    const x = {}
    return {
        a: () => console.log(x),
        b: (key, val) => x[key] = val
    }
})()
resultado.b('Seat', 'Ibiza');
resultado.a();
// output: { Seat: 'Ibiza' }
console.log(resultado.x);
// output: undefined

Con el return exponemos los métodos que queremos hacer públicos. En el ejemplo las funciones a y b son públicas y la propiedad x es privada y no podemos acceder a ella directamente.

Patrón Prototipo

Se basa en que en base a un objeto definido podemos crear prototipos para otros objetos. Con esto se elimina la duplicidad de código.

const pc = {
    user: 'DevSEO',
    turnOn: function(){
        console.log(`Bienvenido ${this.user}!`)
    }
}

// crea un objeto a partir del prototipo
let myPc = Object.create(pc); 

myPc.turnOn();
// output: Bienvenido DevSEO!

console.log(myPc);
// output: {} -> el objeto está vacío porque las propiedades y métodos
//               se encuentran en el prototipo

// podemos reemplazar las propiedades
pc.user = 'Raúl';

pc.turnOn();
// output: Bienvenido Raúl!

Patrones de estructura

Basados en la idea de construir bloques de objetos.

Patrón Mixin

Ayuda a añadir funcionalidades a una clase existente sin tener que alterar la clase.

👉  Crear un plugin de WordPress en 1 minuto

En el siguiente ejemplo declaramos el objeto mixin con dos métodos. Estos métodos los asignaremos a la clase Niño mediante Object.assing. Automáticamente la clase Niño podrá acceder a los métodos decirEdad y saludar.

let mixin = {
    decirEdad() {
        console.log(`Tengo ${this.edad}`);
    },
    saludar() {
        console.log(`Hola, me llamo ${this.nombre}`);
    }
}

class Niño {
    constructor(nombre, edad) {
        this.nombre = nombre;
        this.edad = edad;
    }
}

// aumentamos el prototipo
Object.assign(Niño.prototype, mixin);

const usuario = new Niño('Teodomiro', 15);
usuario.saludar();
// output: Hola, me llamo Teodomiro

Patrón Decorador

El patrón Decorador es similar al patrón Mixin salvo que en lugar de agregar funcionalidades al prototipo lo hace con las instancias de clase.

class CuentaBanco{
    constructor(){
        this.saldo = 100;
    }
}

const miCuenta = new CuentaBanco();

// añadir función a la instancia de la clase CuentaBanco
miCuenta.pagarSeguro = function(){
    this.saldo -= 40;
}

miCuenta.pagarSeguro();
console.log(miCuenta.saldo);
// output: 60

Patrón Facade

El patrón Facade proporciona una interfaz que nos abstrae de una funcionalidad compleja. Al aplicar este patrón solo exponemos lo necesario haciendo el código más simplificado y fácil de utilizar.

El siguiente ejemplo muestra una clase FilmAffinity con varios métodos que nos devuelven un listado de películas según su género.

Necesitamos obtener el listado de películas en las que ha participado un determinado actor o actriz, para este ejemplo Charlize Theron. Para ello necesitamos llamar a cada uno de los métodos de la clase para que nos devuelva el listado de películas por género y luego buscar para cada película si en la propiedad reparto si existe la actriz Charlize Theron.

Si tenemos que hacer varias búsquedas de este tipo el proceso es un poco engorroso y llevaría varias líneas de código. Para simplificarlo creamos una función filmFacade que aceptará como parámetro único el nombre del actor o actriz por el que queremos buscar películas. Esta función hace de fachada simplificando la tarea de buscar películas y reduciendo a una línea de código cada vez que queramos hacer una búsqueda de películas por actor/actriz.

class FilmAffinity {
    getTerror() {
        return [{
            id: 't_001',
            nombre: 'El Exorcista',
            reparto: ['Linda Blair', 'Max von Sydow', 'Ellen Burstyn']
        },
        {
            id: 't_002',
            nombre: 'Trapped',
            reparto: ['Charlize Theron', 'Kevin Bacon', 'Stuart Townsend']
        },
        {
            id: 't_003',
            nombre: 'Psicosis',
            reparto: ['Anthony Perkins', 'Janet Leigh', 'John Gavin']
        }]
    }
    getClasicos() {
        return [{
            id: 'c_001',
            nombre: 'Ciudadano Kane',
            reparto: ['Orson Welles', 'Joseph Cotten', 'Everett Sloane']
        }]
    }
    getAcción() {
        return [{
            id: 'a_001',
            nombre: 'Mad Max: Furia en la carretera',
            reparto: ['Tom Hardy', 'Charlize Theron', 'Nicholas Hoult']
        }]
    }
}

const filmFacade = person => {
    const filmAffinity = new FilmAffinity();
    const terrorFound = filmAffinity.getTerror().filter(p => p.reparto.includes(person));
    const clasicosFound = filmAffinity.getClasicos().filter(p => p.reparto.includes(person));
    const accionFound = filmAffinity.getAcción().filter(p => p.reparto.includes(person));
    const result = [ ...terrorFound, ...clasicosFound, ...accionFound ];
    return result;
}

const films = filmFacade('Charlize Theron');
console.log(films);
// output: 
// [
//     {
//         id: 't_002',
//         nombre: 'Trapped',
//         reparto: [ 'Charlize Theron', 'Kevin Bacon', 'Stuart Townsend' ]
//     },
//     {
//         id: 'a_001',
//         nombre: 'Mad Max: Furia en la carretera',
//         reparto: [ 'Tom Hardy', 'Charlize Theron', 'Nicholas Hoult' ]
//     }
// ]

Patrón Adaptador

El patrón Adaptador traduce una interfaz a otra. Se utiliza comúnmente para integrar nuevos componentes en una aplicación legacy.

Por ejemplo, tenemos en nuestro código utilizamos una API (1.0) de terceros que ya está obsoleta y queremos actualizarla por una nueva versión (2.0) haciendo los menores cambios posibles. Para ello crearemos un adaptador que utilizará la nueva versión de la API 2.0 pero expondrá los métodos de la API 1.0 para no tener que hacer cambios en nuestro código y que este siga utilizando los métodos de API antigua (1.0).

class Api1 {
    constructor() {
        this.getData = function (resource) {
            switch (resource) {
                case 'users':
                // return users
                case 'account':
                // return 
            }
        }
    }
}

class Api2 {
    constructor() {
        this.getUsers = function () {
            // return users
        }

        this.getAccount = function () {
            // return account
        }
    }
}

// expone el mismo método que la API antigua (Api1)
// pero internamente utiliza la API nueva (Api2)
class ApiAdapter {
    constructor() {
        const api2 = new Api2();

        this.getData = function (resource) {
            switch (resource) {
                case 'users':
                    return api2.getUsers();
                case 'account':
                    return api2.getUsers();
            }
        }
    }
}

const api1 = new Api1();
api1.getData('users');

const api2 = new Api2();
api2.getUsers();

const adapter = new ApiAdapter();
adapter.getData('users');

Patrones de comportamiento

Nos ayudan a desacoplar el código para facilitar su mantenimiento.

Patrón Observador

También conocido como publish/subscribe es una manera de comunicar dos objetos. El objeto subscribe estará a la escucha de eventos y el otro objeto publish será el que dispare dichos eventos.

Un ejemplo práctico de este patrón en javascript es el método addEventListener. Este método registra un evento específico sobre un objeto. Cuando el evento se dispare dicho objeto recibirá una notificación y podremos realizar la acción o la lógica necesaria. Por ejemplo, podemos registrar el click del ratón sobre un elemento: elemento.addEventListener("click", ejecutarFuncion, false);

const user = new User()

// suscribirse al evento 'login'
user.on('login', userLoggedIn);
const userLoggedIn = () => {
    // el usuario inició sesión
}

// EN OTRA PARTE DE NUESTRA APLICACIÓN...

// emitimos el evento 'login'
// esto finalmente ejecutará la función userLoggedIn
user.trigger('login');

Patrón Mediador

Es similar al patrón Observador en cuanto a concepto de publish/subscribe con la diferencia de que la responsabilidad de realizar la comunicación recae sobre un tercero, el mediador. Los objetos se suscribirán al mediador y este será el encargado de recibir y despachar los eventos.

👉  WPO WordPress

Una librería conocida que implementa este patrón es Redux.

const Emitter = (() => {
    const topics = {}
    const hOP = topics.hasOwnProperty

    return {
        on: (topic, listener) => {
            if (!hOP.call(topics, topic)) topics[topic] = []
            topics[topic].push(listener)
        },
        emit: (topic, info) => {
            if (!hOP.call(topics, topic)) return
            topics[topic].forEach(item =>
                item(info != undefined ? info : {}))
        }
    }
})()

// nos suscribimos al evento 'login'
Emitter.on('login', x => console.log(x));
// output: { user: 'Bartolo' }

// EN OTRA PARTE DE NUESTRO CÓDIGO
// emitimos el evento 'login' al que enviaremos un objeto como parámetro
Emitter.emit('login', { user: 'Bartolo' });

Patrón Comando

Nos proporciona una interfaz genérica para ejecutar los métodos de algún objeto en particular. En lugar de llamar a los métodos de un objeto, tenemos un método genérico al que le indicaremos como parámetros el método o función a ejecutar y sus argumentos.

En el siguiente ejemplo tenemos un método genérico run al que le indicaremos el método y argumentos que queremos ejecutar.

const Commander = (() => {
    const o = {
        disparar: x => {
            console.log(`Disparando con ${x}`)
        },
        apuntar: x => {
            console.log(`Apuntando con ${x}`)
        }
    }

    return {
        run: (comando, argumentos) => {
            if (!o[comando]) {
                console.log('¡El comando no existe!')
                return
            }
            o[comando](argumentos)
        }
    }
})()

Commander.run('disparar', 'Glock 43 9mm');
// output: Disparando con Glock 43 9mm
Commander.run('atacar');
// output: ¡El comando no existe!

Patrón Cadena de Responsabilidad

Nos permite encapsular un dato y agregar métodos que podemos ejecutar de forma encadenada para modificar el estado o valor del dato.

Su uso es muy frecuente en jQuery donde tenemos un método al que podemos llamar varias veces de forma concatenada. Ejemplo:

 $("p").append("Texto 1").append("Texto 2");
class Resta {
    constructor(v = 0) {
        this.val = v
    }
    resta(v) {
        this.val -= v;
        return this; // <- esta es la clave, devolver el objeto para poder concatenar el método
    }
}

const resultado = new Resta(100)
resultado
    .resta(10)
    .resta(20)
    .resta(30);
console.log(resultado.val);
// output: 40

Patrón Iterador

Nos proporciona una forma sencilla de acceder a los valores de una colección. Este patrón nos ofrece el método next para acceder al siguiente valor de una colección. Si hemos acabado de iterar nos lo indicará mediante la propiedad done.

El ejemplo utiliza una función generadora para implementar este patrón.

function* iterador(col) {
    var nextIndex = 0;

    while (nextIndex < col.length) {
        yield col[nextIndex++];
    }
}

const x = [1, 2, 3]
const gen = iterador(x)
console.log(gen.next())
// {value: 1, done: false}
console.log(gen.next())
// {value: 2, done: false}
console.log(gen.next())
// {value: 3, done: false}
console.log(gen.next())
// {value: undefined, done: true}


Otros patrones

Patrón IIFE o autoejecutable

Son las siglas en inglés de Inmedia Invoke Function Expression. Consiste en una expresión de función que es ejecutada inmediatamente tras definirla.

Nos ayuda a definir el patrón revelador y a limitar el alcance de las variables a la función donde están definidas.

const resultado = (() => {
    // escribir aquí toda la lógica que queramos ejecutar
    const x = 'Hello World!';
    console.log(x);
})();

// output: Hello World!

Patrón Inyección de Dependencias (inversión de control)

Este patrón se basa en extraer la responsabilidad de la creación de instancias de un componente para delegarlas en otro. Es decir, las clases no crean los objetos que necesitan si no que reciben directamente las instancias de estos ya creadas por clases de un nivel superior.

La implementación de este patrón elimina los require en gran parte de nuestro código y facilita el testeo.

En el siguiente ejemplo se crea una instancia de Service en el constructor de ViewModel. Esto hace que ViewModel sea el responsable de instanciar el servicio de obtención de datos, Service. Esto es una violación del Principio de Responsabilidad Único.

class Service {
    getUser() {
        return {
            name: 'Teodomiro',
            edad: 33
        }
    }
}

class ViewModel {
    constructor() {
        const service = new Service();
        this.user = service.getUser();
    }

    mostrarUsuario() {
        console.log(`El usuario ${this.user.name} tiene ${this.user.edad} años`);
    }
}

const vm = new ViewModel();
vm.mostrarUsuario();
// output: El usuario Teodomiro tiene 33 años

Haciendo un pequeño cambio en el código modificamos ViewModel para que reciba inyectado en el constructor el servicio de obtención de datos. De esta manera desacoplamos el código y podemos inyectar a ViewModel cualquier servicio que tenga el método getUser.

class Service {
    getUser() {
        return {
            name: 'Teodomiro',
            edad: 33
        }
    }
}

class ViewModel {
    constructor(service) {
        this.user = service.getUser();
    }

    mostrarUsuario() {
        console.log(`El usuario ${this.user.name} tiene ${this.user.edad} años`);
    }
}

const service = new Service();
// inyectamos service en el constructor de ViewModel
const vm = new ViewModel(service);
vm.mostrarUsuario();
// output: El usuario Teodomiro tiene 33 años

En el entorno de desarrollo de Node.js, la adopción de patrones de diseño adecuados es crucial para construir aplicaciones robustas y escalables. Mientras exploramos varios patrones de diseño en JavaScript y Node.js, es esencial entender cómo estos patrones se traducen en una escalabilidad efectiva en aplicaciones reales. En nuestro artículo sobre Patrones de Diseño para Mejorar la Escalabilidad en Aplicaciones Node.js, discutimos en profundidad cómo patrones como Middleware, Cluster Module, Microservices y Worker Threads pueden ser implementados en Node.js para mejorar la escalabilidad y el rendimiento de las aplicaciones

👇Tu comentario