Patrones elegantes en JavaScript moderno: Ice Factory

Foto de Demi DeHerrera en Unsplash

He estado trabajando con JavaScript de forma intermitente desde finales de los noventa. Al principio no me gustó mucho, pero después de la introducción de ES2015 (también conocido como ES6), comencé a apreciar JavaScript como un lenguaje de programación dinámico y sobresaliente con un enorme poder expresivo.

Con el tiempo, he adoptado varios patrones de codificación que han llevado a un código más limpio, más comprobable y más expresivo. Ahora, estoy compartiendo estos patrones contigo.

Escribí sobre el primer patrón, "RORO", en el artículo a continuación. No se preocupe si no lo ha leído, puede leerlos en cualquier orden.

Hoy, me gustaría presentarles el patrón "Fábrica de hielo".

Una fábrica de hielo es solo una función que crea y devuelve un objeto congelado. Descomprimiremos esa declaración en un momento, pero primero exploremos por qué este patrón es tan poderoso.

Las clases de JavaScript no son tan elegantes

A menudo tiene sentido agrupar funciones relacionadas en un solo objeto. Por ejemplo, en una aplicación de comercio electrónico, podríamos tener un objeto de carrito que expone una función addProduct y una función removeProduct. Entonces podríamos invocar estas funciones con cart.addProduct () y cart.removeProduct ().

Si proviene de un lenguaje de programación centrado en la clase, orientado a objetos, como Java o C #, esto probablemente se sienta bastante natural.

Si eres nuevo en la programación, ahora que has visto una declaración como cart.addProduct (). Sospecho que la idea de agrupar funciones bajo un solo objeto se ve bastante bien.

Entonces, ¿cómo podríamos crear este bonito y pequeño objeto de carrito? Su primer instinto con JavaScript moderno podría ser utilizar una clase. Algo como:

// ShoppingCart.js
exportar clase predeterminada ShoppingCart {
  constructor ({db}) {
    this.db = db
  }
  
  addProduct (producto) {
    this.db.push (producto)
  }
  
  vacío () {
    this.db = []
  }
  obtener productos () {
    devolver objeto
      .freeze ([... this.db])
  }
  removeProduct (id) {
    // eliminar un producto
  }
  // otros metodos
}
// someOtherModule.js
const db = []
const cart = new ShoppingCart ({db})
cart.addProduct ({
  nombre: 'foo',
  precio: 9.99
})
Nota: estoy usando una matriz para el parámetro db por simplicidad. En código real, esto sería algo así como un Modelo o Repo que interactúa con una base de datos real.

Desafortunadamente, a pesar de que esto se ve bien, las clases en JavaScript se comportan de manera bastante diferente de lo que cabría esperar.

Las clases de JavaScript te morderán si no tienes cuidado.

Por ejemplo, los objetos creados con la nueva palabra clave son mutables. Entonces, en realidad puedes reasignar un método:

const db = []
const cart = new ShoppingCart ({db})
cart.addProduct = () => '¡no!'
// ¡No hay error en la línea de arriba!
cart.addProduct ({
  nombre: 'foo',
  precio: 9.99
}) // salida: "¡no!" FTW?

Peor aún, los objetos creados con la nueva palabra clave heredan el prototipo de la clase que se utilizó para crearlos. Por lo tanto, los cambios en el prototipo de una clase afectan a todos los objetos creados a partir de esa clase, ¡incluso si se realiza un cambio después de que se creó el objeto!

Mira esto:

const cart = new ShoppingCart ({db: []})
const other = nuevo ShoppingCart ({db: []})
ShoppingCart.prototype
  .addProduct = () => "no!"
// ¡No hay error en la línea de arriba!
cart.addProduct ({
  nombre: 'foo',
  precio: 9.99
}) // salida: "¡no!"
other.addProduct ({
  nombre: 'bar',
  precio: 8.88
}) // salida: "¡no!"

Luego está el hecho de que esto en JavaScript está vinculado dinámicamente. Entonces, si pasamos por alto los métodos de nuestro objeto de carrito, podemos perder la referencia a esto. Eso es muy contrario a la intuición y nos puede meter en muchos problemas.

Una trampa común es asignar un método de instancia a un controlador de eventos.

Considere nuestro método carro.empty.

vacío () {
    this.db = []
  }

Si asignamos este método directamente al evento de clic de un botón en nuestra página web ...

---
documento
  .querySelector ('# vacío')
  .addEventListener (
    'hacer clic',
    carro.empty
  )

... cuando los usuarios hacen clic en el botón vacío, su carrito permanecerá lleno.

Falla silenciosamente porque esto ahora se referirá al botón en lugar del carrito. Entonces, nuestro método cart.empty termina asignando una nueva propiedad a nuestro botón llamado db y configurando esa propiedad en [] en lugar de afectar la db del objeto del carrito.

