Lanțuri de prototipuri JavaScript, lanțuri de acoperire și performanță: ceea ce trebuie să știți

Publicat: 2022-03-11

JavaScript: Mai mult decât se vede

JavaScript poate părea un limbaj foarte ușor de învățat la început. Poate din cauza sintaxei sale flexibile. Sau poate din cauza asemănării sale cu alte limbi bine cunoscute precum Java. Sau poate pentru că are atât de puține tipuri de date în comparație cu limbi precum Java, Ruby sau .NET.

Dar, în adevăr, JavaScript este mult mai puțin simplist și mai nuanțat decât își dau seama inițial majoritatea dezvoltatorilor. Chiar și pentru dezvoltatorii cu mai multă experiență, unele dintre cele mai importante caracteristici ale JavaScript continuă să fie înțelese greșit și să conducă la confuzie. O astfel de caracteristică este modul în care sunt efectuate căutările datelor (proprietăți și variabile) și ramificațiile performanței JavaScript de care trebuie să fiți conștienți.

În JavaScript, căutările de date sunt guvernate de două lucruri: moștenirea prototipului și lanțul de sfere . În calitate de dezvoltator, înțelegerea clară a acestor două mecanisme este esențială, deoarece acest lucru poate îmbunătăți structura și, adesea, performanța codului dvs.

Căutări de proprietăți prin lanțul de prototipuri

Când accesați o proprietate într-un limbaj bazat pe prototip, cum ar fi JavaScript, are loc o căutare dinamică care implică diferite straturi din arborele prototip al obiectului.

În JavaScript, fiecare funcție este un obiect. Când o funcție este invocată cu new operator, este creat un nou obiect. De exemplu:

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

În exemplul de mai sus, p1 și p2 sunt două obiecte diferite, fiecare creat folosind funcția Person ca constructor. Acestea sunt instanțe independente de Person , așa cum demonstrează acest fragment de cod:

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

Deoarece funcțiile JavaScript sunt obiecte, ele pot avea proprietăți. O proprietate deosebit de importantă pe care o are fiecare funcție se numește prototype .

prototype , care este el însuși un obiect, moștenește de la prototipul părintelui său, care moștenește de la prototipul părintelui său și așa mai departe. Acesta este adesea denumit lanțul de prototipuri . Object.prototype , care se află întotdeauna la sfârșitul lanțului de prototipuri (adică, în partea de sus a arborelui de moștenire prototip), conține metode precum toString() , hasProperty() , isPrototypeOf() și așa mai departe.

Relația dintre prototipul JavaScript și lanțul de acoperire este importantă

Prototipul fiecărei funcții poate fi extins pentru a-și defini propriile metode și proprietăți personalizate.

Când instanțiați un obiect (prin invocarea funcției folosind operatorul new ), acesta moștenește toate proprietățile din prototipul acelei funcții. Rețineți, totuși, că acele instanțe nu vor avea acces direct la obiectul prototype , ci doar la proprietățile acestuia. De exemplu:

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

Există un punct important și oarecum subtil aici: chiar dacă p1 a fost creat înainte ca metoda getFullName să fie definită, va avea în continuare acces la el, deoarece prototipul său este prototipul Person .

(Este de remarcat faptul că browserele stochează și o referință la prototipul oricărui obiect dintr-o proprietate __proto__ , dar este o practică foarte proastă să accesați direct prototipul prin proprietatea __proto__ , deoarece nu face parte din specificația standard a limbajului ECMAScript, așa că nu nu o face! )

Deoarece instanța p1 a obiectului Person nu are ea însăși acces direct la obiectul prototype , dacă dorim să suprascriem getFullName în p1 , am face acest lucru după cum urmează:

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

Acum p1 are propria sa proprietate getFullName . Dar instanța p2 (creată în exemplul nostru anterior) nu are o astfel de proprietate proprie. Prin urmare, invocarea p1.getFullName() accesează metoda getFullName a instanței p1 în sine, în timp ce invocarea p2.getFullName() urcă în lanțul prototipului până la obiectul prototip Person pentru a rezolva getFullName :

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

Vedeți cum P1 și P2 se leagă de prototipul Persoană în acest exemplu de prototip JavaScript.

Un alt lucru important de care trebuie să fii conștient este că este, de asemenea, posibil să schimbi dinamic prototipul unui obiect. De exemplu:

 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'

Când utilizați moștenirea prototipală, nu uitați să definiți proprietățile în prototip după ce fie ați moștenit de la clasa părinte, fie ați specificat un prototip alternativ.

