2 Reasons Why You Must Understand Delegate Prototypes
jsmanifest
Posted on July 25, 2019
Find me on medium
I was reading a section in a book about JavaScript and I came across an issue (but also the power of the concept that the issue stems from) that I want to write about, especially for newcomers to JavaScript. And even if you aren't new, there's a chance you might not know about this issue in JavaScript.
This article will go over a known anti-pattern with delegate prototypes. To users of React, the concept of this anti-pattern might be more familiar to them. But we will also go over how you can use that concept to turn things around and greatly improve the performance of your apps as you can see being used in majority of the JavaScript libraries today!
So if you want to create a library in JavaScript or have any plans to, I highly recommend you to understand how you can optimize your app by understanding how you can take advantage of delegating prototypes to improve the performance of your app if you haven't understood them yet. There's a name for it called the Flyweight Pattern which will be explained in this article.
If you don't know what a prototype is, all prototypes are basically objects that JavaScript uses to model other objects after. You can say that it's similar to classes in ways that it can construct multiple instances of objects, but it's also an object itself.
In JavaScript, all objects have some internal reference to a delegate prototype. When objects are queried by property or method lookups, JavaScript first checks the current object, and if that doesn't exist, then it proceeds to check the object's prototype, which is the delegate prototype, and then proceeds with that prototype's prototype, and so on. When it reaches the end of the prototype chain the last stop ends at the root Object
prototype. Creating objects attaches that root Object
prototype at the root level. You can branch off objects with different immediate prototypes set with Object.create().
Let's take a look at the code snippet below:
const makeSorceress = function(type) {
return {
type: type,
hp: 100,
setName(name) {
this.name = name
},
name: '',
castThunderstorm(target) {
target.hp -= 90
},
}
}
const makeWarrior = function(type) {
let battleCryInterval
return {
type: type,
hp: 100,
setName(name) {
this.name = name
},
name: '',
bash(target) {
target.hp -= 10
this.lastTargets.names.push(target.name)
},
battleCry() {
this.hp += 60
battleCryInterval = setInterval(() => {
this.hp -= 1
}, 1000)
setTimeout(() => {
if (battleCryInterval) {
clearInterval(battleCryInterval)
}
}, 60000)
return this
},
lastTargets: {
names: [],
},
}
}
const knightWarrior = makeWarrior('knight')
const fireSorc = makeSorceress('fire')
const bob = Object.create(knightWarrior)
const joe = Object.create(knightWarrior)
const lucy = Object.create(fireSorc)
bob.setName('bob')
joe.setName('joe')
lucy.setName('lucy')
bob.bash(lucy)
We have two factory functions, one of them is makeSorceress
which takes a type
of sorceress as an argument and returns an object of the sorceress's abilities. The other factory function is makeWarrior
which takes a type
of warrior as an argument and returns an object of the warrior's abilities.
We instantiate a new instance of the warrior class with type knight
along with a sorceress with type fire
.
We then used Object.create
to create new objects for bob, joe, and lucy, additionally delegating the prototype objects for each.
Bob, joe, and lucy were set with their names on the instance so that we claim and expect their own properties. And finally, bob attacks lucy with using bash
, decreasing her HP by 10 points.
At a first glance, there doesn't seem to be anything wrong with this example. But there is actually a problem. We expect bob and joe to have their own copy of properties and methods, which is why we used Object.create
. When bob bashes lucy and inserts the last targeted name into the this.lastTargets.names
array, the array will include the new target's name.
We can log that out and see it for ourselves:
console.log(bob.lastTargets.names)
// result: ["lucy"]
The behavior is expected, however when we also log the last targeted names for joe
, we see this:
console.log(joe.lastTargets.names)
// result: ["lucy"]
This doesn't make sense, does it? The person attacking lucy was bob as clearly demonstrated above. But why was joe apparently involved in the act? The one line of code explicitly writes bob.bash(lucy)
, and that's it.
So the problem is that bob and joe are actually sharing the same state!
But wait, that doesn't make any sense because we should have created their own separate copies when we used Object.create
, or so we assumed.
Even the docs at MDN explicitly says that the Object.create() method creates a new object. It does create a new object--which it did, but the problem here is that if you mutate object or array properties on prototype properties, the mutation will leak and affect other instances that have some link to that prototype on the prototype chain. If you instead replace the entire property on the prototype, the change only occurs on the instance.
For example:
const makeSorceress = function(type) {
return {
type: type,
hp: 100,
setName(name) {
this.name = name
},
name: '',
castThunderstorm(target) {
target.hp -= 90
},
}
}
const makeWarrior = function(type) {
let battleCryInterval
return {
type: type,
hp: 100,
setName(name) {
this.name = name
},
name: '',
bash(target) {
target.hp -= 10
this.lastTargets.names.push(target.name)
},
battleCry() {
this.hp += 60
battleCryInterval = setInterval(() => {
this.hp -= 1
}, 1000)
setTimeout(() => {
if (battleCryInterval) {
clearInterval(battleCryInterval)
}
}, 60000)
return this
},
lastTargets: {
names: [],
},
}
}
const knightWarrior = makeWarrior('knight')
const fireSorc = makeSorceress('fire')
const bob = Object.create(knightWarrior)
const joe = Object.create(knightWarrior)
const lucy = Object.create(fireSorc)
bob.setName('bob')
joe.setName('joe')
lucy.setName('lucy')
bob.bash(lucy)
bob.lastTargets = {
names: [],
}
console.log(bob.lastTargets.names) // result: []
console.log(joe.lastTargets.names) // result: ["lucy"]
If you change the this.lastTargets.names
property, it will be reflected with other objects that are linked to the prototype. However, when you change the prototype's property (this.lastTargets
), it will override that property only for that instance. To a new developer's point of view, this can become a little difficult to grasp.
Some of us who regularly develop apps using React have commonly dealt with this issue when managing state throughout our apps. But what we probably never paid attention to is how that concept stems through the JavaScript language itself. So to look at this more officially, it's a problem with the JavaScript language in itself that this an anti pattern.
But can't it be a good thing?
In certain ways it can be a good thing because you can optimize your apps by delegating methods to preserve memory resources. After all, every object just needs one copy of a method, and methods can just be shared throughout all the instances unless that instance needs to override it for additional functionality.
For example, let's look back at the makeWarrior
function:
const makeWarrior = function(type) {
let battleCryInterval
return {
type: type,
hp: 100,
setName(name) {
this.name = name
},
name: '',
bash(target) {
target.hp -= 10
this.lastTargets.names.push(target.name)
},
battleCry() {
this.hp += 60
battleCryInterval = setInterval(() => {
this.hp -= 1
}, 1000)
setTimeout(() => {
if (battleCryInterval) {
clearInterval(battleCryInterval)
}
}, 60000)
return this
},
lastTargets: {
names: [],
},
}
}
The battleCry
function is probably safe to be shared throughout all prototypes since it doesn't depend on any conditions to function correctly, besides that it requires an hp
property which is already set upon instantiation. Newly created instances of this function do not necessarily need their own copy of battleCry
and can instead delegate to the prototype object that originally defined this method.
The anti pattern of sharing data between instances of the same prototype is that storing state is the biggest drawback, because it can become very easy to accidentally mutate shared properties or data that shouldn't be mutated, which has long been a common source of bugs for JavaScript applications.
We can see this practice being in use for a good reason actually, if we look at how the popular request package instantiates the Har
function in this source code:
function Har(request) {
this.request = request
}
Har.prototype.reducer = function(obj, pair) {
// new property ?
if (obj[pair.name] === undefined) {
obj[pair.name] = pair.value
return obj
}
// existing? convert to array
var arr = [obj[pair.name], pair.value]
obj[pair.name] = arr
return obj
}
So why doesn't Har.prototype.reducer
just get defined like this?
function Har(request) {
this.request = request
this.reducer = function(obj, pair) {
// new property ?
if (obj[pair.name] === undefined) {
obj[pair.name] = pair.value
return obj
}
// existing? convert to array
var arr = [obj[pair.name], pair.value]
obj[pair.name] = arr
return obj
}
}
As explained previously, if newer instances were to be instantiated, it would actually degrade the performance of your apps since it would be [recreating new methods on each instantiation], which is the reducer
function.
When we have separate instances of Har
:
const har1 = new Har(new Request())
const har2 = new Har(new Request())
const har3 = new Har(new Request())
const har4 = new Har(new Request())
const har5 = new Har(new Request())
We're actually creating 5 separate copies of this.reducer
in memory because the method is defined in the instance level. If the reducer was defined directly on the prototype, multiple instances of Har
will delegate the reducer
function to the method defined on the prototype! This is an example of how to take advantage of delegate prototypes and improve the performance of your apps.
Conclusion
That's all I needed to say. I hope you learned something from this post, and see you next time!
Find me on medium
Posted on July 25, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.