Cadenas de prototipos de JavaScript, cadenas de alcance y rendimiento: lo que necesita saber
Publicado: 2022-03-11JavaScript: más de lo que parece
JavaScript puede parecer un lenguaje muy fácil de aprender al principio. Tal vez sea por su sintaxis flexible. O quizás sea por su similitud con otros lenguajes muy conocidos como Java. O tal vez sea porque tiene muy pocos tipos de datos en comparación con lenguajes como Java, Ruby o .NET.
Pero, en verdad, JavaScript es mucho menos simplista y más matizado de lo que la mayoría de los desarrolladores se dan cuenta inicialmente. Incluso para los desarrolladores con más experiencia, algunas de las características más destacadas de JavaScript continúan siendo mal entendidas y generan confusión. Una de esas características es la forma en que se realizan las búsquedas de datos (propiedades y variables) y las ramificaciones de rendimiento de JavaScript a tener en cuenta.
En JavaScript, las búsquedas de datos se rigen por dos cosas: la herencia de prototipos y la cadena de alcance . Como desarrollador, comprender claramente estos dos mecanismos es esencial, ya que hacerlo puede mejorar la estructura y, a menudo, el rendimiento de su código.
Búsquedas de propiedades a través de la cadena de prototipos
Al acceder a una propiedad en un lenguaje basado en prototipos como JavaScript, se lleva a cabo una búsqueda dinámica que involucra diferentes capas dentro del árbol de prototipos del objeto.
En JavaScript, cada función es un objeto. Cuando se invoca una función con el operador new
, se crea un nuevo objeto. Por ejemplo:
function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; } var p1 = new Person('John', 'Doe'); var p2 = new Person('Robert', 'Doe');
En el ejemplo anterior, p1
y p2
son dos objetos diferentes, cada uno creado usando la función Person
como constructor. Son instancias independientes de Person
, como lo demuestra este fragmento de código:
console.log(p1 instanceof Person); // prints 'true' console.log(p2 instanceof Person); // prints 'true' console.log(p1 === p2); // prints 'false'
Dado que las funciones de JavaScript son objetos, pueden tener propiedades. Una propiedad particularmente importante que tiene cada función se llama prototype
.
prototype
, que en sí mismo es un objeto, hereda del prototipo de su padre, que hereda del prototipo de su padre, y así sucesivamente. Esto a menudo se conoce como la cadena prototipo . Object.prototype
, que siempre está al final de la cadena de prototipos (es decir, en la parte superior del árbol de herencia de prototipos), contiene métodos como toString()
, hasProperty()
, isPrototypeOf()
, etc.
El prototipo de cada función se puede ampliar para definir sus propios métodos y propiedades personalizados.
Cuando instancias un objeto (invocando la función usando el operador new
), hereda todas las propiedades en el prototipo de esa función. Sin embargo, tenga en cuenta que esas instancias no tendrán acceso directo al objeto prototype
sino solo a sus propiedades. Por ejemplo:
// Extending the Person prototype from our earlier example to // also include a 'getFullName' method: Person.prototype.getFullName = function() { return this.firstName + ' ' + this.lastName; } // Referencing the p1 object from our earlier example console.log(p1.getFullName()); // prints 'John Doe' // but p1 can't directly access the 'prototype' object... console.log(p1.prototype); // prints 'undefined' console.log(p1.prototype.getFullName()); // generates an error
Aquí hay un punto importante y un tanto sutil: incluso si p1
se creó antes de que se definiera el método getFullName
, aún tendrá acceso a él porque su prototipo es el prototipo Person
.
(Vale la pena señalar que los navegadores también almacenan una referencia al prototipo de cualquier objeto en una propiedad __proto__
, pero es realmente una mala práctica acceder directamente al prototipo a través de la propiedad __proto__
, ya que no es parte de la especificación estándar del lenguaje ECMAScript, así que no no lo hagas! )
Dado que la instancia p1
del objeto Person
no tiene acceso directo al objeto prototype
, si queremos sobrescribir getFullName
en p1
, lo haríamos de la siguiente manera:
// We reference p1.getFullName, *NOT* p1.prototype.getFullName, // since p1.prototype does not exist: p1.getFullName = function(){ return 'I am anonymous'; }
Ahora p1
tiene su propia propiedad getFullName
. Pero la instancia p2
(creada en nuestro ejemplo anterior) no tiene ninguna propiedad propia. Por lo tanto, invocar p1.getFullName()
accede al método getFullName
de la propia instancia p1
, mientras que invocar p2.getFullName()
asciende en la cadena de prototipos hasta el objeto prototipo Person
para resolver getFullName
:
console.log(p1.getFullName()); // prints 'I am anonymous' console.log(p2.getFullName()); // prints 'Robert Doe'
Otra cosa importante a tener en cuenta es que también es posible cambiar dinámicamente el prototipo de un objeto. Por ejemplo:
function Parent() { this.someVar = 'someValue'; }; // extend Parent's prototype to define a 'sayHello' method Parent.prototype.sayHello = function(){ console.log('Hello'); }; function Child(){ // this makes sure that the parent's constructor is called and that // any state is initialized correctly. Parent.call(this); }; // extend Child's prototype to define an 'otherVar' property... Child.prototype.otherVar = 'otherValue'; // ... but then set the Child's prototype to the Parent prototype // (whose prototype doesn't have any 'otherVar' property defined, // so the Child prototype no longer has 'otherVar' defined!) Child.prototype = Object.create(Parent.prototype); var child = new Child(); child.sayHello(); // prints 'Hello' console.log(child.someVar); // prints 'someValue' console.log(child.otherVar); // prints 'undefined'
Cuando utilice la herencia de prototipos, recuerde definir las propiedades en el prototipo después de haberlo heredado de la clase principal o haber especificado un prototipo alternativo.
Para resumir, las búsquedas de propiedades a través de la cadena de prototipos de JavaScript funcionan de la siguiente manera:
- Si el objeto tiene una propiedad con el nombre dado, se devuelve ese valor. (El método
hasOwnProperty
se puede usar para verificar si un objeto tiene una propiedad con nombre en particular). - Si el objeto no tiene la propiedad nombrada, se comprueba el prototipo del objeto.
- Dado que el prototipo también es un objeto, si tampoco contiene la propiedad, se comprueba el prototipo de su padre.
- Este proceso continúa en la cadena de prototipos hasta que se encuentra la propiedad.
- Si se llega a
Object.prototype
y tampoco tiene la propiedad, la propiedad se consideraundefined
.
Comprender cómo funcionan las búsquedas de propiedades y la herencia de prototipos es importante en general para los desarrolladores, pero también es esencial debido a sus (a veces significativas) ramificaciones de rendimiento de JavaScript. Como se menciona en la documentación de V8 (el motor JavaScript de código abierto y alto rendimiento de Google), la mayoría de los motores JavaScript utilizan una estructura de datos similar a un diccionario para almacenar las propiedades de los objetos. Por lo tanto, cada acceso a la propiedad requiere una búsqueda dinámica en esa estructura de datos para resolver la propiedad. Este enfoque hace que el acceso a las propiedades en JavaScript sea mucho más lento que el acceso a las variables de instancia en lenguajes de programación como Java y Smalltalk.
Búsquedas de variables a través de la cadena de ámbito
Otro mecanismo de búsqueda en JavaScript se basa en el alcance.
Para entender cómo funciona esto, es necesario introducir el concepto de contexto de ejecución.
En JavaScript, hay dos tipos de contextos de ejecución:
- Contexto global, creado cuando se inicia un proceso de JavaScript
- Contexto local, creado cuando se invoca una función
Los contextos de ejecución se organizan en una pila. En la parte inferior de la pila, siempre está el contexto global, que es único para cada programa de JavaScript. Cada vez que se encuentra una función, se crea un nuevo contexto de ejecución y se coloca en la parte superior de la pila. Una vez que la función ha terminado de ejecutarse, su contexto se extrae de la pila.
Considere el siguiente código:
// global context var message = 'Hello World'; var sayHello = function(n){ // local context 1 created and pushed onto context stack var i = 0; var innerSayHello = function() { // local context 2 created and pushed onto context stack console.log((i + 1) + ': ' + message); // local context 2 popped off of context stack } for (i = 0; i < n; i++) { innerSayHello(); } // local context 1 popped off of context stack }; sayHello(3); // Prints: // 1: Hello World // 2: Hello World // 3: Hello World
Dentro de cada contexto de ejecución hay un objeto especial llamado cadena de ámbito que se utiliza para resolver variables. Una cadena de alcances es esencialmente una pila de alcances actualmente accesibles, desde el contexto más inmediato hasta el contexto global. (Para ser un poco más precisos, el objeto en la parte superior de la pila se denomina Objeto de activación que contiene referencias a las variables locales para la función que se está ejecutando, los argumentos de la función nombrada y dos objetos "especiales": this
y arguments
. ) Por ejemplo:

