Catene di prototipi JavaScript, catene di ambiti e prestazioni: cosa devi sapere

Pubblicato: 2022-03-11

JavaScript: più di quanto sembri

JavaScript può sembrare una lingua molto facile da imparare all'inizio. Forse è a causa della sua sintassi flessibile. O forse è per la sua somiglianza con altri linguaggi ben noti come Java. O forse è perché ha così pochi tipi di dati rispetto a linguaggi come Java, Ruby o .NET.

Ma in verità, JavaScript è molto meno semplicistico e più sfumato di quanto la maggior parte degli sviluppatori inizialmente realizzi. Anche per gli sviluppatori con più esperienza, alcune delle caratteristiche più salienti di JavaScript continuano a essere fraintese e creano confusione. Una di queste funzionalità è il modo in cui vengono eseguite le ricerche di dati (proprietà e variabili) e le ramificazioni delle prestazioni JavaScript di cui tenere conto.

In JavaScript, le ricerche di dati sono regolate da due cose: ereditarietà prototipica e catena di ambito . Come sviluppatore, è essenziale comprendere chiaramente questi due meccanismi, poiché ciò può migliorare la struttura, e spesso le prestazioni, del codice.

Ricerche di proprietà attraverso la catena di prototipi

Quando si accede a una proprietà in un linguaggio basato su prototipi come JavaScript, viene eseguita una ricerca dinamica che coinvolge diversi livelli all'interno dell'albero dei prototipi dell'oggetto.

In JavaScript, ogni funzione è un oggetto. Quando una funzione viene richiamata con l'operatore new , viene creato un nuovo oggetto. Per esempio:

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

Nell'esempio precedente, p1 e p2 sono due oggetti diversi, ciascuno creato utilizzando la funzione Person come costruttore. Sono istanze indipendenti di Person , come dimostrato da questo frammento di codice:

 console.log(p1 instanceof Person); // prints 'true' console.log(p2 instanceof Person); // prints 'true' console.log(p1 === p2); // prints 'false'

Poiché le funzioni JavaScript sono oggetti, possono avere proprietà. Una proprietà particolarmente importante che possiede ogni funzione è chiamata prototype .

prototype , che è esso stesso un oggetto, eredita dal prototipo del genitore, che eredita dal prototipo del genitore e così via. Questa viene spesso definita catena prototipo . Object.prototype , che si trova sempre alla fine della catena di prototipi (cioè in cima all'albero dell'ereditarietà del prototipo), contiene metodi come toString() , hasProperty() , isPrototypeOf() e così via.

La relazione tra il prototipo JavaScript e la catena dell'ambito è importante

Il prototipo di ciascuna funzione può essere esteso per definire i propri metodi e proprietà personalizzati.