Această diagramă arată un exemplu de relație dintre prototipurile JavaScript dintr-un lanț de prototipuri.

Pentru a rezuma, căutările de proprietăți prin lanțul de prototipuri JavaScript funcționează după cum urmează:

  • Dacă obiectul are o proprietate cu numele dat, acea valoare este returnată. (Metoda hasOwnProperty poate fi folosită pentru a verifica dacă un obiect are o anumită proprietate numită.)
  • Dacă obiectul nu are proprietatea numită, prototipul obiectului este verificat
  • Deoarece prototipul este și un obiect, dacă nici nu conține proprietatea, se verifică prototipul părintelui său.
  • Acest proces continuă în lanțul de prototipuri până când proprietatea este găsită.
  • Dacă se ajunge la Object.prototype și nici nu are proprietatea, proprietatea este considerată undefined .

Înțelegerea modului în care funcționează moștenirea prototipului și căutările de proprietate este importantă în general pentru dezvoltatori, dar este, de asemenea, esențială din cauza ramificațiilor sale (uneori semnificative) de performanță JavaScript. După cum se menționează în documentația pentru V8 (motorul JavaScript de înaltă performanță, open source Google), majoritatea motoarelor JavaScript folosesc o structură de date asemănătoare dicționarului pentru a stoca proprietățile obiectului. Prin urmare, fiecare acces la proprietate necesită o căutare dinamică în acea structură de date pentru a rezolva proprietatea. Această abordare face accesarea proprietăților în JavaScript de obicei mult mai lentă decât accesarea variabilelor de instanță în limbaje de programare precum Java și Smalltalk.

Căutări variabile prin lanțul domeniului de aplicare

Un alt mecanism de căutare în JavaScript se bazează pe domeniul de aplicare.

Pentru a înțelege cum funcționează, este necesar să introduceți conceptul de context de execuție.

În JavaScript, există două tipuri de contexte de execuție:

  • Context global, creat atunci când este lansat un proces JavaScript
  • Context local, creat atunci când o funcție este invocată

Contextele de execuție sunt organizate într-o stivă. În partea de jos a stivei, există întotdeauna contextul global, care este unic pentru fiecare program JavaScript. De fiecare dată când o funcție este întâlnită, un nou context de execuție este creat și împins în partea de sus a stivei. Odată ce funcția a terminat de executat, contextul acesteia este scos din stivă.

Luați în considerare următorul cod:

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

În fiecare context de execuție se află un obiect special numit lanț de scope, care este folosit pentru a rezolva variabile. Un lanț de domenii este în esență o stivă de domenii accesibile în prezent, de la contextul cel mai imediat până la contextul global. (Pentru a fi puțin mai precis, obiectul din partea de sus a stivei este numit un obiect de activare care conține referințe la variabilele locale pentru funcția care se execută, argumentele funcției numite și două obiecte „speciale”: this și arguments . ) De exemplu:

Modul în care lanțul domeniului de aplicare se raportează la obiecte este subliniat în acest exemplu JavaScript.

Observați în diagrama de mai sus cum this indică implicit obiectul window și, de asemenea, cum contextul global conține exemple de alte obiecte, cum ar fi console și location .

Atunci când se încearcă rezolvarea variabilelor prin lanțul de domeniu, contextul imediat este mai întâi verificat pentru o variabilă care se potrivește. Dacă nu se găsește nicio potrivire, următorul obiect de context din lanțul de domeniu este verificat și așa mai departe, până când este găsită o potrivire. Dacă nu se găsește nicio potrivire, se afișează o ReferenceError .

De asemenea, este important să rețineți că un nou domeniu de aplicare este adăugat la lanțul de scop atunci când este întâlnit un bloc try-catch sau un bloc with . În oricare dintre aceste cazuri, un nou obiect este creat și plasat în partea superioară a lanțului domeniului:

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

Pentru a înțelege pe deplin cum au loc căutările de variabile bazate pe domeniu, este important să rețineți că în JavaScript nu există în prezent domenii la nivel de bloc. De exemplu:

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

În majoritatea celorlalte limbi, codul de mai sus ar duce la o eroare, deoarece „viața” (adică domeniul de aplicare) variabilei i ar fi limitată la blocul for. În JavaScript, însă, acesta nu este cazul. Mai degrabă, i este adăugat la obiectul de activare din partea de sus a lanțului domeniului și va rămâne acolo până când acel obiect este eliminat din domeniul de aplicare, ceea ce se întâmplă când contextul de execuție corespunzător este eliminat din stivă. Acest comportament este cunoscut sub numele de ridicare variabilă.

