June 14th, 2022
In JavaScript we are fortunate to have an automatic garbage collection mechanism built into the language. There are some cases where it is essential to manage the memory ourselves. This is where the Flyweight Design Pattern can come in handy as it intends to share commonalities into objects that clients can benefit from. This is an efficient way to write scalable applications as it benefits us to allow users to consume the least amount of memory usage as possible.
In this post we will be going over the power of the the Flyweight Design Pattern in JavaScript and leverage it to create more memory efficient applications. We will go over the problems that arise and showcase how the flyweight pattern knocks them all away.
If you've used a JavaScript library before, there's a good chance you've worked directly on some variation of a flyweight pattern given to you whether it was through a JavaScript library, framework, or even the DOM.
Lets take a look at this array of objects that represent objects as DOM elements:
const elems = [
{
tagName: 'div',
style: { width: '28.5px', height: '20px' },
children: [
{ tagName: 'input', style: { width: '28.5px', height: '20px' } },
{ tagName: 'input', style: { width: '28.5px', height: '20px' } },
{ tagName: 'select', style: { width: '28.5px', height: '20px' } },
{ tagName: 'input', style: { width: '28.5px', height: '20px' } },
],
},
]
If you look at the children
array notice that there are three objects that are structurally identical:
This is already an issue because if we were to continue this practice and our project grows larger our program will take a big hit in performance because it will create three separate objects in memory although they are all structurally equivalent. Imagine if there were 1000?
When we go over real examples of the flyweight design pattern this is basically what the flyweight intends to do behind the scenes:
const inputElement = {
tagName: 'input',
style: { width: '28.5px', height: '20px' },
}
const elems = [
{
tagName: 'div',
style: { width: '28.5px', height: '20px' },
children: [
inputElement,
inputElement,
{ tagName: 'select', style: { width: '28.5px', height: '20px' } },
inputElement,
],
},
]
Notice how inputElement
is mentioned multiple times.
We will go over examples in different object structures (like classes for example) but ultimately this concept is always applied.
A good way to think of the flyweight pattern is "things being shared". In our previous example we shared the inputElement
object three times. We minimized the use of memory by at least three occasions.
A common implementation you might encounter frequently are those that implement some sort of get method to retrieve objects in some cache in memory:
class Coin {
constructor(value) {
this.value = value
}
}
class CoinCollage {
coins = new Map()
create(value) {
let coin = this.coins.get(value)
if (!coin) {
coin = new Coin(value)
this.coins.set(value, coin)
}
return coin
}
}
const coins = new CoinCollage()
const dime = coins.create(0.1)
const quarter = coins.create(0.25)
const dollar = coins.create(1.0)
console.log(coins)
This is a great technique to reuse and share previously created objects that don't need to be recreated since it preserves the user's memory.
It's used in many libraries like ts-morph which usually prefix those methods with something like "getOrCreate<the rest of the variable's name>"
Flyweight implementations usually become useful when we add some intrinsic state to it where we can leverage it to make memory efficient decisions.
In our previous examples if we look at our CoinCollage
class we can spot our intrinsic state here:
We first attempted to grab a previously created Coin
instance with the value
asked for. If our application created it previously we can avoid the unnecessary re-creation and just return the previous Coin
we had stored.
This is a benefit that library authors most often seek.
Another important role to the flyweight pattern is extrinsic state. These are states that exist outside of the flyweight implementation but would like to work with the flyweight. A common use case are states that are observed by callback functions:
class CoinCollage {
#onCreate = undefined
coins = new Map()
create(value) {
let coin = this.coins.get(value)
if (!coin) {
coin = new Coin(value)
this.#onCreate?.(coin)
this.coins.set(value, coin)
}
return coin
}
set onCreate(fn) {
this.#onCreate = fn
}
}
const coins = new CoinCollage()
const createdCoinValues = []
coins.onCreate = function onCreate(coin) {
createdCoinValues.push(coin)
console.log(
`Created new coin of value ${coin.value}. The total number of coins is now ${createdCoinValues.length}`,
)
if (createdCoinValues.length >= 3) {
console.warn(`You are 2 coins away from the maximum coins allowed`)
}
}
const dime = coins.create(0.1)
const quarter = coins.create(0.25)
const dollar = coins.create(1.0)
With this capability in place we can halt further creations of Coin
if our business logic only applies to the first 5 coins. This is a great companion to our instrinsic state!
Something that was hard for me to grasp in the earlier stages of my JavaScript development career was the decision making between prototypal inheritance and factory functions. A mystery that dwelled onto me for way too long was figuring out why I often saw functions being created this way:
function Calculator() {
this.value = 0
}
Calculator.prototype.add = function add(num1, num2) {
return num1 + num2
}
Calculator.prototype.subtract = function subtract(num1, num2) {
return num2 - num1
}
As opposed to having objects created this way:
function makeCalculator() {
let value = 0
return {
add(num1, num2) {
return num1 + num2
},
subtract(num1, num2) {
return num2 - num1
},
}
}
In the first example, new instantiations of our Calculator
class will inherit and re-use the same properties/methods defined on its prototype. That means these:
In the second example, new instantiations of our makeCalculator
factory will not inherit and re-use add
and subtract
but will instead receive an entirely new add
and subtract
functions even though they are identical in shape and code size.
They both have their pros and cons in general. But in the context of flyweight, prototypal inheritance is recommended. For all else however I always go with the factory function but that's beyond the scope of this post.
The superagent library showcases the flyweight design pattern in practice using prototypal inheritance in the Request class. Modern programs make frequent requests to do tasks like data fetching. The thing is, these requests cannot be re-used for subsequent requests. They need to instantiate new instances of some Request
object. When programs work with Request
objects it's really unnecessary to make copies of its methods and properties that don't rely on the current state.
One example of this is the query method on the Request
object. The implementation details hardly ever change so there's no point to create and carry copies to new objects.
And that concludes the end of this post! I hope you found this to be valuable and look out for more in the future!
Tags
© jsmanifest 2023