Observe en el diagrama anterior cómo this
apunta al objeto de window
de forma predeterminada y también cómo el contexto global contiene ejemplos de otros objetos como console
y location
.
Al intentar resolver variables a través de la cadena de ámbito, primero se comprueba el contexto inmediato en busca de una variable coincidente. Si no se encuentra ninguna coincidencia, se comprueba el siguiente objeto de contexto en la cadena de ámbito, y así sucesivamente, hasta que se encuentra una coincidencia. Si no se encuentra ninguna coincidencia, se lanza un ReferenceError
.
También es importante tener en cuenta que se agrega un nuevo ámbito a la cadena de ámbitos cuando se encuentra un bloque try-catch
o un bloque with
. En cualquiera de estos casos, se crea un nuevo objeto y se coloca en la parte superior de la cadena de ámbito:
function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; }; function persist(person) { with (person) { // The 'person' object was pushed onto the scope chain when we // entered this "with" block, so we can simply reference // 'firstName' and 'lastName', rather than person.firstName and // person.lastName if (!firstName) { throw new Error('FirstName is mandatory'); } if (!lastName) { throw new Error('LastName is mandatory'); } } try { person.save(); } catch(error) { // A new scope containing the 'error' object is accessible here console.log('Impossible to store ' + person + ', Reason: ' + error); } } var p1 = new Person('John', 'Doe'); persist(p1);
Para comprender completamente cómo se producen las búsquedas de variables basadas en el ámbito, es importante tener en cuenta que en JavaScript actualmente no hay ámbitos a nivel de bloque. Por ejemplo:
for (var i = 0; i < 10; i++) { /* ... */ } // 'i' is still in scope! console.log(i); // prints '10'
En la mayoría de los demás lenguajes, el código anterior daría lugar a un error porque la "vida" (es decir, el alcance) de la variable i
estaría restringida al bloque for. En JavaScript, sin embargo, este no es el caso. Más bien, i
se agrega al objeto de activación en la parte superior de la cadena de alcance y permanecerá allí hasta que ese objeto se elimine del alcance, lo que sucede cuando el contexto de ejecución correspondiente se elimina de la pila. Este comportamiento se conoce como elevación variable.
Sin embargo, vale la pena señalar que la compatibilidad con los ámbitos de nivel de bloque se está abriendo camino en JavaScript a través de la palabra clave new let
. La palabra clave let
ya está disponible en JavaScript 1.7 y está programada para convertirse en una palabra clave de JavaScript admitida oficialmente a partir de ECMAScript 6.
Ramificaciones de rendimiento de JavaScript
La forma en que las búsquedas de propiedades y variables, utilizando la cadena de prototipo y la cadena de alcance respectivamente, funcionan en JavaScript es una de las características clave del lenguaje, pero es una de las más difíciles y sutiles de entender.
Las operaciones de búsqueda que hemos descrito en este ejemplo, ya sea que se basen en la cadena de prototipo o en la cadena de ámbito, se repiten cada vez que se accede a una propiedad o variable. Cuando esta búsqueda ocurre dentro de bucles u otras operaciones intensivas, puede tener ramificaciones significativas en el rendimiento de JavaScript, especialmente a la luz de la naturaleza de subproceso único del lenguaje que evita que ocurran varias operaciones al mismo tiempo.
Considere el siguiente ejemplo:
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');
En este ejemplo, tenemos un árbol de herencia largo y tres bucles anidados. Dentro del bucle más profundo, la variable de contador se incrementa con el valor de delta
. ¡Pero delta
se encuentra casi en la parte superior del árbol de herencia! Esto significa que cada vez que se accede a child.delta
, se debe navegar por el árbol completo de abajo hacia arriba. Esto puede tener un impacto muy negativo en el rendimiento.
Al comprender esto, podemos mejorar fácilmente el rendimiento de la función nestedFn
anterior mediante el uso de una variable delta
local para almacenar en caché el valor en child.delta
(y, por lo tanto, evitar la necesidad de recorridos repetitivos de todo el árbol de herencia) de la siguiente manera:
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');
Por supuesto, esta técnica en particular solo es viable en un escenario donde se sabe que el valor de child.delta
no cambiará mientras se ejecutan los bucles for; de lo contrario, la copia local deberá actualizarse con el valor actual.
Bien, ejecutemos ambas versiones del método nestedFn
y veamos si hay alguna diferencia de rendimiento apreciable entre los dos.
Comenzaremos ejecutando el primer ejemplo en un REPL de node.js:
diego@alkadia:~$ node test.js Final result: 10000000000 Total time: 8270 milliseconds
Así que tarda unos 8 segundos en ejecutarse. Eso es un largo tiempo.
Ahora veamos qué sucede cuando ejecutamos la versión optimizada:
diego@alkadia:~$ node test2.js Final result: 10000000000 Total time: 1143 milliseconds
Esta vez solo tomó un segundo. ¡Mucho mas rápido!
Tenga en cuenta que el uso de variables locales para evitar búsquedas costosas es una técnica que se puede aplicar tanto para la búsqueda de propiedades (a través de la cadena de prototipos) como para las búsquedas de variables (a través de la cadena de alcance).
Además, este tipo de "almacenamiento en caché" de valores (es decir, en variables en el ámbito local) también puede ser beneficioso cuando se utilizan algunas de las bibliotecas de JavaScript más comunes. Tome jQuery, por ejemplo. jQuery admite la noción de "selectores", que son básicamente un mecanismo para recuperar uno o más elementos coincidentes en el DOM. La facilidad con la que uno puede especificar selectores en jQuery puede hacer que uno olvide lo costoso (desde el punto de vista del rendimiento) que puede ser cada búsqueda de selector. En consecuencia, almacenar los resultados de búsqueda del selector en una variable local puede ser extremadamente beneficioso para el rendimiento. Por ejemplo:
// this does the DOM search for $('.container') "n" times for (var i = 0; i < n; i++) { $('.container').append(“Line “+i+”<br />”); } // this accomplishes the same thing... // but only does the DOM search for $('.container') once, // although it does still modify the DOM "n" times var $container = $('.container'); for (var i = 0; i < n; i++) { $container.append("Line "+i+"<br />"); } // or even better yet... // this version only does the DOM search for $('.container') once // AND only modifies the DOM once var $html = ''; for (var i = 0; i < n; i++) { $html += 'Line ' + i + '<br />'; } $('.container').append($html);
Especialmente en una página web con una gran cantidad de elementos, el segundo enfoque en el ejemplo de código anterior puede generar potencialmente un rendimiento significativamente mejor que el primero.
Envolver
La búsqueda de datos en JavaScript es bastante diferente de lo que es en la mayoría de los demás lenguajes, y tiene muchos matices. Por lo tanto, es esencial comprender completa y adecuadamente estos conceptos para dominar verdaderamente el idioma. La búsqueda de datos y otros errores comunes de JavaScript deben evitarse siempre que sea posible. Es probable que esta comprensión produzca un código más limpio y sólido que logre un mejor rendimiento de JavaScript.