Łańcuchy prototypów JavaScript, łańcuchy zakresu i wydajność: co musisz wiedzieć

Opublikowany: 2022-03-11

JavaScript: więcej niż na pierwszy rzut oka

Na początku JavaScript może wydawać się bardzo łatwym językiem do nauczenia. Być może wynika to z jego elastycznej składni. A może to z powodu podobieństwa do innych dobrze znanych języków, takich jak Java. A może dlatego, że ma tak mało typów danych w porównaniu z językami takimi jak Java, Ruby czy .NET.

Ale tak naprawdę JavaScript jest znacznie mniej uproszczony i bardziej zniuansowany, niż większość programistów początkowo sądzi. Nawet dla programistów z większym doświadczeniem, niektóre z najistotniejszych funkcji JavaScript są nadal źle rozumiane i prowadzą do zamieszania. Jedną z takich cech jest sposób, w jaki wykonywane są wyszukiwania danych (właściwości i zmiennych) oraz konsekwencje wydajności JavaScript, o których należy pamiętać.

W JavaScript wyszukiwaniem danych rządzą dwie rzeczy: dziedziczenie prototypowe i łańcuch zasięgu . Jako programista, jasne zrozumienie tych dwóch mechanizmów jest niezbędne, ponieważ może to poprawić strukturę, a często wydajność kodu.

Wyszukiwanie właściwości w łańcuchu prototypów

Podczas uzyskiwania dostępu do właściwości w języku opartym na prototypach, takim jak JavaScript, następuje wyszukiwanie dynamiczne, które obejmuje różne warstwy w drzewie prototypów obiektu.

W JavaScript każda funkcja jest obiektem. Gdy funkcja jest wywoływana z operatorem new , tworzony jest nowy obiekt. Na przykład:

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

W powyższym przykładzie p1 i p2 to dwa różne obiekty, z których każdy został utworzony przy użyciu funkcji Person jako konstruktora. Są to niezależne instancje Person , jak pokazuje ten fragment kodu:

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

Ponieważ funkcje JavaScript są obiektami, mogą mieć właściwości. Szczególnie ważną właściwością każdej funkcji jest prototype .

prototype , który sam jest obiektem, dziedziczy po prototypie swojego rodzica, który dziedziczy po prototypie swojego rodzica i tak dalej. Jest to często określane jako łańcuch prototypów . Object.prototype , który zawsze znajduje się na końcu łańcucha prototypów (tj. na szczycie prototypowego drzewa dziedziczenia), zawiera metody takie jak toString() , hasProperty() , isPrototypeOf() i tak dalej.

Ważna jest relacja między prototypem JavaScript a łańcuchem zasięgu

Prototyp każdej funkcji można rozszerzyć, aby zdefiniować własne niestandardowe metody i właściwości.

Kiedy tworzysz instancję obiektu (poprzez wywołanie funkcji przy użyciu operatora new ), dziedziczy on wszystkie właściwości z prototypu tej funkcji. Pamiętaj jednak, że te instancje nie będą miały bezpośredniego dostępu do obiektu prototype , a jedynie do jego właściwości. Na przykład:

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

Jest tu ważna i nieco subtelna uwaga: nawet jeśli p1 został utworzony przed zdefiniowaniem metody getFullName , nadal będzie miał do niego dostęp, ponieważ jego prototypem jest prototyp Person .

(Warto zauważyć, że przeglądarki przechowują również odniesienie do prototypu dowolnego obiektu we właściwości __proto__ , ale naprawdę złą praktyką jest bezpośredni dostęp do prototypu za pośrednictwem właściwości __proto__ , ponieważ nie jest to część standardowej specyfikacji języka ECMAScript, więc nie nie rób tego! )

Ponieważ instancja p1 obiektu Person sama w sobie nie ma bezpośredniego dostępu do obiektu prototype , jeśli chcemy nadpisać getFullName w p1 , zrobimy to w następujący sposób:

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

Teraz p1 ma swoją własną właściwość getFullName . Ale instancja p2 (stworzona w naszym poprzednim przykładzie) nie ma żadnej takiej własnej właściwości. Dlatego wywołanie p1.getFullName() uzyskuje dostęp do metody getFullName samej instancji p1 , podczas gdy wywołanie p2.getFullName() przechodzi w górę łańcucha prototypów do obiektu prototypu Person w celu rozwiązania getFullName :

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

Zobacz, jak P1 i P2 odnoszą się do prototypu Person w tym przykładzie prototypu JavaScript.

Kolejną ważną rzeczą, o której należy pamiętać, jest możliwość dynamicznej zmiany prototypu obiektu. Na przykład:

 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'

Używając dziedziczenia prototypowego, pamiętaj, aby zdefiniować właściwości w prototypie po odziedziczeniu z klasy nadrzędnej lub określeniu alternatywnego prototypu.

