Elegante mønstre i moderne JavaScript: Ice Factory

Jeg har arbejdet med JavaScript til og fra siden slutningen af ​​halvfemserne. Jeg kunne ikke rigtig godt lide det i starten, men efter introduktionen af ​​ES2015 (aka ES6) begyndte jeg at sætte pris på JavaScript som et fremragende, dynamisk programmeringssprog med enorm, udtryksfuld styrke.

Over tid har jeg vedtaget flere kodemønstre, der har ført til renere, mere testbar, mere ekspressiv kode. Nu deler jeg disse mønstre med dig.

Jeg skrev om det første mønster - “RORO” - i artiklen nedenfor. Bare rolig, hvis du ikke har læst den, kan du læse disse i hvilken som helst rækkefølge.

Elegante mønstre i moderne JavaScript: RORO

Jeg skrev mine første par linjer med JavaScript ikke længe efter, at sproget blev opfundet. Hvis du på det tidspunkt fortalte mig, at jeg ... medium.freecodecamp.org

I dag vil jeg gerne introducere dig til "Ice Factory" mønsteret.

En isfabrik er bare en funktion, der skaber og returnerer et frossent objekt . Vi udpakker denne erklæring om et øjeblik, men lad os først undersøge, hvorfor dette mønster er så stærkt.

JavaScript-klasser er ikke så klassiske

Det giver ofte mening at gruppere relaterede funktioner i et enkelt objekt. For eksempel i en e-handelsapp kan vi have et cartobjekt, der udsætter en addProductfunktion og en removeProductfunktion. Vi kunne derefter påberåbe sig disse funktioner med cart.addProduct()og cart.removeProduct().

Hvis du kommer fra et klassecentreret, objektorienteret programmeringssprog som Java eller C #, føles dette sandsynligvis ret naturligt.

Hvis du er ny inden for programmering - nu hvor du har set en erklæring som cart.addProduct(). Jeg formoder, at ideen om at gruppere funktioner under et enkelt objekt ser ret godt ud.

Så hvordan ville vi skabe dette pæne lille cartobjekt? Dit første instinkt med moderne JavaScript kan være at bruge en class. Noget som:

// 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})
Bemærk : Jeg bruger en matrix til dbparameteren for enkelhedens skyld. I ægte kode ville dette være noget i retning af en model eller repo, der interagerer med en egentlig database.

Desværre - selvom dette ser godt ud - opfører klasser i JavaScript sig helt anderledes end hvad du kunne forvente.

JavaScript-klasser bider dig, hvis du ikke er forsigtig.

F.eks. Kan objekter, der er oprettet ved hjælp af newnøgleordet, ændres. Så du kan faktisk tildele en metode igen:

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?

Endnu værre er, at objekter, der er oprettet ved hjælp af newnøgleordet, arver prototypedet, classder blev brugt til at oprette dem. Så ændringer i en klasse prototypepåvirker alle objekter, der er oprettet ud fra det class- selvom der foretages en ændring, efter at objektet blev oprettet!

Se det her:

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!"

Så er der det faktum, at thisIn JavaScript er dynamisk bundet. Så hvis vi går rundt om metoderne til vores cartobjekt, kan vi miste henvisningen til this. Det er meget kontraintuitivt, og det kan få os i mange problemer.

En fælles fælde er at tildele en instansmetode til en begivenhedshåndterer.

Overvej vores cart.emptymetode.

empty () { this.db = [] }

Hvis vi tildeler denne metode direkte til clickbegivenheden for en knap på vores webside ...

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

... når brugere klikker på det tomme button, cartforbliver deres fulde.

Det mislykkes lydløst, fordi thisdet nu henviser til buttonstedet for cart. Så vores cart.emptymetode ender med at tildele en ny egenskab til vores buttonkaldte dbog indstille den egenskab til i []stedet for at påvirke cartobjektets db.

Dette er den slags fejl, der vil gøre dig skør, fordi der ikke er nogen fejl i konsollen, og din sunde fornuft vil fortælle dig, at det skal fungere, men det gør det ikke.

For at få det til at fungere skal vi gøre:

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

Eller:

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

Jeg tror Mattias Petter Johansson sagde det bedst:

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.

Men i stedet for at udvide vores objekter ved at arve en produktliste, kan vi i stedet vedtage det tidløse princip, der tilbydes i en af ​​de mest indflydelsesrige programmeringsbøger, der nogensinde er skrevet:

"Foretrækker objektsammensætning frem for klassearv."

- Designmønstre: Elementer af genanvendelig objektorienteret software.

Faktisk fortæller forfatterne til den bog - i det mindste kendt som "The Four of Gang":

"... vores erfaring er, at designere overforbruger arv som en genbrugsteknik, og design gøres ofte mere genanvendelige (og enklere) ved at afhænge mere af objektsammensætningen."

Så her er vores produktliste:

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

Og her er vores indkøbskurv:

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

Og nu kan vi bare indsprøjte vores produktliste i vores indkøbskurv på denne måde:

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

Og brug produktlisten via egenskaben 'varer'. Synes godt om:

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.

Hvis du har fundet denne artikel nyttig, skal du knuse det bifaldsikon en række gange for at hjælpe med at sprede ordet. Og hvis du vil lære flere ting som dette, skal du tilmelde dig mit Dev Mastery-nyhedsbrev nedenfor. Tak!

OPDATERING 2019: Her er en video, hvor jeg bruger dette mønster meget!