Today, we’re going to dive into one of those seemingly small tweaks you can make in JavaScript that will leave even the most scrutinizing performance fiend smiling from ear to ear — Utilizing Prototypes for Method Declarations.
Here’s how you would traditionally define a `Cat` class and its `meow` method:
// The old non-optimized way 😿
function Cat(name) {
this.name = name;
this.meow = function() { // *Gasp
* Declaring directly on the instance!
console.log(`${this.name} says meow.`);
};
}
let fluffy = new Cat('Fluffy');
fluffy.meow(); // Fluffy says meow.
The issue with this approach is that every time we create a new `Cat` (and judging by my internet search history, that’s often), it creates a brand-new `meow` function *just for Fluffy*. This redundancy can pile up faster than cats demanding breakfast!
Now let’s see how utilizing prototypes can make both V8 and our memory management purr with delight:
// The shiny new optimized way 🌟
function Cat(name) {
this.name = name;
}
Cat.prototype.meow = function() { // Ahh, delegation! So efficient.
console.log(`${this.name} says meow.`);
};
let whiskers = new Cat('Whiskers');
whiskers.meow(); // Whiskers says meow.
In this refactored feline masterpiece, `meow` resides peacefully in the realm of prototypes — one method to rule them all (cats).
Common Missteps with Utilizing Prototypes for Method Declarations
As with any arcane art, there are missteps that can lead us astray from performance enlightenment. Let’s take a look at what happens when we forget our prototypical wisdom:
// The well-intentioned but inefficient approach 😸
function Kitty(name) {
this.name = name;
}
Kitty.prototype.playfulPurr = () => {
// Using an arrow function here is actually an anti-pattern!
console.log(`Oh ${this.name}, you're so playful! Purrrrr.`);
};
let mittens = new Kitty("Mittens");
mittens.playfulPurr();
// Error: this.name is undefined - even kitties have identity crises sometimes.
By incorrectly using an arrow function (`=>`) inside `playfulPurr`, we lose access to `this`. Our poor little Mittens don’t know who they are anymore!
Additionally, overusing instance specific methods via direct declaration or improper modification practices can lead us down a treacherous path…
The Engine Room: How V8 Reacts to Prototypal Wisdom
When we employ prototypes effectively, we’re essentially telling the V8 engine: “Hey buddy, these methods? They’re all the same deep down; stop duplicating them!” And just like that nosy neighbor who peaked at everyone’s mail back in ’99 (you know who you are…), V8 is thrilled for the efficiencies.
The magic lies within how V8 optimizes property lookups. When a method is called on an object instance but lives happily ever after in its prototype chain instead, V8 can use hidden classes and inline caching to find and execute our beloved functions faster than you can say cat the bullsh*t (pun intended).
By reducing memory churn (less redeclaring functions per instance) and improving method lookup times (thanks, predictable hidden classes!), faster than Busta Rhymes spitting fire.
Alright, let’s dive into the secret sauce of what’s cooking in the browser’s kitchen! Ever wondered what happens when you go wild creating a bazillion objects? Well, strap in, because we’re about to play detective with the memory tab in developer tools. This nifty feature in browsers like Chrome is like x-ray vision for your web page’s memory munchies. It’s where you can spy on your JavaScript objects, DOM nodes, and all the other memory-hogging critters.
To put on our detective hats, we’re gonna whip up a whopping 10,000 objects. Then, we’ll sneak a peek at the memory tab to catch these little rascals red-handed, showing up with each line of code. It’s like a where’s Waldo, but for code!
Code example without prototype
function UserProfileNoPrototype(name, email) {
this.name = name;
this.email = email;
this.displayInfo = function() {
return `Name: ${this.name}, Email: ${this.email}`;
};
}
var usersNoPrototype = [];
for (let i = 0; i < 10000; i++) {
usersNoPrototype.push(new UserProfileNoPrototype(`User${i}`, `@example.com">user${i}@example.com`));
}
Code example with Prototype
function UserProfileWithPrototype(name, email) {
this.name = name;
this.email = email;
}
UserProfileWithPrototype.prototype.displayInfo = function() {
return `Name: ${this.name}, Email: ${this.email}`;
};
var usersWithPrototype = [];
for (let i = 0; i < 10000; i++) {
usersWithPrototype.push(new UserProfileWithPrototype(`User${i}`, `@example.com">user${i}@example.com`));
}
What are we looking at here? What’s going on? Let’s break it down
UserProfileWithPrototype:
- Shallow Size: This is the size of memory that is taken up by the object itself, not including any referenced objects. For UserProfileWithPrototype, the shallow size is 200,000 bytes.
- Retained Size: This is the total size of memory that is freed once the object is garbage collected. It includes the object itself and any other objects that are only referenced by this object. For UserProfileWithPrototype, the retained size is 800,000 bytes, which is 10% of the total memory.
- Distance: This is the distance from the garbage collector root to this object. A distance of 3 means there are three references that the garbage collector would need to traverse to get from the root to this object.
UserProfileNoPrototype:
- Shallow Size: The shallow size here is 240,000 bytes, which indicates that instances of UserProfileNoPrototype are taking up more space individually than UserProfileWithPrototype.
- Retained Size: The retained size is significantly larger at 1,160,000 bytes (15% of total), suggesting that instances of UserProfileNoPrototype may be holding onto more memory, possibly due to not sharing methods via a prototype.
- Distance: The distance is the same as in the UserProfileWithPrototype, which is 3.
Analysis:
- Memory Usage: The use of prototypes in UserProfileWithPrototype is more memory efficient. By defining methods on the prototype, all instances share these methods, reducing the memory footprint. On the other hand, UserProfileNoPrototype is defining methods within each instance, leading to more memory usage.
- Retained Size Impact: The larger retained size for UserProfileNoPrototype means that this constructor’s pattern is creating more closures or holding onto more data that can’t be cleaned up until the object is garbage collected.
- Prototype Benefits: Using prototypes is a common practice in JavaScript to save memory, especially when creating many instances of an object. If each object instance contains a copy of all methods, the memory usage can increase rapidly, as indicated by the comparison between the two constructors.
Memory Efficiency
If each product object has its method for displaying more information, and you do not use prototypes, each product instance will carry its copy of the display function. This can be highly inefficient when you have a large inventory of products:
- Without Prototype:
(Number of Products) x (Size of the Display Method) = Total Memory for Display Methods
- With Prototype:
(Size of the Display Method) = Total Memory for Display Method
Using prototypes means that all product instances share the same displayMoreInfo
function, greatly reducing the memory footprint of your page. If the display function is complex or there are multiple functions per product, the savings are even greater.
Performance Improvement
The performance gains come in two main areas:
- Load Time: When the product information is retrieved from the database and product objects are instantiated in the browser, using prototypes means that the JavaScript engine needs to compile the method(s) only once. Without prototypes, it might need to compile the method for each instance, which can slow down the initial loading of the page.
- Runtime Performance: JavaScript engines can optimize prototype methods better because they are not redefined for each instance. This can result in faster execution when methods are called.
- Garbage Collection: With fewer functions in memory, the garbage collector has less work to do, which can lead to smoother performance, especially on devices with limited resources.
Quantifying the Improvement
In this hypothetical example, if each display method takes up 1KB of memory and you have 10,000 products, using prototypes would save approximately:
- Without Prototype:
10,000 products x 1KB = 10,000KB (or ~9.77MB)
- With Prototype:
1KB = 0.001MB
In this scenario, using prototypes saves nearly 9.77MB of memory on the display methods alone… That’s insane. Creating a light weight website or webapp and using this approach is like baking delicate soufflé and garnish it with an anvil.
But, you might ask yourself, is it really that significant? And will I encounter such things in my day to day work?
The answer is absolutely! This is something you can do without even realizing it (I did) and you can even ask one of the AI tools (just kidding of course it’s ChatGPT) to write code for you and it can create this code but it doesn’t not have a context of what you are building and what you are trying to achieve.
Let’s imagine that you have an ecommerce website and you get all the products from the database and present them on the page. When creating each product you might want to get additional data about the product and you might do something like the code example from above with no prototype. What will happen is that you will now create closure, which is data saved. in the function (the instance of the object) so that it will be available later, and this my friends will probably make the browser explode.
What is closure?
When you define a function within another function in JavaScript, the inner function has access to the outer function’s scope even after the outer function has returned. This is what we call a closure. If the inner function is retained (for example, as a method on an object), the variables it closes over are also retained and cannot be garbage collected. This will lead to higher memory consumption if those variables hold significant amounts of data.
Here’s an illustrative example without using prototypes:
function Product(name) {
this.name = name;
this.getDetails = function() {
// Imagine this call retrieves a large amount of data
var productDetails = someService.callToGetProductDetails(name);
return productDetails;
};
}
In this example, if someService.callToGetProductDetails(name) retrieves a large amount of data, and getDetails is a method created for every Product instance, each instance will hold onto the productDetails data as long as that instance exists. If you have many Product instances, this can lead to significant memory use and then you will wonder why the website/app is running so slowly…
With prototypes, you typically do not create closures in the same way. Instead, you’d have:
function Product(name) {
this.name = name;
}
Product.prototype.getDetails = function() {
// The service call would still retrieve data, but it's not stored on the object
var productDetails = someService.callToGetProductDetails(this.name);
return productDetails;
};
In this case, getDetails is shared across all Product instances, and no separate closure is created for each one. The data fetched by getDetails is not retained after the function executes unless it’s explicitly stored elsewhere. This can be significantly more memory efficient, especially if productDetails contains a lot of data and there are many Product instances.
However, it’s crucial to note that the mere existence of a closure does not necessarily result in high memory usage. It’s the size of the data that the closure retains that matters. If the “closed over” data is small and the number of object instances is not large, the impact on memory may be negligible. But with large, complex data and many objects, using prototypes will lead to better memory efficiency.
When You Don’t Need a Closure
If you’re just displaying information that doesn’t change per instance or doesn’t rely on instance specific data that needs to be retained, you don’t need a closure. You can use prototype methods for this. For example, a method to display static information about a product can be defined on the prototype:
function Product(name) {
this.name = name;
}
Product.prototype.displayInfo = function() {
console.log(`Product Name: ${this.name}`);
};
In this case, displayInfo does not create a closure over any variables. It accesses this.name, which refers to the property of the object on which displayInfo is called.
When You Might Need a Closure
If you have functionality that requires retaining or capturing dynamic data unique to each instance, you might use closures. For example, if selecting a color triggers a dynamic change that should be remembered for that specific instance, you could use a closure:
function Product(name) {
this.name = name;
this.setColor = function(selectedColor) {
var color = selectedColor; // Closure over `color`
// Other logic using `color`
};
}
Here, setColor creates a closure over the color variable. Each Product instance has its own setColor method and its own color variable.
Concluding Our Performance Piece
In conclusion my dear readers
When it comes to performance tuning in JavaScript:
- Treat methods like good bourbon — best shared among friends (instances).
- Avoid clinginess — no one likes those sticky notes plastered everywhere (direct declarations).
- Trust in tradition- there’s wisdom passed down through generations (*cough*prototypes *cough*).
Alrighty, quick tip before we zoom off: in JavaScript, it’s often the little tweaks that make the biggest splash. So, keep it chill, make those small changes, and watch your code do some seriously cool stuff. Stay light, keep coding fun, and let those little tweaks lead to big wins! 🌟🚀👋