JavaScript Prototip Zincirleri, Kapsam Zincirleri ve Performans: Bilmeniz Gerekenler
Yayınlanan: 2022-03-11JavaScript: Görünenden daha fazlası
JavaScript ilk bakışta öğrenmesi çok kolay bir dil gibi görünebilir. Belki de esnek sözdiziminden dolayıdır. Ya da belki de Java gibi iyi bilinen diğer dillere benzerliğinden dolayıdır. Ya da belki de Java, Ruby veya .NET gibi dillere kıyasla çok az veri türüne sahip olduğu içindir.
Ancak gerçekte JavaScript, çoğu geliştiricinin başlangıçta fark ettiğinden çok daha az basit ve daha nüanslıdır. Daha fazla deneyime sahip geliştiriciler için bile, JavaScript'in en göze çarpan özelliklerinden bazıları yanlış anlaşılmaya devam ediyor ve kafa karışıklığına neden oluyor. Böyle bir özellik, veri (özellik ve değişken) aramalarının gerçekleştirilme şekli ve bilinmesi gereken JavaScript performans sonuçlarıdır.
JavaScript'te veri aramaları iki şey tarafından yönetilir: prototip kalıtım ve kapsam zinciri . Bir geliştirici olarak, bu iki mekanizmayı açıkça anlamak çok önemlidir, çünkü bunu yapmak kodunuzun yapısını ve genellikle performansını iyileştirebilir.
Prototip zinciri üzerinden mülk aramaları
JavaScript gibi prototip tabanlı bir dilde bir özelliğe erişirken, nesnenin prototip ağacında farklı katmanları içeren dinamik bir arama gerçekleşir.
JavaScript'te her fonksiyon bir nesnedir. new
operatörle bir işlev çağrıldığında, yeni bir nesne oluşturulur. Örneğin:
function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; } var p1 = new Person('John', 'Doe'); var p2 = new Person('Robert', 'Doe');
Yukarıdaki örnekte, p1
ve p2
, her biri yapıcı olarak Person
işlevi kullanılarak oluşturulan iki farklı nesnedir. Bu kod parçacığında gösterildiği gibi, bunlar Person
öğesinin bağımsız örnekleridir:
console.log(p1 instanceof Person); // prints 'true' console.log(p2 instanceof Person); // prints 'true' console.log(p1 === p2); // prints 'false'
JavaScript işlevleri nesneler olduğundan, özelliklere sahip olabilirler. Her işlevin sahip olduğu özellikle önemli bir özelliğe prototype
adı verilir.
kendisi bir nesne olan prototype
, ebeveyninin prototipinden miras alan ebeveyninin prototipinden miras alır, vb. Bu genellikle prototip zinciri olarak adlandırılır. Her zaman prototip zincirinin sonunda (yani prototip miras ağacının en üstünde) bulunan Object.prototype
, toString()
, hasProperty()
, isPrototypeOf()
ve benzeri yöntemleri içerir.
Her işlevin prototipi, kendi özel yöntemlerini ve özelliklerini tanımlayacak şekilde genişletilebilir.
Bir nesneyi başlattığınızda ( new
operatörü kullanarak işlevi çağırarak), o işlevin prototipindeki tüm özellikleri devralır. Ancak, bu örneklerin prototype
nesnesine değil, yalnızca özelliklerine doğrudan erişimi olacağını unutmayın. Örneğin:
// 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
Burada önemli ve biraz incelikli bir nokta var: p1
getFullName
yöntemi tanımlanmadan önce oluşturulmuş olsa bile, prototipi Person
prototipi olduğu için yine de buna erişimi olacaktır.
(Tarayıcıların bir __proto__
özelliğindeki herhangi bir nesnenin prototipine bir referans da sakladığını belirtmekte fayda var, ancak standart ECMAScript Dil Belirtiminin bir parçası olmadığı için prototipe __proto__
özelliği aracılığıyla doğrudan erişmek gerçekten kötü bir uygulamadır . yapma! )
Person
nesnesinin p1
örneğinin kendisi prototype
nesnesine doğrudan erişime sahip olmadığı için, p1
getFullName
üzerine yazmak istiyorsak, bunu aşağıdaki gibi yaparız:
// We reference p1.getFullName, *NOT* p1.prototype.getFullName, // since p1.prototype does not exist: p1.getFullName = function(){ return 'I am anonymous'; }
Şimdi p1
kendi getFullName
özelliği var. Ancak (önceki örneğimizde yaratılan) p2
örneğinin kendine ait böyle bir özelliği yoktur. Bu nedenle, p1.getFullName()
çağrılması, p1
örneğinin kendisinin getFullName
yöntemine erişirken, p2.getFullName()
çağrılması, getFullName
öğesini çözümlemek için prototip zincirini Person
prototip nesnesine götürür:
console.log(p1.getFullName()); // prints 'I am anonymous' console.log(p2.getFullName()); // prints 'Robert Doe'
Dikkat edilmesi gereken bir diğer önemli nokta da, bir nesnenin prototipini dinamik olarak değiştirmenin de mümkün olmasıdır. Örneğin:
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'
Prototip kalıtımı kullanırken, üst sınıftan miras aldıktan veya alternatif bir prototip belirledikten sonra prototipte özellikleri tanımlamayı unutmayın.
Özetlemek gerekirse, JavaScript prototip zinciri üzerinden özellik aramaları şu şekilde çalışır:
- Nesnenin belirtilen ada sahip bir özelliği varsa, bu değer döndürülür. (Bir nesnenin belirli bir adlandırılmış özelliği olup olmadığını kontrol etmek için
hasOwnProperty
yöntemi kullanılabilir.) - Nesnenin adlandırılmış özelliği yoksa nesnenin prototipi kontrol edilir.
- Prototip de bir nesne olduğundan, özelliği de içermiyorsa ebeveyninin prototipi kontrol edilir.
- Bu işlem, özellik bulunana kadar prototip zinciri boyunca devam eder.
-
Object.prototype
ulaşılırsa ve özelliği de yoksa, özellikundefined
olarak kabul edilir.
Prototip devralma ve özellik aramalarının nasıl çalıştığını anlamak, geliştiriciler için genel olarak önemlidir, ancak (bazen önemli) JavaScript performans sonuçları nedeniyle de önemlidir. V8 (Google'ın açık kaynaklı, yüksek performanslı JavaScript motoru) belgelerinde belirtildiği gibi, çoğu JavaScript motoru, nesne özelliklerini depolamak için sözlük benzeri bir veri yapısı kullanır. Bu nedenle, her özellik erişimi, özelliği çözümlemek için bu veri yapısında dinamik bir arama gerektirir. Bu yaklaşım, JavaScript'teki özelliklere erişmeyi, Java ve Smalltalk gibi programlama dillerindeki örnek değişkenlere erişmekten genellikle çok daha yavaş hale getirir.
Kapsam zinciri boyunca değişken aramalar
JavaScript'teki bir başka arama mekanizması da kapsama dayalıdır.
Bunun nasıl çalıştığını anlamak için yürütme bağlamı kavramını tanıtmak gerekir.
JavaScript'te iki tür yürütme bağlamı vardır:
- Bir JavaScript işlemi başlatıldığında oluşturulan küresel bağlam
- Bir işlev çağrıldığında oluşturulan yerel bağlam
Yürütme bağlamları bir yığın halinde düzenlenir. Yığının altında her zaman her JavaScript programı için benzersiz olan global bağlam vardır. Bir işlevle her karşılaşıldığında, yeni bir yürütme bağlamı oluşturulur ve yığının üstüne itilir. İşlev yürütmeyi bitirdiğinde, bağlamı yığından çıkarılır.
Aşağıdaki kodu göz önünde bulundurun:
// 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
Her yürütme bağlamında, değişkenleri çözmek için kullanılan kapsam zinciri adı verilen özel bir nesne bulunur. Kapsam zinciri, esasen, en yakın bağlamdan küresel bağlama kadar şu anda erişilebilir kapsamların bir yığınıdır. (Biraz daha kesin olmak gerekirse, yığının en üstündeki nesneye, yürütülmekte olan işlev için yerel değişkenlere, adlandırılmış işlev bağımsız değişkenlerine ve iki "özel" nesneye referanslar içeren Etkinleştirme Nesnesi adı verilir: this
ve arguments
. ) Örneğin:

Yukarıdaki şemada this
varsayılan olarak window
nesnesini nasıl gösterdiğine ve ayrıca global bağlamın console
ve location
gibi diğer nesnelerin örneklerini nasıl içerdiğine dikkat edin.
Değişkenleri kapsam zinciri aracılığıyla çözmeye çalışırken, ilk olarak, anında bağlam eşleşen bir değişken için kontrol edilir. Eşleşme bulunamazsa, kapsam zincirindeki bir sonraki bağlam nesnesi kontrol edilir ve bir eşleşme bulunana kadar bu böyle devam eder. Eşleşme bulunamazsa, bir ReferenceError
atılır.
Bir try-catch
bloğu veya bir with
bloğu ile karşılaşıldığında, kapsam zincirine yeni bir kapsamın eklendiğini de unutmamak önemlidir. Bu durumlardan herhangi birinde, yeni bir nesne oluşturulur ve kapsam zincirinin en üstüne yerleştirilir:
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);
Kapsam tabanlı değişken aramalarının nasıl gerçekleştiğini tam olarak anlamak için JavaScript'te şu anda blok düzeyinde kapsam olmadığını akılda tutmak önemlidir. Örneğin:
for (var i = 0; i < 10; i++) { /* ... */ } // 'i' is still in scope! console.log(i); // prints '10'
Diğer dillerin çoğunda, i
değişkeninin "ömrü" (yani kapsamı) for bloğu ile sınırlandırılacağından, yukarıdaki kod bir hataya yol açacaktır. Ancak JavaScript'te durum böyle değil. Bunun yerine, kapsam zincirinin en üstündeki etkinleştirme nesnesine i
eklenir ve o nesne kapsamdan kaldırılana kadar orada kalır; bu, ilgili yürütme bağlamı yığından kaldırıldığında gerçekleşir. Bu davranış, değişken kaldırma olarak bilinir.
Bununla birlikte, blok düzeyinde kapsamlar için desteğin yeni let
anahtar sözcüğü aracılığıyla JavaScript'e girdiğini belirtmekte fayda var. let
anahtar sözcüğü JavaScript 1.7'de zaten mevcuttur ve ECMAScript 6'dan itibaren resmi olarak desteklenen bir JavaScript anahtar sözcüğü olması planlanmıştır.
JavaScript Performans Sonuçları
Sırasıyla prototip zincirini ve kapsam zincirini kullanan özellik ve değişken aramalarının JavaScript'te çalışma şekli, dilin temel özelliklerinden biridir, ancak anlaşılması en zor ve en incelikli olanlardan biridir.
Bu örnekte tanımladığımız arama işlemleri, ister prototip zincirine ister kapsam zincirine dayalı olsun, bir özelliğe veya değişkene her erişildiğinde tekrarlanır. Bu arama döngüler veya diğer yoğun işlemler içinde gerçekleştiğinde, özellikle dilin aynı anda birden çok işlemin gerçekleşmesini engelleyen tek iş parçacıklı yapısı ışığında, JavaScript performansında önemli sonuçlar doğurabilir.
Aşağıdaki örneği göz önünde bulundurun:
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');
Bu örnekte, uzun bir miras ağacımız ve iç içe üç döngümüz var. En derin döngü içinde, sayaç değişkeni delta
değeriyle artırılır. Ancak delta
, miras ağacının neredeyse en üstünde yer alır! Bu, child.delta
her erişildiğinde, ağacın tamamında aşağıdan yukarıya gidilmesi gerektiği anlamına gelir. Bunun performans üzerinde gerçekten olumsuz bir etkisi olabilir.
Bunu anlayarak, child.delta
değeri önbelleğe almak için yerel bir delta
değişkeni kullanarak (ve böylece tüm kalıtım ağacının tekrar tekrar geçiş ihtiyacını ortadan kaldırarak) yukarıdaki nestedFn
işlevinin performansını aşağıdaki gibi kolayca iyileştirebiliriz:
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');
Elbette, bu özel teknik yalnızca, for döngüleri yürütülürken child.delta
değerinin değişmeyeceğinin bilindiği bir senaryoda geçerlidir; aksi takdirde yerel kopyanın geçerli değerle güncellenmesi gerekir.
Tamam, şimdi nestedFn
yönteminin her iki sürümünü de çalıştıralım ve ikisi arasında kayda değer bir performans farkı olup olmadığına bakalım.
İlk örneği bir node.js REPL'de çalıştırarak başlayacağız:
diego@alkadia:~$ node test.js Final result: 10000000000 Total time: 8270 milliseconds
Yani çalıştırmak için yaklaşık 8 saniye sürer. Bu uzun bir süre.
Şimdi optimize edilmiş sürümü çalıştırdığımızda ne olacağını görelim:
diego@alkadia:~$ node test2.js Final result: 10000000000 Total time: 1143 milliseconds
Bu sefer sadece bir saniye sürdü. Çok daha hızlı!
Pahalı aramalardan kaçınmak için yerel değişkenlerin kullanılmasının, hem özellik araması (prototip zinciri aracılığıyla) hem de değişken aramaları (kapsam zinciri aracılığıyla) için uygulanabilecek bir teknik olduğunu unutmayın.
Ayrıca, değerlerin bu tür "önbelleğe alınması" (yani, yerel kapsamdaki değişkenlerde), en yaygın JavaScript kitaplıklarından bazılarını kullanırken de faydalı olabilir. Örneğin, jQuery'yi alın. jQuery, temelde DOM'da bir veya daha fazla eşleşen öğeyi almak için bir mekanizma olan "seçici" kavramını destekler. Birinin jQuery'de seçicileri belirleme kolaylığı, her seçici aramasının ne kadar maliyetli (performans açısından) olabileceğini unutmasına neden olabilir. Buna göre, seçici arama sonuçlarının yerel bir değişkende saklanması performans için son derece faydalı olabilir. Örneğin:
// 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);
Özellikle çok sayıda öğeye sahip bir web sayfasında, yukarıdaki kod örneğindeki ikinci yaklaşım, potansiyel olarak birinciden önemli ölçüde daha iyi performansla sonuçlanabilir.
Sarmak
JavaScript'te veri arama, diğer dillerin çoğundan oldukça farklıdır ve oldukça nüanslıdır. Bu nedenle, dilde gerçekten ustalaşmak için bu kavramları tam ve doğru bir şekilde anlamak çok önemlidir. Veri arama ve diğer yaygın JavaScript hatalarından mümkün olduğunca kaçınılmalıdır. Bu anlayış, muhtemelen daha iyi JavaScript performansı sağlayan daha temiz, daha sağlam kod sağlayacaktır.