DEV Community

Cover image for 2 Reasons Why You Must Understand Delegate Prototypes
jsmanifest
jsmanifest

Posted on • Originally published at jsmanifest.com

2 Reasons Why You Must Understand Delegate Prototypes

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)
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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: [],
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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

Top comments (1)

Collapse
 
senninseyi profile image
Senninseyi

what about the use of proptypes their functions looks similar