Patrones elegantes en JavaScript moderno: Ice Factory

He estado trabajando con JavaScript de forma intermitente desde finales de los noventa. Realmente no me gustó al principio, 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 con ustedes.

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

Patrones elegantes en JavaScript moderno: RORO

Escribí mis primeras líneas de JavaScript poco después de que se inventara el lenguaje. Si me dijeras en ese momento que yo ... medium.freecodecamp.org

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 . Analizaremos 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 cartobjeto que expone una addProductfunción y una removeProductfunción. Entonces podríamos invocar estas funciones con cart.addProduct()y cart.removeProduct().

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

Si es nuevo en la programación, ahora que ha 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 crearíamos este bonito cartobjeto? Su primer instinto con JavaScript moderno podría ser utilizar un class. Algo como:

// ShoppingCart.js
export default class ShoppingCart { constructor({db}) { this.db = db } addProduct (product) { this.db.push(product) } empty () { this.db = [] }
 get products () { return Object .freeze([...this.db]) }
 removeProduct (id) { // remove a product }
 // other methods
}
// someOtherModule.js
const db = [] const cart = new ShoppingCart({db})cart.addProduct({ name: 'foo', price: 9.99})
Nota : Estoy usando una matriz para el dbparámetro por simplicidad. En código real, esto sería algo así como un modelo o repositorio que interactúa con una base de datos real.

Desafortunadamente, aunque 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 newpalabra clave son mutables. Entonces, puedes reasignar un método:

const db = []const cart = new ShoppingCart({db})
cart.addProduct = () => 'nope!' // No Error on the line above!
cart.addProduct({ name: 'foo', price: 9.99}) // output: "nope!" FTW?

Peor aún, los objetos creados con la newpalabra clave heredan el valor prototypedel classque se utilizó para crearlos. Entonces, los cambios en una clase ' prototypeafectan a todos los objetos creados a partir de eso class, incluso si se realiza un cambio después de que se creó el objeto!

Mira este:

const cart = new ShoppingCart({db: []})const other = new ShoppingCart({db: []})
ShoppingCart.prototype .addProduct = () => ‘nope!’// No Error on the line above!
cart.addProduct({ name: 'foo', price: 9.99}) // output: "nope!"
other.addProduct({ name: 'bar', price: 8.88}) // output: "nope!"

Luego está el hecho de que thisIn JavaScript está vinculado dinámicamente. Entonces, si pasamos los métodos de nuestro cartobjeto, podemos perder la referencia a this. 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 cart.emptymétodo.

empty () { this.db = [] }

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