Este es el tipo de error que te volverá loco porque no hay ningún error en la consola y tu sentido común te dirá que debería funcionar, pero no funciona.

Para que funcione tenemos que hacer:

documento
  .querySelector ("# vacío")
  .addEventListener (
    "hacer clic",
    () => cart.empty ()
  )

O:

documento
  .querySelector ("# vacío")
  .addEventListener (
    "hacer clic",
    cart.empty.bind (carrito)
  )

Creo que Mattias Petter Johansson lo dijo mejor:

"Nuevo y esto [en JavaScript] es una especie de trampa de arcoíris de nubes poco intuitiva, extraña".

Fábrica de hielo al rescate

Como dije antes, una fábrica de hielo es solo una función que crea y devuelve un objeto congelado. Con una fábrica de hielo, nuestro ejemplo de carrito de compras se ve así:

// makeShoppingCart.js
función predeterminada de exportación makeShoppingCart ({
  db
}) {
  return Object.freeze ({
    Agregar producto,
    vacío,
    getProducts,
    removeProduct,
    // otros
  })
  función addProduct (producto) {
    db.push (producto)
  }
  
  función vacía () {
    db = []
  }
  función getProducts () {
    devolver objeto
      .freeze ([... db])
  }
  función removeProduct (id) {
    // eliminar un producto
  }
  // otras funciones
}
// someOtherModule.js
const db = []
const cart = makeShoppingCart ({db})
cart.addProduct ({
  nombre: 'foo',
  precio: 9.99
})

Observe que nuestras "trampas de arcoíris de nubes raras" se han ido:

  • Ya no necesitamos nuevos.
    Simplemente invocamos una función de JavaScript antigua para crear nuestro objeto de carrito.
  • Ya no necesitamos esto.
    Podemos acceder al objeto db directamente desde nuestras funciones miembro.
  • Nuestro objeto de carrito es completamente inmutable.
    Object.freeze () congela el objeto del carrito para que no se puedan agregar nuevas propiedades, las propiedades existentes no se pueden eliminar o cambiar, y el prototipo tampoco se puede cambiar. Solo recuerde que Object.freeze () es superficial, por lo que si el objeto que devolvemos contiene una matriz u otro objeto, debemos asegurarnos de Object.freeze () también. Además, si está utilizando un objeto congelado fuera de un módulo ES, debe estar en modo estricto para asegurarse de que las reasignaciones causen un error en lugar de simplemente fallar en silencio.

Un poco de privacidad por favor

Otra ventaja de Ice Factories es que pueden tener miembros privados. Por ejemplo:

función makeThing (espec.) {
  const secret = '¡shhh!'
  return Object.freeze ({
    hacer cosas
  })
  función doStuff () {
    // Podemos usar ambas especificaciones
    // y secreto aquí
  }
}
// el secreto no es accesible aquí
cosa constante = makeThing ()
thing.secret // undefined

Esto es posible gracias a los cierres en JavaScript, sobre los que puede leer más en MDN.

Un pequeño reconocimiento por favor

Aunque Factory Functions ha estado alrededor de JavaScript para siempre, el patrón Ice Factory se inspiró en gran medida en algún código que Douglas Crockford mostró en este video.

Aquí está Crockford demostrando la creación de objetos con una función que él llama "constructor":

Douglas Crockford demostrando el código que me inspiró.

Mi versión de Ice Factory del ejemplo de Crockford anterior se vería así:

función makeSomething ({member}) {
  const {other} = makeSomethingElse ()
  
  return Object.freeze ({
    otro,
    método
  })
  método de función () {
    // código que usa "miembro"
  }
}

Aproveché la función de elevación para poner mi declaración de retorno cerca de la parte superior, para que los lectores tuvieran un pequeño resumen de lo que está sucediendo antes de profundizar en los detalles.

También utilicé la desestructuración en el parámetro spec. Y cambié el nombre del patrón a "Fábrica de hielo" para que sea más memorable y menos fácil de confundir con la función de constructor de una clase JavaScript. Pero es básicamente lo mismo.

Entonces, crédito donde se debe, gracias Sr. Crockford.

Nota: Probablemente valga la pena mencionar que Crockford considera que la función "elevar" es una "parte mala" de JavaScript y probablemente consideraría la herejía de mi versión. Discutí mis sentimientos sobre esto en un artículo anterior y más específicamente, este comentario.

¿Qué pasa con la herencia?

Si avanzamos en la construcción de nuestra pequeña aplicación de comercio electrónico, pronto nos daremos cuenta de que el concepto de agregar y eliminar productos sigue apareciendo una y otra vez en todo el lugar.

Junto con nuestro carrito de compras, probablemente tengamos un objeto de catálogo y un objeto de pedido. Y todo esto probablemente exponga alguna versión de `addProduct` y` removeProduct`.

