Patrones de módulos modernos en JavaScript: IIFE, singleton, pub/sub y event emitter propio

Los patrones de diseño clásicos (Gang of Four) se diseñaron para lenguajes orientados a objetos con tipado estático. En JavaScript moderno con ESM, muchos se implementan de formas más simples y naturales aprovechando los closures, los módulos y las características del lenguaje. Este artículo muestra cómo implementar los más útiles sin boilerplate innecesario.

Singleton con ESM: sin getInstance()

Los módulos ES son singletons por diseño: el motor evalúa cada módulo una sola vez y cachea el resultado. No necesitas el patrón clásico con una variable estática e getInstance():

// config.js — este objeto se crea una sola vez en toda la app
const config = {
  apiUrl: 'https://api.ejemplo.com',
  timeout: 5000,
  intentos: 3,
};

Object.freeze(config); // inmutable
export default config;

// cualquier módulo que importe config obtiene el mismo objeto
import config from './config.js';
import config from './config.js'; // mismo objeto, no se re-evalúa
// conexion-db.js — singleton de conexión
let conexion = null;

export async function obtenerConexion() {
  if (!conexion) {
    conexion = await crearConexion(config.dbUrl);
  }
  return conexion;
}

Pub/Sub propio con función de limpieza

El patrón publicador/suscriptor desacopla emisores de oyentes. Con closures y un Map es trivial de implementar, y devolver la función de limpieza desde subscribe facilita la gestión del ciclo de vida:

function crearEventBus() {
  const oyentes = new Map();

  return {
    on(evento, callback) {
      if (!oyentes.has(evento)) oyentes.set(evento, new Set());
      oyentes.get(evento).add(callback);
      return () => oyentes.get(evento)?.delete(callback); // función de limpieza
    },

    off(evento, callback) {
      oyentes.get(evento)?.delete(callback);
    },

    emit(evento, datos) {
      oyentes.get(evento)?.forEach(fn => fn(datos));
    },

    once(evento, callback) {
      const limpiar = this.on(evento, (datos) => {
        callback(datos);
        limpiar();
      });
      return limpiar;
    },
  };
}

export const bus = crearEventBus();

// Uso
const limpiar = bus.on('usuario:login', ({ nombre }) => {
  console.log(`Bienvenido, ${nombre}`);
});

bus.emit('usuario:login', { nombre: 'Ana' }); // "Bienvenido, Ana"
limpiar(); // eliminar el listener

WeakMap para estado privado

Antes de los campos privados con #, el patrón con WeakMap era la forma de tener estado verdaderamente privado en objetos JavaScript. Sigue siendo útil para añadir estado privado a instancias sin modificar la clase:

const _privado = new WeakMap();

export class CuentaBancaria {
  constructor(saldoInicial) {
    _privado.set(this, { saldo: saldoInicial, historial: [] });
  }

  depositar(cantidad) {
    const p = _privado.get(this);
    p.saldo += cantidad;
    p.historial.push({ tipo: 'deposito', cantidad, fecha: new Date() });
    return this; // chainable
  }

  retirar(cantidad) {
    const p = _privado.get(this);
    if (cantidad > p.saldo) throw new Error('Saldo insuficiente');
    p.saldo -= cantidad;
    p.historial.push({ tipo: 'retiro', cantidad, fecha: new Date() });
    return this;
  }

  get saldo() {
    return _privado.get(this).saldo;
  }
}

const cuenta = new CuentaBancaria(1000);
cuenta.depositar(500).retirar(200);
console.log(cuenta.saldo); // 1300
// _privado no es accesible desde fuera del módulo

Observer con WeakRef para evitar memory leaks

Un observador que retiene referencias fuertes a sus callbacks impide que el GC libere los componentes desmontados. Usar WeakRef permite que los suscriptores se liberen automáticamente:

class ObservableDebil {
  #oyentes = new Set();

  suscribir(callback) {
    const ref = new WeakRef(callback);
    this.#oyentes.add(ref);
    return () => this.#oyentes.delete(ref);
  }

  notificar(datos) {
    for (const ref of this.#oyentes) {
      const fn = ref.deref();
      if (fn) {
        fn(datos);
      } else {
        this.#oyentes.delete(ref); // limpiar referencias muertas
      }
    }
  }
}

EventTarget nativo como event emitter

La clase EventTarget está disponible en Node.js desde la versión 14 y en todos los navegadores. Extenderla te da un event emitter con la misma API que los elementos del DOM, sin librerías externas:

class TiendaEstado extends EventTarget {
  #estado;

  constructor(estadoInicial) {
    super();
    this.#estado = estadoInicial;
  }

  get estado() {
    return { ...this.#estado }; // copia defensiva
  }

  actualizar(cambios) {
    const anterior = this.#estado;
    this.#estado = { ...this.#estado, ...cambios };

    this.dispatchEvent(
      new CustomEvent('cambio', {
        detail: { anterior, actual: this.#estado, cambios },
      })
    );
  }
}

const tienda = new TiendaEstado({ usuario: null, tema: 'claro', idioma: 'es' });

tienda.addEventListener('cambio', (e) => {
  console.log('Estado actualizado:', e.detail.cambios);
});

tienda.actualizar({ tema: 'oscuro' });
// "Estado actualizado: { tema: 'oscuro' }"

// También funciona con removeEventListener y opciones como { once: true }
tienda.addEventListener('cambio', handler, { once: true });

La tendencia en JavaScript moderno es preferir funciones y closures sobre clases cuando el patrón no requiere herencia, usar el sistema de módulos como contenedor de singletons, y aprovechar las APIs nativas (EventTarget, WeakRef, campos # privados) en lugar de implementar desde cero abstracciones que el lenguaje ya proporciona.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP