Κομψά μοτίβα στη σύγχρονη JavaScript: Ice Factory

Δουλεύω με το JavaScript μέσα και έξω από τα τέλη της δεκαετίας του '90. Στην αρχή δεν μου άρεσε πολύ, αλλά μετά την εισαγωγή του ES2015 (γνωστός και ως ES6), άρχισα να εκτιμώ τη JavaScript ως μια εξαιρετική, δυναμική γλώσσα προγραμματισμού με τεράστια, εκφραστική δύναμη.

Με την πάροδο του χρόνου, έχω υιοθετήσει διάφορα μοτίβα κωδικοποίησης που έχουν οδηγήσει σε καθαρότερο, πιο δοκιμή, πιο εκφραστικό κώδικα. Τώρα, μοιράζομαι αυτά τα μοτίβα μαζί σας.

Έγραψα για το πρώτο μοτίβο - "RORO" - στο παρακάτω άρθρο. Μην ανησυχείτε αν δεν το έχετε διαβάσει, μπορείτε να τα διαβάσετε με οποιαδήποτε σειρά.

Κομψά μοτίβα στη σύγχρονη JavaScript: RORO

Έγραψα τις πρώτες μου γραμμές JavaScript λίγο μετά την επινόηση της γλώσσας. Αν μου το είπες εκείνη τη στιγμή… medium.freecodecamp.org

Σήμερα, θα ήθελα να σας συστήσω το μοτίβο "Ice Factory".

Το Ice Factory είναι απλώς μια λειτουργία που δημιουργεί και επιστρέφει ένα παγωμένο αντικείμενο . Θα αποσυσκευάσουμε αυτήν τη δήλωση σε λίγο, αλλά πρώτα ας διερευνήσουμε γιατί αυτό το μοτίβο είναι τόσο ισχυρό.

Τα μαθήματα JavaScript δεν είναι τόσο αριστοκρατικά

Συχνά έχει νόημα η ομαδοποίηση συναφών συναρτήσεων σε ένα μόνο αντικείμενο. Για παράδειγμα, σε μια εφαρμογή ηλεκτρονικού εμπορίου, ενδέχεται να έχουμε ένα cartαντικείμενο που εκθέτει μια addProductσυνάρτηση και μια removeProductσυνάρτηση. Θα μπορούσαμε τότε να επικαλεστούμε αυτές τις λειτουργίες με cart.addProduct()και cart.removeProduct().

Εάν προέρχεστε από μια γλώσσα προγραμματισμού με επίκεντρο την τάξη, αντικειμενοστραφή, όπως Java ή C #, αυτό μάλλον αισθάνεται πολύ φυσικό.

Εάν είστε νέοι στον προγραμματισμό - τώρα που έχετε δει μια δήλωση σαν cart.addProduct(). Υποψιάζομαι ότι η ιδέα της ομαδοποίησης συναρτήσεων σε ένα αντικείμενο φαίνεται αρκετά καλή.

Λοιπόν, πώς θα δημιουργήσουμε αυτό το ωραίο μικρό cartαντικείμενο; Το πρώτο σας ένστικτο με τη σύγχρονη JavaScript μπορεί να είναι να χρησιμοποιήσετε ένα class. Κάτι όπως:

// 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})
Σημείωση : Χρησιμοποιώ ένα Array για την dbπαράμετρο για λόγους απλότητας. Σε πραγματικό κώδικα, αυτό θα ήταν κάτι σαν Μοντέλο ή Repo που αλληλεπιδρά με μια πραγματική βάση δεδομένων.

Δυστυχώς - παρόλο που φαίνεται ωραίο - τα μαθήματα σε JavaScript συμπεριφέρονται αρκετά διαφορετικά από αυτά που θα περίμενε κανείς.

Τα μαθήματα JavaScript θα σας δαγκώσουν εάν δεν είστε προσεκτικοί.

Για παράδειγμα, αντικείμενα που δημιουργούνται χρησιμοποιώντας τη newλέξη-κλειδί είναι μεταβλητά. Έτσι, μπορείτε πραγματικά να εκχωρήσετε ξανά μια μέθοδο:

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?

Ακόμα χειρότερα, τα αντικείμενα δημιουργούνται χρησιμοποιώντας την newλέξη-κλειδί κληρονομούν το prototypeτου classπου χρησιμοποιήθηκε για τη δημιουργία τους. Έτσι, οι αλλαγές σε μια τάξη prototypeεπηρεάζουν όλα τα αντικείμενα που δημιουργήθηκαν από αυτό class- ακόμα κι αν μια αλλαγή γίνει μετά τη δημιουργία του αντικειμένου!

Κοίτα αυτό:

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

Τότε υπάρχει το γεγονός ότι thisστο JavaScript δεσμεύεται δυναμικά. Έτσι, εάν περάσουμε γύρω από τις μεθόδους του cartαντικειμένου μας , μπορούμε να χάσουμε την αναφορά στο this. Αυτό είναι πολύ διαισθητικό και μπορεί να μας φέρει σε πολλά προβλήματα.

Μια κοινή παγίδα εκχωρεί μια μέθοδο εμφάνισης σε έναν χειριστή συμβάντων.

Εξετάστε τη cart.emptyμέθοδο μας .

empty () { this.db = [] }

Εάν εκχωρήσουμε αυτήν τη μέθοδο απευθείας στο clickγεγονός ενός κουμπιού στην ιστοσελίδα μας…

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

… Όταν οι χρήστες κάνουν κλικ στο κενό button, cartθα παραμείνουν γεμάτοι.

Είναι αποτυγχάνει αθόρυβα , επειδή thisθα αναφερθώ τώρα στην buttonαντί του cart. Έτσι, η cart.emptyμέθοδος μας καταλήγει να εκχωρήσει μια νέα ιδιότητα στην buttonκλήση μας dbκαι να ορίσουμε αυτήν την ιδιότητα []αντί να επηρεάσει το cartαντικείμενο db.

Αυτό είναι το είδος του bug που θα σας κάνει να τρελαίνετε γιατί δεν υπάρχει σφάλμα στην κονσόλα και η κοινή λογική σας θα σας πει ότι πρέπει να λειτουργεί, αλλά δεν λειτουργεί.

Για να λειτουργήσει πρέπει να κάνουμε:

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

Ή:

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

Νομίζω ότι ο Mattias Petter Johansson το είπε καλύτερα:

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.

Αλλά αντί να επεκτείνουμε τα αντικείμενά μας κληρονομώντας μια λίστα προϊόντων, μπορούμε αντίθετα να υιοθετήσουμε την αρχή του διαχρονικού που προσφέρεται σε ένα από τα πιο σημαντικά βιβλία προγραμματισμού που έχουν γραφτεί ποτέ:

"Αγαπημένη σύνθεση αντικειμένου έναντι κληρονομιάς κλάσης."

- Πρότυπα σχεδίασης: Στοιχεία επαναχρησιμοποιήσιμου αντικειμενοστραφούς λογισμικού.

Στην πραγματικότητα, οι συγγραφείς αυτού του βιβλίου - γνωστές ως "The Gang of Four" - συνεχίζουν να λένε:

«… Η εμπειρία μας είναι ότι οι σχεδιαστές χρησιμοποιούν υπερβολικά την κληρονομιά ως τεχνική επαναχρησιμοποίησης, και τα σχέδια συχνά γίνονται πιο επαναχρησιμοποιήσιμα (και απλούστερα) εξαρτώμενα περισσότερο από τη σύνθεση αντικειμένων.»

Ορίστε λοιπόν η λίστα προϊόντων μας:

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

Και εδώ είναι το καλάθι αγορών μας:

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

Και τώρα μπορούμε απλώς να εισάγουμε τη Λίστα Προϊόντων στο Καλάθι Αγορών μας, ως εξής:

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

Και χρησιμοποιήστε τη λίστα προϊόντων μέσω της ιδιότητας `item`. Σαν:

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.

Εάν βρήκατε αυτό το άρθρο χρήσιμο, παρακαλώ χτυπήστε αυτό το εικονίδιο χειροκρότησης πολλές φορές για να βοηθήσετε να διαδώσετε τη λέξη. Και αν θέλετε να μάθετε περισσότερα πράγματα όπως αυτό, εγγραφείτε στο ενημερωτικό δελτίο Dev Mastery παρακάτω. Ευχαριστώ!

ΕΝΗΜΕΡΩΣΗ 2019: Εδώ είναι ένα βίντεο όπου χρησιμοποιώ αυτό το μοτίβο, πολύ!