Totuși, merită remarcat faptul că suportul pentru domenii la nivel de bloc își face loc în JavaScript prin noul cuvânt cheie let . Cuvântul cheie let este deja disponibil în JavaScript 1.7 și este programat să devină un cuvânt cheie JavaScript acceptat oficial începând cu ECMAScript 6.

Ramificații ale performanței JavaScript

Modul în care căutările de proprietăți și variabile, folosind lanțul de prototipuri și, respectiv, lanțul de scop, funcționează în JavaScript este una dintre caracteristicile cheie ale limbajului, dar este una dintre cele mai dificile și mai subtile de înțeles.

Operațiunile de căutare pe care le-am descris în acest exemplu, fie că se bazează pe lanțul de prototipuri sau pe lanțul scope, sunt repetate de fiecare dată când este accesată o proprietate sau o variabilă. Atunci când această căutare are loc în bucle sau alte operațiuni intensive, poate avea ramificații semnificative de performanță JavaScript, în special în lumina naturii cu un singur thread a limbajului, care împiedică operațiunile multiple să aibă loc concomitent.

Luați în considerare următorul exemplu:

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

În acest exemplu, avem un arbore de moștenire lung și trei bucle imbricate. În interiorul celei mai adânci bucle, variabila contor este incrementată cu valoarea delta . Dar delta este situat aproape în vârful arborelui moștenire! Aceasta înseamnă că de fiecare dată când se accesează child.delta , arborele complet trebuie să fie navigat de jos în sus. Acest lucru poate avea un impact cu adevărat negativ asupra performanței.

Înțelegând acest lucru, putem îmbunătăți cu ușurință performanța funcției nestedFn de mai sus utilizând o variabilă delta locală pentru a stoca în cache valoarea din child.delta (și, prin urmare, evităm nevoia de parcurgere repetitivă a întregului arbore de moștenire), după cum urmează:

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

Desigur, această tehnică specială este viabilă doar într-un scenariu în care se știe că valoarea child.delta nu se va schimba în timp ce buclele for sunt în execuție; în caz contrar, copia locală ar trebui actualizată cu valoarea curentă.

OK, haideți să rulăm ambele versiuni ale metodei nestedFn și să vedem dacă există vreo diferență apreciabilă de performanță între cele două.

Vom începe prin a rula primul exemplu într-un REPL node.js:

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

Deci durează aproximativ 8 secunde pentru a rula. E o perioadă lungă.

Acum să vedem ce se întâmplă când rulăm versiunea optimizată:

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

De data aceasta a durat doar o secundă. Mult mai repede!

Rețineți că utilizarea variabilelor locale pentru a evita căutările costisitoare este o tehnică care poate fi aplicată atât pentru căutarea proprietăților (prin lanțul de prototipuri), cât și pentru căutări de variabile (prin lanțul de scope).

Mai mult, acest tip de „caching” a valorilor (adică, în variabile în domeniul local) poate fi, de asemenea, benefic atunci când se utilizează unele dintre cele mai comune biblioteci JavaScript. Luați jQuery, de exemplu. jQuery acceptă noțiunea de „selectori”, care sunt practic un mecanism pentru a prelua unul sau mai multe elemente care se potrivesc în DOM. Ușurința cu care se pot specifica selectoare în jQuery poate face să uite cât de costisitoare (din punct de vedere al performanței) poate fi fiecare căutare a selectorului. În consecință, stocarea rezultatelor căutării selectorului într-o variabilă locală poate fi extrem de benefică pentru performanță. De exemplu:

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

În special pe o pagină web cu un număr mare de elemente, a doua abordare din exemplul de cod de mai sus poate duce la o performanță semnificativ mai bună decât prima.

Învelire

Căutarea datelor în JavaScript este destul de diferită de cea în majoritatea altor limbi și este foarte nuanțată. Prin urmare, este esențial să înțelegem pe deplin și corect aceste concepte pentru a stăpâni cu adevărat limbajul. Căutarea datelor și alte greșeli JavaScript comune ar trebui evitate ori de câte ori este posibil. Această înțelegere va genera probabil un cod mai curat și mai robust, care obține performanțe JavaScript îmbunătățite.

Înrudit: În calitate de dezvoltator JS, acesta este ceea ce mă ține treaz noaptea / Înțelegând confuzia clasei ES6