 Empty cart
---
document .querySelector('#empty') .addEventListener( 'click', cart.empty )

... cuando los usuarios hagan clic en el vacío button, cartpermanecerán llenos.

Se produce un error en silencio , porque thisahora se referirá a la buttonvez de la cart. Entonces, nuestro cart.emptymétodo termina asignando una nueva propiedad a nuestra buttonllamada dby estableciendo esa propiedad en en []lugar de afectar la del cartobjeto db.

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:

document .querySelector("#empty") .addEventListener( "click", () => cart.empty() )

O:

document .querySelector("#empty") .addEventListener( "click", cart.empty.bind(cart) )

Creo que Mattias Petter Johansson lo dijo mejor:

new and this [in JavaScript] are some kind of unintuitive, weird, cloud rainbow trap.”

Ice Factory to the rescue

As I said earlier, an Ice Factory is just a function that creates and returns a frozen object. With an Ice Factory our shopping cart example looks like this:

// makeShoppingCart.js
export default function makeShoppingCart({ db}) { return Object.freeze({ addProduct, empty, getProducts, removeProduct, // others })
 function addProduct (product) { db.push(product) } function empty () { db = [] }
 function getProducts () { return Object .freeze([...db]) }
 function removeProduct (id) { // remove a product }
 // other functions}
// someOtherModule.js
const db = []const cart = makeShoppingCart({ db })cart.addProduct({ name: 'foo', price: 9.99})

Notice our “weird, cloud rainbow traps” are gone:

  • We no longer need new.

    We just invoke a plain old JavaScript function to create our cart object.

  • We no longer need this.

    We can access the db object directly from our member functions.

  • Our cart object is completely immutable.

    Object.freeze() freezes the cart object so that new properties can’t be added to it, existing properties can’t be removed or changed, and the prototype can’t be changed either. Just remember that Object.freeze() is shallow, so if the object we return contains an array or another object we must make sure to Object.freeze() them as well. Also, if you’re using a frozen object outside of an ES Module, you need to be in strict mode to make sure that re-assignments cause an error rather than just failing silently.

A little privacy please

Another advantage of Ice Factories is that they can have private members. For example:

function makeThing(spec) { const secret = 'shhh!'
 return Object.freeze({ doStuff })
 function doStuff () { // We can use both spec // and secret in here }}
// secret is not accessible out here
const thing = makeThing()thing.secret // undefined

This is made possible because of Closures in JavaScript, which you can read more about on MDN.

A little acknowledgement please

Although Factory Functions have been around JavaScript forever, the Ice Factory pattern was heavily inspired by some code that Douglas Crockford showed in this video.

Here’s Crockford demonstrating object creation with a function he calls “constructor”:

My Ice Factory version of the Crockford example above would look like this:

function makeSomething({ member }) { const { other } = makeSomethingElse() return Object.freeze({ other, method }) 
 function method () { // code that uses "member" }}

I took advantage of function hoisting to put my return statement near the top, so that readers would have a nice little summary of what’s going on before diving into the details.

I also used destructuring on the spec parameter. And I renamed the pattern to “Ice Factory” so that it’s more memorable and less easily confused with the constructor function from a JavaScript class. But it’s basically the same thing.

So, credit where credit is due, thank you Mr. Crockford.

Note: It’s probably worth mentioning that Crockford considers function “hoisting” a “bad part” of JavaScript and would likely consider my version heresy. I discussed my feelings on this in a previous article and more specifically, this comment.

What about inheritance?

If we tick along building out our little e-commerce app, we might soon realize that the concept of adding and removing products keeps cropping up again and again all over the place.

Along with our Shopping Cart, we probably have a Catalog object and an Order object. And all of these probably expose some version of `addProduct` and `removeProduct`.

We know that duplication is bad, so we’ll eventually be tempted to create something like a Product List object that our cart, catalog, and order can all inherit from.

Pero en lugar de extender nuestros objetos heredando una Lista de productos, podemos adoptar el principio atemporal 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 reutilizable.

De hecho, los autores de ese libro, conocido coloquialmente 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:

function makeProductList({ productDb }) { return Object.freeze({ addProduct, empty, getProducts, removeProduct, // others )} // definitions for // addProduct, etc…}

Y aquí está nuestro carrito de compras:

function makeShoppingCart(productList) { return Object.freeze({ items: productList, someCartSpecificMethod, // …)}
function someCartSpecificMethod () { // code }}

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

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

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

cart.items.addProduct()

It may be tempting to subsume the entire Product List by incorporating its methods directly into the shopping cart object, like so:

function makeShoppingCart({ addProduct, empty, getProducts, removeProduct, …others}) { return Object.freeze({ addProduct, empty, getProducts, removeProduct, someOtherMethod, …others)}
function someOtherMethod () { // code }}

In fact, in an earlier version of this article, I did just that. But then it was pointed out to me that this is a bit dangerous (as explained here). So, we’re better off sticking with proper object composition.

Awesome. I’m Sold!

Whenever we’re learning something new, especially something as complex as software architecture and design, we tend to want hard and fast rules. We want to hear thing like “always do this” and “ never do that.”

The longer I spend working with this stuff, the more I realize that there’s no such thing as always and never. It’s about choices and trade-offs.

Making objects with an Ice Factory is slower and takes up more memory than using a class.

In the types of use case I’ve described, this won’t matter. Even though they are slower than classes, Ice Factories are still quite fast.

If you find yourself needing to create hundreds of thousands of objects in one shot, or if you’re in a situation where memory and processing power is at an extreme premium you might need a class instead.

Just remember, profile your app first and don’t prematurely optimize. Most of the time, object creation is not going to be the bottleneck.

Despite my earlier rant, Classes are not always terrible. You shouldn’t throw out a framework or library just because it uses classes. In fact, Dan Abramov wrote pretty eloquently about this in his article, How to use Classes and Sleep at Night.

Finally, I need to acknowledge that I’ve made a bunch of opinionated style choices in the code samples I’ve presented to you:

  • I use function statements instead of function expressions.
  • I put my return statement near the top (this is made possible by my use of function statements, see above).
  • I name my factory function, makeX instead of createX or buildX or something else.
  • My factory function takes a single, destructured, parameter object.
  • I don’t use semi-colons (Crockford would also NOT approve of that)
  • and so on…

You may make different style choices, and that’s okay! The style is not the pattern.

The Ice Factory pattern is just: use a function to create and return a frozen object. Exactly how you write that function is up to you.

If you’ve found this article useful, please smash that applause icon a bunch of times to help spread the word. And if you want to learn more stuff like this, please sign up for my Dev Mastery newsletter below. Thanks!

UPDATE 2019: Here’s a video where I use this pattern, a lot!