Everything you need to know about prototypes, closures, and performance

Original author: Diego Castorina
  • Transfer
  • Tutorial

Not so simple


At first glance, JavaScript may seem like a fairly simple language. Perhaps this is due to a fairly flexible syntax. Or because of similarities with other well-known languages, such as Java. Well, or because of the relatively small number of data types, compared to Java, Ruby, or .NET.

But in reality, JavaScript syntax is much less simple and obvious than it might seem at first. Some of the most characteristic features of JavaScript are still misunderstood and not fully understood, especially among experienced developers. One of these features is the performance of data (properties and variables) retrieval and the resulting performance issues.

In JavaScript, data searches depend on two things: prototype inheritance and scope chains. For the developer, an understanding of these two mechanisms is absolutely necessary, because it leads to an improvement in the structure, and, often, also in code performance.

Getting properties in the prototype chain


When accessing a property in JavaScript, the entire prototype chain of the object is scanned.

Every function in JavaScript is an object. When a function is called with an operator new, a new object is created.

function Person(firstName, lastName) {
  this.firstName = firstName;
  this.lastName = lastName;
}
var p1 = new Person('John', 'Doe');
var p2 = new Person('Robert', 'Doe');


In the example above p1, there are p2two different objects, each of which is created using the constructor Person. As you can see from the following example, they are independent instances Person:

console.log(p1 instanceof Person); // выводит 'true'
console.log(p2 instanceof Person); // выводит 'true'
console.log(p1 === p2);            // выводит 'false'


Once functions are in JavaScript objects, they can have properties. The most important property they have is called prototype.

prototyperepresenting an object is inherited from the parent prototype again and again until it reaches the very top level. This is often called the prototype chain . At the beginning of the chain always Object.prototype(i.e., at the highest level of the prototype chain); it contains methods toString(), hasProperty(), isPrototypeOf()and so on. d.



The prototype of each function can be expanded own methods and properties.

When creating a new instance of an object (by calling a function with an operator new), it inherits all the properties through the prototype. However, keep in mind that instances do not have direct access to the prototype object, only to its properties.

// Расширим прототип Person из примера выше
// методом 'getFullName':
Person.prototype.getFullName = function() {
  return this.firstName + ' ' + this.lastName;
}
// Объект p1 также из примера выше
console.log(p1.getFullName());            // выводит 'John Doe'
// но у p1 нет прямого доступа к объекту 'prototype'...
console.log(p1.prototype);                // выводит 'undefined'
console.log(p1.prototype.getFullName());  // выкидывает ошибку


This is an important and subtle point: even if it p1was created before the definition of the method getFullName, it will still have access to it, because its prototype was a prototype Person.

(It is worth mentioning that browsers keep a reference to the prototype in the property __proto__, but its use is very spoiling karma, at least because it is not in the ECMAScript standard , so do not use it ).

Since the instance Personp1does not have direct access to the prototype object, we must rewrite the method getFullNamein p1like this:

// Мы ссылаемся на p1.getFullName, *НЕ* p1.prototype.getFullName,
// ибо p1.prototype нет:
p1.getFullName = function(){
  return 'Я есъм аноним';
}


Now it p1has its own property getFullName. But an instance of p2its own implementation does not have this property. Accordingly, the call p1.getFullNameyanks the object’s own method p1, while the call p2.getFullName()goes up the prototype chain to Person.

console.log(p1.getFullName()); // выводит 'Я есъм аноним'
console.log(p2.getFullName()); // выводит 'Robert Doe'


image

Another thing to be wary of is the ability to dynamically change the prototype of an object:

function Parent() {
  this.someVar = 'someValue';
};
// расширяем прототип Parent, что бы определить метод 'sayHello'
Parent.prototype.sayHello = function(){
    console.log('Hello');
};
function Child(){
  // убеждаемся что родительский конструктор вызывается
  // и состояние корректно инициализируется.
  Parent.call(this);
};
// расширяем прототип Child что бы задать свойство 'otherVar'...
Child.prototype.otherVar = 'otherValue';
// ... но затем мы устанавливаем вместо прототипа Child прототип Parent
// (в нем нет никакого свойства 'otherVar',
//  поэтому и в прототипе Child свойства ‘otherVar’ больше не определено)
Child.prototype = Object.create(Parent.prototype);
var child = new Child();
child.sayHello();            // выводит 'Hello'
console.log(child.someVar);  // выводит 'someValue'
console.log(child.otherVar); // выводит 'undefined'


