Există o opinia că programarea front-end este mai puțin expusă modelelor și șabloanelor arhitecturale, cât mai degrabă o vânătoare de add-onuri cât mai stabile. De vreme ce aceasta este o abordare evident superficială, vom folosi acest articol pentru a arunca puțină lumină asupra moștenirii clasice în JavaScript, șabloanele lui uzuale, feature-uri și greșeli frecvente în aplicarea lor. Vom lua în considerare exemple de moștenire în Babel, Backbone JS și Ember JS, și vom încerca să derivăm principiile cheie ale moștenirii orientate-obiect pentru crearea implementării personalizate folosind EcmaScript 5.
Sub denumirea de "clasic", înțelegem moștenirea în stilul OOP. Este de notat faptul că nu există moștenire clasică în JavaScript pur. Mai mult, îi lipsește complet noțiunea de clase. Deși specificațiile recente de EcmaScript adaugă construcții sintactice pentru lucrul cu clase, aceasta nu schimbă faptul că la bază folosește de fapt funcții constructor și prototipare. În consecință, această tehnică este adesea denumită moștenire "pseudo-clasică". Urmărește, probabil, singurul scop - de a reprezenta cod într-un stil familiar OOP.
Există numeroase tehnici de moștenire, pe lângă cea clasică: funcțională, pur prototipală, structurală, folosind mixinuri. Adevăratul concept de moștenire, care a câștigat o largă popularitate printre programatori, este criticată și contrastată cu alternativă rezonabilă - compoziția. Deși moștenirea, mai ales în stilul clasic, nu este un panaceu. Utilitatea ei depinde de o situație concretă specifică proiectului. Oricum, în acest articol, nu vom intra în detaliile legate de avantajele și dezavantajele acestei abordări, ci mai degrabă ne vom concentra pe modalitățile corecte de aplicarea ei.
Așadar, am decis să folosim OOP și moștenirea clasică în limbajul pe care nu o permite în esență. Această soluție este adesea adoptată în proiecte de mari dimensiuni de către programatorii obișnuiți cu OOP în alte limbaje. Este de asemenea folosită în multe frameworkuri majore: Backbone JS, Ember JS, etc. , dar și în specificarea recentă a EcmaScript.
Cel mai bun sfat în utilizarea moștenirii ar fi să fie folosită conform descrierii din EcmaScript 6, prin cuvintele cheie class, extends, constructor, s.a.m.d. Dacă există o astfel de opțiune, este cea mai bună din punct de vedere al interpretării codului și a performanței. Toate descrierile care urmează vor fi utile pentru cazul specificațiilor vechi, atunci când proiectul a fost deja pornit conform ES5, iar tranziția spre noua versiune nu pare disponibilă.
Iată câteva exemple populare de realizare a moștenirii clasice, pe care le analizăm prin următoarele cinci aspecte:
Eficiența memoriei;
Performanța;
Proprietăți și metode statice;
Referința la clasa de bază;
În primul rând, evident, trebuie să ne asigurăm că șablonul este eficient din punctul de vedere al performanței și eficienței memoriei. Din acest considerent, nu există cerințe speciale de la următoarele exemple din frameworkuri populare. Dar, în practică, adeseori apar exemple eronate care duc la memory leaks și saturarea stackului, pe care le vom discuta mai jos.
Celelalte criterii din listă sunt legate de utilizarea și lizibilitatea codului. Vor fi mai "utilizabile" implementările care sunt strâns legate ca sintaxă și funcționalitate de moștenirea clasică din alte limbaje. De exemplu, referința la clasa de bază (cuvântul cheie super) este opțional, dar prezența lui este de dorit pentru o emulare completă a moștenirii clasice. Prin "detalii cosmetice" înțelegem designul general al codului, ușurința operațiunii de depanare, utilizarea operatorului instanceof
, s.a.m.d.
Luăm în considerare moștenirea EcmaScript 6 și rezultatele pe care le obținem când compilăm codul în ES5 cu Babel. Observați mai jos un exemplu de extinderea clasei în ES6:
class BasicClass {
static staticMethod() {}
constructor(x) {
this.x = x;
}
someMethod() {}
}
class DerivedClass extends BasicClass {
static staticMethod() {}
constructor(x) {
super(x);
}
someMethod() {
super.someMethod();
}
}
Așa cum se poate observa, sintaxa este similar cu alte limbaje OOP, cu excepția tipurilor și a modificatorilor de acces. Aceasta este unicitatea utilizării ES6 cu compilator: ne putem permite sintaxa confortabilă și obținerea codului ES5 funcțional, în același timp. Niciunul din exemplele următoare nu se pot lăuda cu același nivel de simplitate sintactică, deoarece funcția de moștenire este gata creată, fără transformări de sintaxă.
Compilatorul Babel implementează moștenirea folosind o simplă funcție denumită _inherits
:
function _inherits(subClass, superClass) {
if (typeof superClass !== "function"
&& superClass !== null) {
throw new TypeError("Super expression must" +
"either be null or a function, not "
+ typeof superClass);
}
subClass.prototype = Object.create(superClass
&& superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
});
if (superClass) Object.setPrototypeOf
? Object.setPrototypeOf(subClass, superClass)
: subClass.__proto__ = superClass;
}
Cheia, în acest caz, este utilizarea următoarei linii:
subClass.prototype = Object.create(superClass.prototype);
Acest apel creează un obiect cu prototipul specificat. Proprietatea prototype
a constructorului subclass referă un obiect nou al cărui prototip este superclass.prototype
. Prin urmare, aceasta este o moștenire prototipală simplă, deghizată ca o formă clasică a codului sursa.
Moștenirea câmpurilor statice se realizează cu ajutorul următoarei linii:
Object.setPrototypeOf
? Object.setPrototypeOf(subClass, superClass)
: subClass.__proto__ = superClass;
Constructorul clasei de bază (i.e. funcției) este chiar prototipul constructorului noii clase (i.e. o altă funcție). În acest fel, toate funcțiile și proprietățile statice ale clasei de bază devin accesibile din sub-clasă. În absența setPrototypeOf
, Babel asigură atribuirea directă a prototipului spre proprietatea ascunsă __proto__
- această metodă nu este recomandată, dar este potrivită pentru cazul marginal de utilizare a browserelor vechi.
Atribuirea metodelor, atât statice cât și dinamice, intervine separat, după apelarea _inherits
prin simpla copiere a referințelor în constructor sau în propriul prototip
. La scrierea implementării personalizate a moștenirii, putem folosi acest exemplu ca bază și să-i adăugăm obiecte cu proprietăți dinamice și statice ca parametri suplimentari.
Cuvântul cheie "super" este înlocuit în timpul compilării cu apelul direct al prototipului. De exemplu, invocarea super-constructorului din exemplul de mai sus este înlocuită cu următoarea linie:
return _possibleConstructorReturn(this
, (DerivedClass.__proto__
|| Object.getPrototypeOf(DerivedClass))
.call(this, x));
Babel folosește multe funcții ajutătoare despre care nu vom vorbi aici. Concluzia este că interpretorul primește prototipul constructorului clasei curente, care este însuși constructorul clasei de bază (vezi mai sus), și îl apelează cu contextul actual.
Într-o implementare personalizată cu ES5 pur, putem adăuga manual câmpul _super
în constructor și prototip, pentru a beneficia de o referință inteligentă a clasei de bază, de exemplu:
function extend(subClass, superClass) {
// ...
subClass._super = superClass;
subClass.prototype._super = superClass.prototype;
}
Funcția "extend" în Backbone JS
Backbone JS oferă funcția extend pentru extinderea claselor librăriei: Model, View, Collection, etc. Dacă se dorește, poate fi împrumutată propriilor scopuri. Puteți vedea mai jos codul funcției extend din Backbone JS versiunea 1.3.3.
var extend = function(protoProps, staticProps) {
var parent = this;
var child;
// The constructor function for the new subclass
// is either defined by you
// (the "constructor" property in your `extend`
// definition), or defaulted
// by us to simply call the parent constructor.
if (protoProps && _.has(protoProps,'constructor')){
child = protoProps.constructor;
} else {
child = function(){ return parent.apply(this,
arguments); };
}
// Add static properties to the constructor
// function, if supplied.
_.extend(child, parent, staticProps);
// Set the prototype chain to inherit from
// `parent`, without calling
// `parent`'s constructor function and add
// the prototype properties.
child.prototype = _.create(parent.prototype,
protoProps);
child.prototype.constructor = child;
// Set a convenience property in case the parent's
// prototype is needed later.
child.__super__ = parent.prototype;
return child;
}
;
Un exemplu de utilizare arată după cum urmează:
var MyModel = Backbone.Model.extend({
constructor: function() {
// your constructor; its usage is optional,
// but when used it requires the super-constructor
// to be called:
Backbone.Model.apply(this, arguments);
},
toJSON: function() {
// the method is overridden, but the original
// method can be called via "__super_"
MyModel.__super__.toJSON.apply(this, arguments);
}
}, {
staticMethod: function() {}
});
Această funcție implementează extensia clasei de bază susținând propriul constructor și propriile câmpuri statice. Ea returnează funcția constructor a clasei. Adevărata moștenire se realizează folosind următoarea linie, similară exemplului din Babel:
child.prototype = _.create(parent.prototype,
protoProps);
Funcția _.create()
este similară cu Object.create()
din ES6, dar implementată de librăria Underscore JS. Cel de-al doilea parametru permite scrierea de proprietăți și metode protoProps
, primite la apelul extend, imediat după prototip.
Moștenirea câmpurilor statice este implementată prin simpla copiere a referințelor (sau a valorilor) din clasa de bază și din obiectul cu câmpuri statice primit ca parametru secundar al funcției extend
:
_.extend(child, parent, staticProps);
Specificarea constructorului este opțională. Se realizează înăuntrul declarației clasei sub forma de funcție "constructor". Când folosim constructori, este obligatoriu să apelăm constructorul clasei de bază (exact ca în alte limbaje), deci programatorii obișnuiesc să folosească funcția initialize()
în schimb.
Cuvântul cheie __super__
este doar o adăugare convenabilă, deoarece apelul spre funcția de bază încă mai are loc prin numele funcției respective și prin transmiterea contextului this
. Fără aceasta, un astfel de apel ar conduce la un ciclu în cazul unui lanț de moștenire multi-nivel. Funcția clasei de bază, al cărei nume este cunoscut de obicei în contextul curent, poate fi apelat direct, deci cuvântul cheie this este doar o scurtătură:
Backbone.Model.prototype.toJSON.apply(this,arguments);
Din punctul de vedere al codului, extensia claselor în Backbone este destul de clară. Nu trebuie creați manual constructorii de clasă și asociați cu clasa părinte pe o linie diferită. Acest confort are prețul lui - dificultatea depanării. În debuggerul browserului, toate instanțele de clase moștenite în acest fel au același nume de constructor declarat în funcția extend
- "copil". Acest dezavantaj poate părea nesemnificativ până când îl întâlnești în practică, atunci când depanezi un lanț de clase. Devine dificil de înțeles ce clasă instanțiază un anumit obiect și ce clasă derivează. Aveți aici un exemplu din consola Google Chrome:
Mult mai convenabil pare acest lanț când folosim moștenirea din Babel:
O altă carență este faptul că proprietatea constructor este un enumerabil, i.e. listată la parcurgerea unei instanțe de clasă într-un ciclu "for-in". Este irelevant, dar Babel are grijă și de aceasta, declarând constructorul cu lista de modificatori necesară. Nu face nicio diferență, dar Babel a ținut cont și de el, listând toți modificatorii necesari pentru declararea constructorului.
Ember JS folosește atât funcția inherits
implementată de Babel, cât și propria implementare de extend
- una foarte complicată și încărcată, care suportă mixinuri, etc.. Pur și simplu nu este suficient spațiu în acest articol pentru a-i prezenta tot codul aici. Și exact acest fapt deja ridică întrebări legate de performanța acestei implementări, atunci când cineva vrea să-l folosească pentru propriile scopuri, în afara frameworkului.
Este de un interes aparte implementarea cuvântului cheie "super" în Ember. El permite apelarea unei funcții de bază fără specificarea concretă a numelui metodei, de exemplu:
var MyClass = MySuperClass.extend({
myMethod: function (x) {
this._super(x);
}
});
Cum funcționează aceasta? Cum știe Ember ce funcție să invoce când apelează versatila funcție _super
, fără să transforme codul? Secretul este în procesarea complexă a claselor și o funcție inteligentă _wrap
, al cărei cod îl afișăm mai jos:
function _wrap(func, superFunc) {
function superWrapper() {
var orig = this._super;
this._super = superFunc; // the magic is here
var ret = func.apply(this, arguments);
this._super = orig;
return ret;
}
// some irrelevant piece of code is omitted here
return superWrapper;
}
Când derivăm o clasă, Ember parcurge toate funcțiile și invocă acest wrapper pentru fiecare. El înlocuiește fiecare funcție originală cu superWrapper
. Fiți atenți la linia marcată prin comentariu. Proprietatea _super
conține acum o referință la funcția părinte corespunzătoare numelui de metodă care este apelată (toată activitatea de determinare a corespondențelor a apărut în timpul creării clasei prin apelarea extend
). Pe linia următoare se apelează funcția originală, din interiorul căreia se poate invoca _super
ca funcție părinte. Proprietatea _super
este atunci resetată înapoi la valoarea ei inițială. Aceasta permite să fie folosită în apeluri profund înlănțuite.
Ideea este fără dubiu, interesantă și poate fi aplicată într-o implementare personalizată de moștenire. Dar există un avertisment important: complexitatea acestei funcții îi afectează performanța. Fiecare metodă a clasei (cel puțin printre cele care au metode părinte omonime), indiferent dacă metoda _super
este folosită sau nu, este împachetată într-o funcție separată. Cu o profunzime mare a lanțului de apeluri ale metodelor aceleiași clase, ne conduce la o explozie a stackului. Aceasta este cu atât mai crucial pentru metodele care sunt apelate regulat într-un ciclu sau la afișarea UI-ului. Deci, putem spune că această implementare este prea împovărătoare și nu justifică avantajul sub forma notației abreviate.
Una dintre cele mai frecvente și periculoase greșeli în practică este crearea unei instanțe a clasei părinte în timpul extinderii ei. Aveți mai jos un astfel de exemplu de cod care ar trebui evitat întotdeauna:
function BasicClass() {
this.x = this.initializeX();
this.runSomeBulkyCode();
}
// ...declaration of BasicClass methods
// in prototype...
function SubClass() {
BasicClass.apply(this, arguments);
this.y = this.initializeY();
}
// the inheritance
SubClass.prototype = new BasicClass();
SubClass.prototype.constructor = SubClass;
// ...declaration of SubClass methods in prototype...
new SubClass(); // instantiating
Acest cod ar trebui să funcționeze. El va permite SubClass
să deriveze proprietăți și metode ale clasei părinte. Dar asociind clasele prin prototype
, s-a creat o instanță a clasei părinte, iar constructorul ei este invocat. Aceasta conduce la alte acțiuni, în special dacă funcția constructor execută multă activitate în timp ce inițializează un obiect (runSomeBulkyCode
). Aceasta ar putea de asemenea să conducă la erori greu de detectat atunci când proprietățile inițializate în constructorul părinte (this.x
) sunt scrise în prototipul tuturor instanțelor clasei SubClass
în locul instanțelor înseși. Mai mult, același constructor BasicClass
este apelat din nou din constructorul sub-clasei.
În cazul în care constructorul părinte necesită anumiți parametri atunci când este apelată, această greșeală este greu de realizat, dar altfel este extrem de probabilă.
În schimb, un obiect gol ar trebui creat de fiecare dată și al cărui prototip este proprietatea prototype
a clasei părinte:
SubClass.prototype = Object.create(
BasicClass.prototype);
Am oferit câteva exemple de implementare de moștenire pseudo-clasică în compilatorul Babel (din ES6 spre ES5) și în frameworkuri cum ar fi Backbone JS și Ember JS. Aveți mai jos un tabel comparativ al tuturor celor trei implementări structurate după criteriul descris mai sus. Performanța a fost evaluată nu în unități absolute, ci în valori relative între ele, în baza unui număr de operațiuni și cicluri în fiecare exemplu. În general, diferența de performanță nu este semnificativă, deoarece moștenirea se aplică o singură dată la nivelul inițial al aplicației și nu se mai repetă.
Toate exemplele de mai sus au propriile argumente pro și contra, dar cel mai practic poate fi considerat implementarea folosind Babel. Așa cum am menționat mai sus, dacă este posibil, ar trebui folosită moștenirea specificată în EcmaScript 6 cu compilarea în ES5. În absența unei asemenea oportunități, se recomandă scrierea unei implementări personalizate a funcției extend, bazată pe exemplul din compilatorul Babel, luând în considerare remarcile de mai sus și feature-urile din alte exemple. Prin urmare, moștenirea poate fi implementată în cel mai flexibil și potrivit mod pentru orice proiect specific.
- utilizarea Babel este perfectă când se combină cu ES6; la scrierea unei implementări personalizate pentru ES5 pornind de la aceasta, câmpurile statice și referința la clasa de bază ar trebui implementată manual.
de Bálint Ákos
de Andrei Oneț
de Raul Boldea
de Ioana Varga