Quando istanzia un oggetto (richiamando la funzione usando l'operatore new ), eredita tutte le proprietà nel prototipo di quella funzione. Tieni presente, tuttavia, che tali istanze non avranno accesso diretto all'oggetto prototype ma solo alle sue proprietà. Per esempio:

 // 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

C'è un punto importante e alquanto sottile qui: anche se p1 è stato creato prima che il metodo getFullName fosse definito, avrà comunque accesso ad esso perché il suo prototipo è il prototipo Person .

(Vale la pena notare che i browser memorizzano anche un riferimento al prototipo di qualsiasi oggetto in una proprietà __proto__ , ma è davvero una cattiva pratica accedere direttamente al prototipo tramite la proprietà __proto__ , poiché non fa parte della specifica del linguaggio ECMAScript standard, quindi don non farlo! )

Poiché l'istanza p1 dell'oggetto Person non ha di per sé accesso diretto all'oggetto prototype , se vogliamo sovrascrivere getFullName in p1 , lo faremmo come segue:

 // We reference p1.getFullName, *NOT* p1.prototype.getFullName, // since p1.prototype does not exist: p1.getFullName = function(){ return 'I am anonymous'; }

Ora p1 ha la sua proprietà getFullName . Ma l'istanza p2 (creata nel nostro esempio precedente) non ha alcuna proprietà di questo tipo. Pertanto, invocando p1.getFullName() accede al metodo getFullName dell'istanza p1 stessa, mentre invocando p2.getFullName() si risale la catena del prototipo fino all'oggetto Person prototipo per risolvere getFullName :

 console.log(p1.getFullName()); // prints 'I am anonymous' console.log(p2.getFullName()); // prints 'Robert Doe' 

Guarda come P1 e P2 si relazionano al prototipo Persona in questo esempio di prototipo JavaScript.

Un'altra cosa importante da tenere presente è che è anche possibile modificare dinamicamente il prototipo di un oggetto. Per esempio:

 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'

Quando si utilizza l'ereditarietà del prototipo, ricordarsi di definire le proprietà nel prototipo dopo aver ereditato dalla classe padre o aver specificato un prototipo alternativo.

Questo diagramma mostra un esempio della relazione tra i prototipi JavaScript in una catena di prototipi.

Per riassumere, le ricerche di proprietà attraverso la catena di prototipi JavaScript funzionano come segue:

  • Se l'oggetto ha una proprietà con il nome specificato, viene restituito quel valore. (Il metodo hasOwnProperty può essere utilizzato per verificare se un oggetto ha una particolare proprietà denominata.)
  • Se l'oggetto non ha la proprietà denominata, viene verificato il prototipo dell'oggetto
  • Poiché anche il prototipo è un oggetto, se non contiene nemmeno la proprietà, viene verificato il prototipo del suo genitore.
  • Questo processo continua lungo la catena del prototipo fino a quando non viene trovata la proprietà.
  • Se Object.prototype viene raggiunto e non ha nemmeno la proprietà, la proprietà viene considerata undefined .

Comprendere come funzionano l'ereditarietà dei prototipi e le ricerche di proprietà è importante in generale per gli sviluppatori, ma è anche essenziale a causa delle sue ramificazioni delle prestazioni JavaScript (a volte significative). Come menzionato nella documentazione per V8 (il motore JavaScript ad alte prestazioni e open source di Google), la maggior parte dei motori JavaScript utilizza una struttura dati simile a un dizionario per memorizzare le proprietà degli oggetti. Ogni accesso alla proprietà richiede quindi una ricerca dinamica in quella struttura di dati per risolvere la proprietà. Questo approccio rende l'accesso alle proprietà in JavaScript in genere molto più lento rispetto all'accesso alle variabili di istanza in linguaggi di programmazione come Java e Smalltalk.

Ricerche di variabili attraverso la catena dell'ambito

Un altro meccanismo di ricerca in JavaScript si basa sull'ambito.

Per capire come funziona, è necessario introdurre il concetto di contesto di esecuzione.

In JavaScript, ci sono due tipi di contesti di esecuzione:

  • Contesto globale, creato all'avvio di un processo JavaScript
  • Contesto locale, creato quando viene richiamata una funzione

I contesti di esecuzione sono organizzati in uno stack. In fondo allo stack c'è sempre il contesto globale, che è unico per ogni programma JavaScript. Ogni volta che si incontra una funzione, viene creato un nuovo contesto di esecuzione che viene inserito in cima allo stack. Una volta che la funzione ha terminato l'esecuzione, il suo contesto viene estratto dallo stack.

Considera il seguente codice:

 // 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

All'interno di ogni contesto di esecuzione è presente un oggetto speciale chiamato catena di ambito che viene utilizzato per risolvere le variabili. Una catena di ambiti è essenzialmente una pila di ambiti attualmente accessibili, dal contesto più immediato al contesto globale. (Per essere un po' più precisi, l'oggetto in cima allo stack è chiamato Activation Object che contiene riferimenti alle variabili locali per la funzione in esecuzione, gli argomenti della funzione denominata e due oggetti "speciali": this e arguments . ) Per esempio:

Il modo in cui la catena dell'ambito è correlata agli oggetti è descritto in questo esempio JavaScript.

Nota nel diagramma sopra come this punta all'oggetto window per impostazione predefinita e anche come il contesto globale contiene esempi di altri oggetti come console e location .

Quando si tenta di risolvere le variabili tramite la catena dell'ambito, nel contesto immediato viene prima verificata la presenza di una variabile corrispondente. Se non viene trovata alcuna corrispondenza, viene verificato il successivo oggetto di contesto nella catena dell'ambito e così via, finché non viene trovata una corrispondenza. Se non viene trovata alcuna corrispondenza, viene generato un ReferenceError .

È importante notare anche che un nuovo ambito viene aggiunto alla catena di ambito quando si incontra un blocco try-catch o un blocco with . In uno di questi casi, viene creato un nuovo oggetto e posizionato in cima alla catena dell'ambito:

 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);

Per comprendere appieno come si verificano le ricerche di variabili basate sull'ambito, è importante tenere presente che in JavaScript attualmente non sono presenti ambiti a livello di blocco. Per esempio:

 for (var i = 0; i < 10; i++) { /* ... */ } // 'i' is still in scope! console.log(i); // prints '10'