Ten diagram pokazuje przykład relacji między prototypami JavaScript w łańcuchu prototypów.

Podsumowując, wyszukiwanie właściwości w łańcuchu prototypów JavaScript działa w następujący sposób:

  • Jeśli obiekt ma właściwość o podanej nazwie, zwracana jest ta wartość. (Metoda hasOwnProperty może być użyta do sprawdzenia, czy obiekt ma konkretną nazwaną właściwość.)
  • Jeśli obiekt nie posiada nazwanej właściwości, sprawdzany jest prototyp obiektu
  • Ponieważ prototyp jest również obiektem, jeśli również nie zawiera właściwości, sprawdzany jest prototyp jego rodzica.
  • Ten proces jest kontynuowany w łańcuchu prototypów, aż do znalezienia właściwości.
  • Jeśli osiągnięto Object.prototype i nie ma on również właściwości, właściwość jest uważana za undefined .

Zrozumienie, jak działa dziedziczenie prototypowe i wyszukiwanie właściwości, jest ogólnie ważne dla programistów, ale jest również niezbędne ze względu na jego (czasem znaczące) konsekwencje wydajności JavaScript. Jak wspomniano w dokumentacji dla V8 (otwartego źródła Google, wysokowydajnego silnika JavaScript), większość silników JavaScript używa struktury danych podobnej do słownika do przechowywania właściwości obiektów. Każdy dostęp do właściwości wymaga zatem dynamicznego wyszukiwania w tej strukturze danych w celu rozwiązania właściwości. Takie podejście sprawia, że ​​dostęp do właściwości w JavaScript jest zwykle znacznie wolniejszy niż dostęp do zmiennych instancji w językach programowania, takich jak Java i Smalltalk.

Wyszukiwania zmiennych w łańcuchu zasięgu

Kolejny mechanizm wyszukiwania w JavaScript oparty jest na zakresie.

Aby zrozumieć, jak to działa, konieczne jest wprowadzenie pojęcia kontekstu wykonania.

W JavaScript istnieją dwa rodzaje kontekstów wykonania:

  • Kontekst globalny, tworzony po uruchomieniu procesu JavaScript
  • Kontekst lokalny, tworzony podczas wywoływania funkcji

Konteksty wykonania są zorganizowane w stos. Na dole stosu zawsze znajduje się kontekst globalny, który jest unikalny dla każdego programu JavaScript. Za każdym razem, gdy napotkana zostanie funkcja, tworzony jest nowy kontekst wykonania i umieszczany na szczycie stosu. Po zakończeniu wykonywania funkcji jej kontekst jest usuwany ze stosu.

Rozważ następujący kod:

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

W każdym kontekście wykonania znajduje się specjalny obiekt zwany łańcuchem zasięgu, który służy do rozwiązywania zmiennych. Łańcuch zakresów jest zasadniczo stosem aktualnie dostępnych zakresów, od kontekstu najbardziej bezpośredniego do kontekstu globalnego. (Aby być bardziej precyzyjnym, obiekt na szczycie stosu nazywany jest obiektem aktywacji , który zawiera odniesienia do zmiennych lokalnych dla wykonywanej funkcji, nazwanych argumentów funkcji i dwóch „specjalnych” obiektów: this i arguments . ) Na przykład:

Sposób, w jaki łańcuch zasięgu odnosi się do obiektów, został przedstawiony w tym przykładzie JavaScript.

Zauważ na powyższym diagramie, w jaki sposób domyślnie wskazuje this na obiekt window , a także jak kontekst globalny zawiera przykłady innych obiektów, takich jak console i location .

Podczas próby rozwiązania zmiennych za pośrednictwem łańcucha zasięgu najpierw sprawdzany jest kontekst bezpośredni pod kątem pasującej zmiennej. Jeśli nie zostanie znalezione żadne dopasowanie, sprawdzany jest następny obiekt kontekstu w łańcuchu zasięgu i tak dalej, aż do znalezienia dopasowania. Jeśli nie zostanie znalezione żadne dopasowanie, zostanie zgłoszony ReferenceError .

Ważne jest również, aby pamiętać, że nowy zakres jest dodawany do łańcucha zakresów, gdy napotkany zostanie blok try-catch lub with blokiem. W każdym z tych przypadków nowy obiekt jest tworzony i umieszczany na szczycie łańcucha zasięgu:

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

Aby w pełni zrozumieć, w jaki sposób występują wyszukiwania zmiennych w oparciu o zakres, należy pamiętać, że w JavaScript obecnie nie ma zakresów na poziomie bloków. Na przykład:

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

W większości innych języków powyższy kod prowadziłby do błędu, ponieważ „życie” (tj. zasięg) zmiennej i byłoby ograniczone do bloku for. Jednak w JavaScript tak nie jest. Zamiast tego, i jest dodawane do obiektu aktywacji na szczycie łańcucha zasięgu i pozostanie tam, dopóki obiekt nie zostanie usunięty z zakresu, co ma miejsce, gdy odpowiedni kontekst wykonania zostanie usunięty ze stosu. Takie zachowanie jest znane jako zmienne podnoszenie.

