JavaScript-Prototypketten, Bereichsketten und Leistung: Was Sie wissen müssen
Veröffentlicht: 2022-03-11JavaScript: Mehr als man denkt
JavaScript scheint zunächst eine sehr einfach zu erlernende Sprache zu sein. Vielleicht liegt es an seiner flexiblen Syntax. Oder vielleicht liegt es an seiner Ähnlichkeit mit anderen bekannten Sprachen wie Java. Oder vielleicht liegt es daran, dass es im Vergleich zu Sprachen wie Java, Ruby oder .NET so wenige Datentypen hat.
Aber in Wahrheit ist JavaScript viel weniger simpel und nuancierter, als die meisten Entwickler zunächst annehmen. Selbst für Entwickler mit mehr Erfahrung werden einige der hervorstechendsten Funktionen von JavaScript weiterhin missverstanden und führen zu Verwirrung. Eine solche Funktion ist die Art und Weise, wie Daten (Eigenschaften und Variablen) nachgeschlagen werden, und die zu beachtenden Auswirkungen auf die JavaScript-Leistung.
In JavaScript werden Datensuchen durch zwei Dinge geregelt: prototypische Vererbung und Bereichskette . Als Entwickler ist es wichtig, diese beiden Mechanismen genau zu verstehen, da dies die Struktur und oft auch die Leistung Ihres Codes verbessern kann.
Property-Lookups durch die Prototyp-Kette
Beim Zugriff auf eine Eigenschaft in einer prototypbasierten Sprache wie JavaScript findet eine dynamische Suche statt, die verschiedene Ebenen innerhalb des prototypischen Baums des Objekts umfasst.
In JavaScript ist jede Funktion ein Objekt. Wenn eine Funktion mit dem new
-Operator aufgerufen wird, wird ein neues Objekt erstellt. Zum Beispiel:
function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; } var p1 = new Person('John', 'Doe'); var p2 = new Person('Robert', 'Doe');
Im obigen Beispiel sind p1
und p2
zwei verschiedene Objekte, die jeweils mit der Person
-Funktion als Konstruktor erstellt wurden. Sie sind unabhängige Instanzen von Person
, wie dieser Codeausschnitt zeigt:
console.log(p1 instanceof Person); // prints 'true' console.log(p2 instanceof Person); // prints 'true' console.log(p1 === p2); // prints 'false'
Da JavaScript-Funktionen Objekte sind, können sie Eigenschaften haben. Eine besonders wichtige Eigenschaft, die jede Funktion hat, wird prototype
genannt.
prototype
, der selbst ein Objekt ist, erbt vom Prototyp des übergeordneten Objekts, das wiederum vom Prototyp des übergeordneten Objekts erbt, und so weiter. Dies wird oft als Prototypenkette bezeichnet. Object.prototype
, das sich immer am Ende der Prototypkette befindet (dh an der Spitze des prototypischen Vererbungsbaums), enthält Methoden wie toString()
, hasProperty()
, isPrototypeOf()
und so weiter.
Der Prototyp jeder Funktion kann erweitert werden, um seine eigenen benutzerdefinierten Methoden und Eigenschaften zu definieren.
Wenn Sie ein Objekt instanziieren (indem Sie die Funktion mit dem new
-Operator aufrufen), erbt es alle Eigenschaften des Prototyps dieser Funktion. Beachten Sie jedoch, dass diese Instanzen keinen direkten Zugriff auf das prototype
haben, sondern nur auf seine Eigenschaften. Zum Beispiel:
// 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
Hier gibt es einen wichtigen und etwas subtilen Punkt: Selbst wenn p1
erstellt wurde, bevor die getFullName
Methode definiert wurde, hat es immer noch Zugriff darauf, da sein Prototyp der Person
-Prototyp ist.
(Es ist erwähnenswert, dass Browser auch einen Verweis auf den Prototyp eines beliebigen Objekts in einer __proto__
Eigenschaft speichern, aber es ist wirklich schlechte Praxis , direkt über die __proto__
Eigenschaft auf den Prototyp zuzugreifen, da er nicht Teil der Standard-ECMAScript-Sprachspezifikation ist, also don nicht machen! )
Da die p1
Instanz des Person
-Objekts selbst keinen direkten Zugriff auf das prototype
-Objekt hat, würden wir, wenn wir getFullName
in p1
überschreiben möchten, wie folgt vorgehen:
// We reference p1.getFullName, *NOT* p1.prototype.getFullName, // since p1.prototype does not exist: p1.getFullName = function(){ return 'I am anonymous'; }
Jetzt hat p1
seine eigene getFullName
Eigenschaft. Aber die p2
Instanz (die in unserem vorherigen Beispiel erstellt wurde) hat keine eigene solche Eigenschaft. Daher greift der Aufruf von p1.getFullName()
auf die getFullName
Methode der p1
-Instanz selbst zu, während der Aufruf p2.getFullName()
die Prototypkette hinauf zum Person
-Prototypobjekt geht, um getFullName
aufzulösen:
console.log(p1.getFullName()); // prints 'I am anonymous' console.log(p2.getFullName()); // prints 'Robert Doe'
Eine weitere wichtige Sache, die Sie beachten sollten, ist, dass es auch möglich ist, den Prototyp eines Objekts dynamisch zu ändern. Zum Beispiel:
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'
Wenn Sie die prototypische Vererbung verwenden, denken Sie daran, Eigenschaften im Prototyp zu definieren, nachdem Sie entweder von der übergeordneten Klasse geerbt oder einen alternativen Prototyp angegeben haben.
Zusammenfassend funktionieren Eigenschaftssuchen über die JavaScript-Prototypkette wie folgt:
- Wenn das Objekt eine Eigenschaft mit dem angegebenen Namen hat, wird dieser Wert zurückgegeben. (Die
hasOwnProperty
Methode kann verwendet werden, um zu prüfen, ob ein Objekt eine bestimmte benannte Eigenschaft hat.) - Wenn das Objekt die benannte Eigenschaft nicht hat, wird der Prototyp des Objekts überprüft
- Da der Prototyp ebenfalls ein Objekt ist, wird, wenn er die Eigenschaft ebenfalls nicht enthält, der Prototyp seines Elternobjekts geprüft.
- Dieser Prozess setzt sich die Prototypenkette hinauf fort, bis die Eigenschaft gefunden ist.
- Wenn
Object.prototype
erreicht wird und es auch nicht über die Eigenschaft verfügt, wird die Eigenschaft alsundefined
betrachtet.
Das Verständnis, wie prototypische Vererbung und Property-Lookups funktionieren, ist im Allgemeinen für Entwickler wichtig, aber auch wegen der (manchmal erheblichen) Auswirkungen auf die JavaScript-Leistung. Wie in der Dokumentation für V8 (Googles Open-Source-Hochleistungs-JavaScript-Engine) erwähnt, verwenden die meisten JavaScript-Engines eine wörterbuchähnliche Datenstruktur zum Speichern von Objekteigenschaften. Jeder Eigenschaftszugriff erfordert daher ein dynamisches Nachschlagen in dieser Datenstruktur, um die Eigenschaft aufzulösen. Dieser Ansatz macht den Zugriff auf Eigenschaften in JavaScript normalerweise viel langsamer als den Zugriff auf Instanzvariablen in Programmiersprachen wie Java und Smalltalk.
Variable Lookups durch die Bereichskette
Ein weiterer Suchmechanismus in JavaScript basiert auf dem Geltungsbereich.
Um zu verstehen, wie dies funktioniert, ist es notwendig, das Konzept des Ausführungskontexts einzuführen.
In JavaScript gibt es zwei Arten von Ausführungskontexten:
- Globaler Kontext, der erstellt wird, wenn ein JavaScript-Prozess gestartet wird
- Lokaler Kontext, der erstellt wird, wenn eine Funktion aufgerufen wird
Ausführungskontexte sind in einem Stack organisiert. Am Ende des Stapels befindet sich immer der globale Kontext, der für jedes JavaScript-Programm einzigartig ist. Jedes Mal, wenn eine Funktion angetroffen wird, wird ein neuer Ausführungskontext erstellt und an die Spitze des Stapels geschoben. Sobald die Funktion die Ausführung beendet hat, wird ihr Kontext vom Stack entfernt.
Betrachten Sie den folgenden Code:
// 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
Innerhalb jedes Ausführungskontextes gibt es ein spezielles Objekt, das als Bereichskette bezeichnet wird und zum Auflösen von Variablen verwendet wird. Eine Bereichskette ist im Wesentlichen ein Stapel aktuell zugänglicher Bereiche, vom unmittelbarsten Kontext bis zum globalen Kontext. (Um etwas genauer zu sein, wird das Objekt ganz oben auf dem Stapel als Aktivierungsobjekt bezeichnet, das Verweise auf die lokalen Variablen für die ausgeführte Funktion, die benannten Funktionsargumente und zwei „spezielle“ Objekte enthält: this
und arguments
. ) Zum Beispiel:

