Elegante Muster in modernem JavaScript: Ice Factory

Ich arbeite seit Ende der neunziger Jahre immer wieder mit JavaScript. Anfangs mochte ich es nicht wirklich, aber nach der Einführung von ES2015 (auch bekannt als ES6) begann ich, JavaScript als herausragende, dynamische Programmiersprache mit enormer Ausdruckskraft zu schätzen.

Im Laufe der Zeit habe ich mehrere Codierungsmuster übernommen, die zu saubererem, testbarerem und aussagekräftigerem Code geführt haben. Jetzt teile ich diese Muster mit Ihnen.

Ich habe über das erste Muster - "RORO" - im folgenden Artikel geschrieben. Machen Sie sich keine Sorgen, wenn Sie es nicht gelesen haben, Sie können diese in beliebiger Reihenfolge lesen.

Elegante Muster in modernem JavaScript: RORO

Ich habe meine ersten Zeilen JavaScript nicht lange nach der Erfindung der Sprache geschrieben. Wenn Sie mir damals sagten, dass ich… medium.freecodecamp.org

Heute möchte ich Ihnen das Muster der „Eisfabrik“ vorstellen.

Eine Eisfabrik ist nur eine Funktion, die ein eingefrorenes Objekt erstellt und zurückgibt . Wir werden diese Aussage gleich auspacken, aber zuerst wollen wir untersuchen, warum dieses Muster so mächtig ist.

JavaScript-Klassen sind nicht so edel

Oft ist es sinnvoll, verwandte Funktionen in einem einzigen Objekt zu gruppieren. In einer E-Commerce-App haben wir möglicherweise ein cartObjekt, das eine addProductFunktion und eine removeProductFunktion verfügbar macht . Wir könnten diese Funktionen dann mit cart.addProduct()und aufrufen cart.removeProduct().

Wenn Sie aus einer klassenzentrierten, objektorientierten Programmiersprache wie Java oder C # stammen, fühlt sich dies wahrscheinlich ganz natürlich an.

Wenn Sie neu in der Programmierung sind - jetzt, wo Sie eine Aussage wie gesehen haben cart.addProduct(). Ich vermute, dass die Idee, Funktionen unter einem einzigen Objekt zusammenzufassen, ziemlich gut aussieht.

Wie würden wir dieses nette kleine cartObjekt schaffen? Ihr erster Instinkt mit modernem JavaScript könnte darin bestehen, a zu verwenden class. Etwas wie:

// 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})
Hinweis : Der dbEinfachheit halber verwende ich ein Array für den Parameter. In echtem Code wäre dies so etwas wie ein Modell oder Repo, das mit einer tatsächlichen Datenbank interagiert.

Leider verhalten sich Klassen in JavaScript - auch wenn dies gut aussieht - ganz anders als erwartet.

JavaScript-Klassen werden Sie beißen, wenn Sie nicht vorsichtig sind.

Mit dem newSchlüsselwort erstellte Objekte können beispielsweise geändert werden. Sie können also eine Methode neu zuweisen :

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?

Noch schlimmer ist , Objekte mit Hilfe des erstellten newSchlüsselwort erbt die prototypevon der , classdie verwendet wurde , um sie zu erstellen. Änderungen an einer Klasse prototypewirken sich also auf alle daraus erstellten Objekte aus class- auch wenn eine Änderung vorgenommen wird, nachdem das Objekt erstellt wurde!

Schau dir das an:

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

Dann gibt es die Tatsache, dass thisIn JavaScript dynamisch gebunden ist. Wenn wir also die Methoden unseres cartObjekts weitergeben, können wir den Verweis auf verlieren this. Das ist sehr kontraintuitiv und kann uns in große Schwierigkeiten bringen.

Ein häufiger Trap weist einem Ereignishandler eine Instanzmethode zu.

Betrachten Sie unsere cart.emptyMethode.

empty () { this.db = [] }

Wenn wir diese Methode direkt dem clickEreignis einer Schaltfläche auf unserer Webseite zuweisen ...

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

… Wenn Benutzer auf das Leerzeichen klicken button, cartbleiben sie voll.

Es schlägt lautlos fehl, da thisnun auf das buttonanstelle des verwiesen wird cart. Unsere cart.emptyMethode weist unserem buttonaufgerufenen Objekt also eine neue Eigenschaft zu dbund setzt diese Eigenschaft auf, []anstatt die des cartObjekts zu beeinflussen db.

Dies ist die Art von Fehler, die Sie verrückt machen wird, weil es keinen Fehler in der Konsole gibt und Ihr gesunder Menschenverstand Ihnen sagt, dass es funktionieren sollte, aber es funktioniert nicht.

Damit es funktioniert, müssen wir Folgendes tun:

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

Oder:

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

Ich denke, Mattias Petter Johansson hat es am besten gesagt:

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.

Aber anstatt unsere Objekte durch das Erben einer Produktliste zu erweitern, können wir stattdessen das zeitlose Prinzip übernehmen, das in einem der einflussreichsten Programmierbücher angeboten wird, die jemals geschrieben wurden:

"Bevorzugen Sie die Objektzusammensetzung gegenüber der Klassenvererbung."

- Entwurfsmuster: Elemente wiederverwendbarer objektorientierter Software.

Tatsächlich sagen die Autoren dieses Buches - umgangssprachlich als „The Gang of Four“ bekannt - weiter:

„… Unsere Erfahrung zeigt, dass Designer Vererbung als Wiederverwendungstechnik überbeanspruchen und Designs häufig wiederverwendbarer (und einfacher) werden, indem sie stärker von der Objektzusammensetzung abhängen.“

Hier ist unsere Produktliste:

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

Und hier ist unser Einkaufswagen:

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

Und jetzt können wir unsere Produktliste einfach so in unseren Warenkorb legen:

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

Verwenden Sie die Produktliste über die Eigenschaft "Artikel". Mögen:

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!