Warto jednak zauważyć, że obsługa zakresów na poziomie bloków trafia do JavaScriptu poprzez nowe słowo kluczowe let . Słowo kluczowe let jest już dostępne w JavaScript 1.7 i ma stać się oficjalnie obsługiwanym słowem kluczowym JavaScript od ECMAScript 6.

Konsekwencje wydajności JavaScript

Sposób, w jaki wyszukiwania właściwości i zmiennych, przy użyciu odpowiednio łańcucha prototypów i łańcucha zasięgu, działają w JavaScript, jest jedną z kluczowych cech języka, jednak jest jedną z najtrudniejszych i najsubtelniejszych do zrozumienia.

Operacje wyszukiwania, które opisaliśmy w tym przykładzie, niezależnie od tego, czy są oparte na łańcuchu prototypów, czy łańcuchu zasięgu, są powtarzane za każdym razem, gdy uzyskiwany jest dostęp do właściwości lub zmiennej. Gdy to wyszukiwanie występuje w pętlach lub innych intensywnych operacjach, może mieć znaczące konsekwencje dla wydajności JavaScript, szczególnie w świetle jednowątkowej natury języka, która zapobiega jednoczesnemu wykonywaniu wielu operacji.

Rozważmy następujący przykład:

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

W tym przykładzie mamy długie drzewo dziedziczenia i trzy zagnieżdżone pętle. Wewnątrz najgłębszej pętli zmienna licznika jest zwiększana o wartość delta . Ale delta znajduje się prawie na szczycie drzewa dziedziczenia! Oznacza to, że za każdym razem, gdy uzyskuje się dostęp do child.delta , pełne drzewo musi być nawigowane od dołu do góry. Może to mieć naprawdę negatywny wpływ na wydajność.

Rozumiejąc to, możemy łatwo poprawić wydajność powyższej funkcji nestedFn , używając lokalnej zmiennej delta do buforowania wartości w child.delta (a tym samym uniknąć konieczności powtarzania całego drzewa dziedziczenia) w następujący sposób:

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

Oczywiście ta konkretna technika jest możliwa tylko w scenariuszu, w którym wiadomo, że wartość child.delta nie zmieni się podczas wykonywania pętli for; w przeciwnym razie kopia lokalna musiałaby zostać zaktualizowana o bieżącą wartość.

OK, uruchommy obie wersje metody nestedFn i zobaczmy, czy jest między nimi jakaś znacząca różnica w wydajności.

Zaczniemy od uruchomienia pierwszego przykładu w repozytorium node.js:

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

To trwa około 8 sekund. To długi czas.

Zobaczmy teraz, co się stanie, gdy uruchomimy zoptymalizowaną wersję:

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

Tym razem zajęło to tylko sekundę. O wiele szybciej!

Należy zauważyć, że użycie zmiennych lokalnych w celu uniknięcia kosztownych wyszukiwań jest techniką, którą można zastosować zarówno do wyszukiwania właściwości (poprzez łańcuch prototypów), jak i do wyszukiwania zmiennych (poprzez łańcuch zasięgu).

Co więcej, ten rodzaj „buforowania” wartości (tj. w zmiennych w zakresie lokalnym) może być również korzystny przy korzystaniu z niektórych z najpopularniejszych bibliotek JavaScript. Weźmy na przykład jQuery. jQuery obsługuje pojęcie „selektorów”, które są w zasadzie mechanizmem pobierania jednego lub więcej pasujących elementów w DOM. Łatwość, z jaką można określić selektory w jQuery, może spowodować, że zapomni się, jak kosztowne (z punktu widzenia wydajności) może być każde wyszukiwanie selektora. W związku z tym przechowywanie wyników wyszukiwania selektora w zmiennej lokalnej może być niezwykle korzystne dla wydajności. Na przykład:

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

Zwłaszcza na stronie internetowej z dużą liczbą elementów drugie podejście w powyższym przykładzie kodu może potencjalnie skutkować znacznie lepszą wydajnością niż pierwsze.

Zakończyć

Wyszukiwanie danych w JavaScript jest zupełnie inne niż w większości innych języków i jest bardzo zniuansowane. Dlatego niezbędne jest pełne i właściwe zrozumienie tych pojęć, aby naprawdę opanować język. W miarę możliwości należy unikać wyszukiwania danych i innych typowych błędów JavaScript. Takie zrozumienie prawdopodobnie zapewni czystszy, bardziej niezawodny kod, który zapewnia lepszą wydajność JavaScript.

Powiązane: Jako programista JS, to jest to, co nie daje mi spać w nocy / Zrozumienie zamieszania klas ES6