JavaScript Prototip Zincirleri, Kapsam Zincirleri ve Performans: Bilmeniz Gerekenler

Yayınlanan: 2022-03-11

JavaScript: 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.

JavaScript prototipi ile kapsam zinciri arasındaki ilişki önemlidir

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' 

Bu JavaScript prototip örneğinde P1 ve P2'nin Kişi prototipiyle nasıl ilişkili olduğunu görün.

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.

Bu şema, bir prototip zincirindeki JavaScript prototipleri arasındaki ilişkinin bir örneğini gösterir.

Ö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, özellik undefined 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:

Kapsam zincirinin nesnelerle ilişkisi bu JavaScript örneğinde özetlenmiştir.

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.

İlgili: Bir JS Geliştiricisi Olarak, Beni Geceleri Uyandıran Bu / ES6 Sınıfı Karmaşasını Anlamak