Nella maggior parte degli altri linguaggi, il codice sopra porterebbe a un errore perché la "vita" (cioè l'ambito) della variabile i sarebbe limitata al blocco for. In JavaScript, tuttavia, non è così. Piuttosto, i viene aggiunto all'oggetto di attivazione nella parte superiore della catena dell'ambito e rimarrà lì fino a quando l'oggetto non viene rimosso dall'ambito, cosa che accade quando il contesto di esecuzione corrispondente viene rimosso dallo stack. Questo comportamento è noto come sollevamento variabile.

Vale la pena notare, tuttavia, che il supporto per gli ambiti a livello di blocco si sta facendo strada in JavaScript attraverso la parola chiave new let . La parola chiave let è già disponibile in JavaScript 1.7 ed è prevista per diventare una parola chiave JavaScript ufficialmente supportata a partire da ECMAScript 6.

Ramificazioni delle prestazioni JavaScript

Il modo in cui le ricerche di proprietà e variabili, utilizzando rispettivamente la catena del prototipo e la catena dell'ambito, funzionano in JavaScript è una delle caratteristiche chiave del linguaggio, ma è una delle più complicate e sottili da comprendere.

Le operazioni di ricerca descritte in questo esempio, basate sulla catena del prototipo o sulla catena dell'ambito, vengono ripetute ogni volta che si accede a una proprietà o a una variabile. Quando questa ricerca si verifica all'interno di cicli o altre operazioni intensive, può avere significative ramificazioni delle prestazioni JavaScript, soprattutto alla luce della natura a thread singolo del linguaggio che impedisce l'esecuzione simultanea di più operazioni.

Considera il seguente esempio:

 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 questo esempio, abbiamo un albero di ereditarietà lungo e tre cicli nidificati. All'interno del ciclo più profondo, la variabile contatore viene incrementata del valore di delta . Ma il delta si trova quasi in cima all'albero dell'eredità! Ciò significa che ogni volta che si accede a child.delta , l'intero albero deve essere navigato dal basso verso l'alto. Questo può avere un impatto davvero negativo sulle prestazioni.

Comprendendo questo, possiamo facilmente migliorare le prestazioni della funzione nestedFn sopra utilizzando una variabile delta locale per memorizzare nella cache il valore in child.delta (e quindi evitare la necessità di attraversamento ripetitivo dell'intero albero dell'ereditarietà) come segue:

 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');

Naturalmente, questa tecnica particolare è praticabile solo in uno scenario in cui è noto che il valore di child.delta non cambierà mentre i cicli for sono in esecuzione; in caso contrario, la copia locale dovrebbe essere aggiornata con il valore corrente.

OK, eseguiamo entrambe le versioni del metodo nestedFn e vediamo se c'è una differenza di prestazioni apprezzabile tra i due.

Inizieremo eseguendo il primo esempio in un REPL node.js:

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

Quindi ci vogliono circa 8 secondi per l'esecuzione. È molto tempo.

Ora vediamo cosa succede quando eseguiamo la versione ottimizzata:

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

Questa volta è bastato un secondo. Più veloce!

Si noti che l'uso di variabili locali per evitare ricerche costose è una tecnica che può essere applicata sia per la ricerca di proprietà (tramite la catena di prototipi) che per le ricerche di variabili (tramite la catena di ambito).

Inoltre, questo tipo di "caching" dei valori (cioè nelle variabili nell'ambito locale) può essere utile anche quando si utilizzano alcune delle librerie JavaScript più comuni. Prendi jQuery, per esempio. jQuery supporta la nozione di "selettori", che sono fondamentalmente un meccanismo per recuperare uno o più elementi corrispondenti nel DOM. La facilità con cui è possibile specificare i selettori in jQuery può far dimenticare quanto possa essere costosa (dal punto di vista delle prestazioni) ciascuna ricerca del selettore. Di conseguenza, la memorizzazione dei risultati della ricerca del selettore in una variabile locale può essere estremamente vantaggiosa per le prestazioni. Per esempio:

 // 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);

Soprattutto in una pagina Web con un numero elevato di elementi, il secondo approccio nell'esempio di codice sopra può potenzialmente comportare prestazioni significativamente migliori rispetto al primo.

Incartare

La ricerca dei dati in JavaScript è abbastanza diversa da quella della maggior parte delle altre lingue ed è molto ricca di sfumature. È quindi essenziale comprendere appieno e correttamente questi concetti per padroneggiare veramente la lingua. La ricerca dei dati e altri errori JavaScript comuni dovrebbero essere evitati quando possibile. È probabile che questa comprensione produca un codice più pulito e robusto che consente di ottenere prestazioni JavaScript migliorate.

Relazionato: Come sviluppatore JS, questo è ciò che mi tiene sveglio la notte / Dare un senso alla confusione della classe ES6