Sabemos que la duplicación es mala, por lo que eventualmente tendremos la tentación de crear algo así como un objeto de la Lista de productos del que todos nuestro carrito, catálogo y pedido pueden heredar.

Pero en lugar de extender nuestros objetos heredando una Lista de productos, podemos adoptar el principio intemporal ofrecido en uno de los libros de programación más influyentes jamás escritos:

"Favorecer la composición de objetos sobre la herencia de clases".
- Patrones de diseño: elementos de software orientado a objetos reutilizables.

De hecho, los autores de ese libro, coloquialmente conocidos como "La banda de los cuatro", continúan diciendo:

"... nuestra experiencia es que los diseñadores abusan de la herencia como una técnica de reutilización, y los diseños a menudo se vuelven más reutilizables (y más simples) al depender más de la composición del objeto".

Entonces, aquí está nuestra lista de productos:

función makeProductList ({productDb}) {
  return Object.freeze ({
    Agregar producto,
    vacío,
    getProducts,
    removeProduct,
    // otros
  )}
 
  // definiciones para
  // addProduct, etc.
}

Y aquí está nuestro carrito de compras:

función makeShoppingCart (productList) {
  return Object.freeze ({
    artículos: productList,
    someCartSpecificMethod,
    // ...
)}
function someCartSpecificMethod () {
  // código
  }
}

Y ahora podemos simplemente inyectar nuestra Lista de productos en nuestro carrito de compras, así:

const productDb = []
const productList = makeProductList ({productDb})
const cart = makeShoppingCart (lista de productos)

Y use la Lista de productos a través de la propiedad `items`. Me gusta:

cart.items.addProduct ()

Puede ser tentador incluir toda la Lista de productos incorporando sus métodos directamente en el objeto del carrito de compras, de esta manera:

función makeShoppingCart ({
  Agregar producto,
  vacío,
  getProducts,
  removeProduct,
  …otros
}) {
  return Object.freeze ({
    Agregar producto,
    vacío,
    getProducts,
    removeProduct,
    someOtherMethod,
    …otros
)}
function someOtherMethod () {
  // código
  }
}

De hecho, en una versión anterior de este artículo, hice exactamente eso. Pero luego se me señaló que esto es un poco peligroso (como se explica aquí). Por lo tanto, es mejor seguir con la composición adecuada del objeto.

Increíble. ¡Estoy vendido!

Cuidadoso

Cada vez que aprendemos algo nuevo, especialmente algo tan complejo como la arquitectura y el diseño de software, tendemos a querer reglas estrictas y rápidas. Queremos escuchar cosas como "siempre haz esto" y "nunca hagas eso".

Cuanto más tiempo paso trabajando con estas cosas, más me doy cuenta de que no existe tal cosa como siempre y nunca. Se trata de elecciones y compensaciones.

Hacer objetos con una fábrica de hielo es más lento y ocupa más memoria que usar una clase.

En los tipos de casos de uso que he descrito, esto no importará. A pesar de que son más lentos que las clases, las fábricas de hielo siguen siendo bastante rápidas.

Si necesita crear cientos de miles de objetos de una sola vez, o si se encuentra en una situación en la que la memoria y la potencia de procesamiento son extremadamente altas, es posible que necesite una clase.

Solo recuerde, primero perfile su aplicación y no optimice prematuramente. La mayoría de las veces, la creación de objetos no será el cuello de botella.

A pesar de mi discurso anterior, las clases no siempre son terribles. No deberías tirar un framework o biblioteca solo porque usa clases. De hecho, Dan Abramov escribió bastante elocuentemente sobre esto en su artículo, Cómo usar las clases y dormir por la noche.

Finalmente, debo reconocer que he hecho un montón de elecciones de estilo obstinadas en los ejemplos de código que les he presentado:

  • Yo uso declaraciones de función en lugar de expresiones de función.
  • Puse mi declaración de retorno cerca de la parte superior (esto es posible por mi uso de declaraciones de función, ver arriba).
  • Nombro mi función de fábrica, makeX en lugar de createX o buildX u otra cosa.
  • Mi función de fábrica toma un único objeto de parámetro desestructurado.
  • No uso punto y coma (Crockford tampoco lo aprobaría)
  • y así…

Puede elegir diferentes estilos, ¡y está bien! El estilo no es el patrón.

El patrón Ice Factory es solo: use una función para crear y devolver un objeto congelado. La forma exacta en que escribe esa función depende de usted.

Si este artículo le ha resultado útil, aplastar ese icono de aplausos varias veces para ayudar a correr la voz. Y si quieres aprender más cosas como esta, suscríbete a mi boletín de Dev Mastery a continuación. ¡Gracias!

ACTUALIZACIÓN 2019: ¡Aquí hay un video donde uso mucho este patrón!