Beachten Sie im obigen Diagramm, wie this
standardmäßig auf das window
verweist und auch, wie der globale Kontext Beispiele für andere Objekte wie console
und location
enthält.
Beim Versuch, Variablen über die Bereichskette aufzulösen, wird zunächst der unmittelbare Kontext auf eine passende Variable überprüft. Wenn keine Übereinstimmung gefunden wird, wird das nächste Kontextobjekt in der Bereichskette geprüft und so weiter, bis eine Übereinstimmung gefunden wird. Wenn keine Übereinstimmung gefunden wird, wird ein ReferenceError
geworfen.
Es ist auch wichtig zu beachten, dass der Bereichskette ein neuer Geltungsbereich hinzugefügt wird, wenn ein try-catch
Block oder ein with
-Block auftritt. In beiden Fällen wird ein neues Objekt erstellt und oben in der Bereichskette platziert:
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);
Um vollständig zu verstehen, wie bereichsbasierte Variablensuchen ablaufen, ist es wichtig zu bedenken, dass es in JavaScript derzeit keine Bereiche auf Blockebene gibt. Zum Beispiel:
for (var i = 0; i < 10; i++) { /* ... */ } // 'i' is still in scope! console.log(i); // prints '10'
In den meisten anderen Sprachen würde der obige Code zu einem Fehler führen, da die „Lebensdauer“ (dh der Geltungsbereich) der Variablen i
auf den for-Block beschränkt wäre. In JavaScript ist dies jedoch nicht der Fall. Stattdessen wird i
zum Aktivierungsobjekt ganz oben in der Gültigkeitsbereichskette hinzugefügt und bleibt dort, bis dieses Objekt aus dem Gültigkeitsbereich entfernt wird, was geschieht, wenn der entsprechende Ausführungskontext aus dem Stapel entfernt wird. Dieses Verhalten wird als variables Heben bezeichnet.
Es ist jedoch erwähnenswert, dass die Unterstützung für Geltungsbereiche auf Blockebene durch das neue Schlüsselwort let
in JavaScript Einzug hält. Das Schlüsselwort let
ist bereits in JavaScript 1.7 verfügbar und soll ab ECMAScript 6 ein offiziell unterstütztes JavaScript-Schlüsselwort werden.
Auswirkungen auf die JavaScript-Leistung
Die Art und Weise, wie Property- und Variablen-Lookups mit Prototyp-Kette bzw. Scope-Kette in JavaScript funktionieren, ist eines der Hauptmerkmale der Sprache, aber es ist eines der schwierigsten und am subtilsten zu verstehenden.
Die Suchoperationen, die wir in diesem Beispiel beschrieben haben, unabhängig davon, ob sie auf der Prototypkette oder der Bereichskette basieren, werden jedes Mal wiederholt, wenn auf eine Eigenschaft oder Variable zugegriffen wird. Wenn diese Suche innerhalb von Schleifen oder anderen intensiven Vorgängen erfolgt, kann dies erhebliche Auswirkungen auf die JavaScript-Leistung haben, insbesondere angesichts der Single-Thread-Natur der Sprache, die verhindert, dass mehrere Vorgänge gleichzeitig ausgeführt werden.
Betrachten Sie das folgende Beispiel:
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 diesem Beispiel haben wir einen langen Vererbungsbaum und drei verschachtelte Schleifen. Innerhalb der tiefsten Schleife wird die Zählervariable um den Wert von delta
erhöht. Aber delta
befindet sich fast an der Spitze des Vererbungsbaums! Das bedeutet, dass bei jedem Zugriff auf child.delta
durch den gesamten Baum von unten nach oben navigiert werden muss. Dies kann sich sehr negativ auf die Leistung auswirken.
Wenn wir dies verstehen, können wir die Leistung der obigen nestedFn
Funktion leicht verbessern, indem wir eine lokale delta
Variable verwenden, um den Wert in child.delta
(und dadurch die Notwendigkeit einer wiederholten Traversierung des gesamten Vererbungsbaums zu vermeiden) wie folgt:
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');
Natürlich ist diese spezielle Technik nur in einem Szenario praktikabel, in dem bekannt ist, dass sich der Wert von child.delta
nicht ändert, während die for-Schleifen ausgeführt werden; andernfalls müsste die lokale Kopie mit dem aktuellen Wert aktualisiert werden.
OK, lassen Sie uns beide Versionen der nestedFn
Methode ausführen und sehen, ob es einen nennenswerten Leistungsunterschied zwischen den beiden gibt.
Wir beginnen mit der Ausführung des ersten Beispiels in einer node.js-REPL:
diego@alkadia:~$ node test.js Final result: 10000000000 Total time: 8270 milliseconds
Das dauert also etwa 8 Sekunden. Das ist eine lange Zeit.
Sehen wir uns nun an, was passiert, wenn wir die optimierte Version ausführen:
diego@alkadia:~$ node test2.js Final result: 10000000000 Total time: 1143 milliseconds
Diesmal dauerte es nur eine Sekunde. Viel schneller!
Beachten Sie, dass die Verwendung lokaler Variablen zur Vermeidung teurer Suchvorgänge eine Technik ist, die sowohl für die Eigenschaftssuche (über die Prototypkette) als auch für die Variablensuche (über die Bereichskette) angewendet werden kann.
Darüber hinaus kann diese Art des „Zwischenspeicherns“ von Werten (dh in Variablen im lokalen Gültigkeitsbereich) auch vorteilhaft sein, wenn einige der gängigsten JavaScript-Bibliotheken verwendet werden. Nehmen Sie zum Beispiel jQuery. jQuery unterstützt das Konzept von „Selektoren“, die im Grunde ein Mechanismus zum Abrufen eines oder mehrerer übereinstimmender Elemente im DOM sind. Die Leichtigkeit, mit der man Selektoren in jQuery spezifizieren kann, kann dazu führen, dass man vergisst, wie kostspielig (aus Performance-Sicht) jede Selektor-Suche sein kann. Dementsprechend kann das Speichern von Selektor-Lookup-Ergebnissen in einer lokalen Variablen äußerst vorteilhaft für die Leistung sein. Zum Beispiel:
// 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);
Insbesondere auf einer Webseite mit einer großen Anzahl von Elementen kann der zweite Ansatz im obigen Codebeispiel möglicherweise zu einer deutlich besseren Leistung führen als der erste.
Einpacken
Die Datensuche in JavaScript ist ganz anders als in den meisten anderen Sprachen und sehr nuanciert. Es ist daher wichtig, diese Konzepte vollständig und richtig zu verstehen, um die Sprache wirklich zu beherrschen. Datensuche und andere häufige JavaScript-Fehler sollten nach Möglichkeit vermieden werden. Dieses Verständnis führt wahrscheinlich zu einem saubereren, robusteren Code, der eine verbesserte JavaScript-Leistung erzielt.