When using prototype inheritance, remember that the properties of the child prototype should be set after inheritance from the parent.



So, getting properties in the prototype chain works as follows:

  • If the object has a property with the desired name, it is returned. (Using the method, hasOwnPropertyyou can verify that this is precisely the property of the object).
  • If the object has nothing similar, look at the prototype of the object.
  • If there is nothing here, then we check his own prototype already.
  • And so on, until we find the desired property.
  • If we get to Object.prototype but find nothing, the property is considered not set.


Understanding how prototype inheritance works is generally important for developers, but beyond that it is important because of its impact (sometimes noticeable) on performance. As written in the V8 documentation, most JavaScript engines use a dictionary-like data structure to store properties. Therefore, calling any property requires a dynamic search to find the desired property. This makes accessing properties in JavaScript much slower than accessing instance variables in languages ​​such as Java or Smalltalk.

Variable search through scope chain


Another JavaScript search engine is based on closure.

To understand how this works, it is necessary to introduce such a concept as a context of execution .

In JavaScript, there are two types of execution context:

  • Global, created when JavaScript is run.
  • Local, created when a function is called


Execution contexts are organized as a stack. At the bottom of the stack is always a global context unique to each program. Each time a function is encountered, a new execution context is created and placed at the top of the stack. As soon as the function is completed, its context is thrown out of the stack.

// глобальный контекст
var message = 'Hello World';
var sayHello = function(n){
  // локальный контекст 1 создается и помещается в стек
  var i = 0;
  var innerSayHello = function() {
    // локальный контекст 2 создается и помещается в стек
    console.log((i + 1) + ':  ' + message);
    // локальный контекст 2 покидает стек
  }
  for (i = 0; i < n; i++) {
    innerSayHello();
  }
  // локальный контекст 1 покидает стек
};
sayHello(3);
// Выводит:
// 1:  Hello World
// 2:  Hello World
// 3:  Hello World


In each execution context, there is a special object called the scope chain used to resolve variables. The chain is essentially a stack of available execution contexts, from current to global. (To be more precise, the object at the top of the stack is called the Activation Object and contains: references to local variables for the executable function, the given arguments of the function, and two "special" objects: thisand arguments).



note how the diagram thisindicates the object by default window, and that the global object contains other objects, such as consoleand location.

When trying to resolve a variable through a chain of scopes, the current context is first checked for the desired variable. If no matches are found, the next context object in the chain is checked, and so on, until the desired one is found. If nothing is found, throws ReferenceError.

In addition, it is important to note that a new scope is added if there are blocks try-catchor with. In all these cases, a new object is created and placed at the top of the chain of visibility areas.

function Person(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
};
function persist(person) {
  with (person) {
    // Объект 'person' попадает в цепочку областей видимости
    // как только мы попадаем в блок "with", так что мы можем просто ссылаться на
    // 'firstName' и 'lastName', а не на person.firstName и
    // person.lastName
    if (!firstName) {
      throw new Error('FirstName is mandatory');
    }
    if (!lastName) {
      throw new Error('LastName is mandatory');
    }
  }
  try {
    person.save();
  } catch(error) {
    // Новая область видимости, содержащая объект 'error'
    console.log('Impossible to store ' + person + ', Reason: ' + error);
  }
}
var p1 = new Person('John', 'Doe');
persist(p1);


To fully understand how a variable is resolved in a scope environment, it is important to remember that JavaScript does not currently have scope at block level.

for (var i = 0; i < 10; i++) {
  /* ... */
}
// 'i' всё ещё в области видимости!
console.log(i);  // выводит '10'


In most other languages, the code above will result in an error, because the “life” (ie, scope) of the variable iwill be limited to the for block. But not in JavaScript. i is added to the Activation Object at the top of the scope chain, and remains there until the object is deleted, which happens after the execution context is removed from the stack. This behavior is known as floating up variables.

It is worth mentioning that block-level scope was introduced in JavaScript with the advent of a new keyword let. It is already available in JavaScript 1.7 and should become an officially supported keyword starting with ECMAScript 6.

Performance impact


The way to find and resolve variables and properties is one of the key features of JavaScript, but at the same time it is one of the most subtle and tricky points to understand.

The search operations that we described along the prototype chain or scope are repeated every time a property or variable is called. When this happens in a loop or during another heavy operation, you will immediately feel the impact on code performance, especially against the background of the single-threaded nature of JavaScript, which prevents several operations from being executed simultaneously.

var start = new Date().getTime();
function Parent() { this.delta = 10; };
function ChildA(){};
ChildA.prototype = new Parent();
function ChildB(){}
ChildB.prototype = new ChildA();
function ChildC(){}
ChildC.prototype = new ChildB();
function ChildD(){};
ChildD.prototype = new ChildC();
function ChildE(){};
ChildE.prototype = new ChildD();
function nestedFn() {
  var child = new ChildE();
  var counter = 0;
  for(var i = 0; i < 1000; i++) {
    for(var j = 0; j < 1000; j++) {
      for(var k = 0; k < 1000; k++) {
        counter += child.delta;
      }
    }
  }
  console.log('Final result: ' + counter);
}
nestedFn();
var end = new Date().getTime();
var diff = end - start;
console.log('Total time: ' + diff + ' milliseconds');


In the code above, we have a long inheritance tree and three nested loops. In the deepest loop, the counter increments with the value of the variable delta. But the value of the delta is determined at the very top of the inheritance tree! This means that every time you call, the child.deltawhole tree is scanned from top to bottom . This can adversely affect performance.

Realizing this once, we can easily improve performance nestedFnby caching a value locally child.deltain a variable delta:

function nestedFn() {
  var child = new ChildE();
  var counter = 0;
  var delta = child.delta;  // cache child.delta value in current scope
  for(var i = 0; i < 1000; i++) {
    for(var j = 0; j < 1000; j++) {
      for(var k = 0; k < 1000; k++) {
        counter += delta;  // no inheritance tree traversal needed!
      }
    }
  }
  console.log('Final result: ' + counter);
}
nestedFn();
var end = new Date().getTime();
var diff = end - start;
console.log('Total time: ' + diff + ' milliseconds');


Naturally, we can do this if we only know for sure that the value child.deltawill not change during the execution of the cycles; otherwise, we will have to periodically update the value of the variable with the current value.

So, let's run both versions now nestedFnand see if there is a noticeable performance difference between the two.

diego@alkadia:~$ node test.js 
Final result: 10000000000
Total time: 8270 milliseconds


It took about 8 seconds to complete. It's a lot.

Now let's see what with our optimized version:

diego@alkadia:~$ node test2.js 
Final result: 10000000000
Total time: 1143 milliseconds


This time only a second. Much faster!

The use of local variables to prevent heavy queries is used both to search for a property (along the prototype chain) and to resolve variables (through the scope).

Moreover, such "caching" of values ​​(that is, in local variables) gives a gain when using some common JavaScript libraries. Take jQuery, for example. It supports “selectors,” a mechanism for obtaining one or more DOM elements. The ease with which this happens “helps” to forget how much searching the selector is a difficult operation. Therefore, storing search results in a variable gives a tangible increase in performance.

// это причина поиска по DOM селектора $('.container') "n" раз
for (var i = 0; i < n; i++) {
    $('.container').append(“Line “+i+”
”); } // и снова... // ок, мы ищем селектор $('.container') всего раз, // зато измываемся над его DOM "n" раз var $container = $('.container'); for (var i = 0; i < n; i++) { $container.append("Line "+i+"
"); } // гораздо лучше было бы вот так... // так мы ищем по DOM селектор $('.container') один раз, // И модифицируем DOM только один раз var $html = ''; for (var i = 0; i < n; i++) { $html += 'Line ' + i + '
'; } $('.container').append($html);


The second approach will give significantly better performance than the first, especially on pages with a large number of elements.

To summarize



JavaScript data search is quite different from other languages, and there are many nuances in it. Therefore, it is necessary to fully and most importantly correctly understand its concepts in order to truly master this language. This knowledge will bring cleaner, more reliable code and improved performance.

Also popular now: