În calitate de dezvoltator JS, acesta este ceea ce mă ține treaz noaptea

Publicat: 2022-03-11

JavaScript este o limbă ciudată. Deși este inspirat de Smalltalk, folosește o sintaxă asemănătoare C. Combină aspecte ale paradigmelor de programare procedurală, funcțională și orientată pe obiecte (OOP). Are numeroase abordări, adesea redundante, pentru a rezolva aproape orice problemă de programare imaginabilă și nu are o părere puternică despre care sunt preferate. Este scris slab și dinamic, cu o abordare de tip labirint a constrângerii de tip care împiedică chiar și dezvoltatorii experimentați.

JavaScript are, de asemenea, negi, capcane și caracteristici discutabile. Noii programatori se luptă cu unele dintre conceptele sale mai dificile - gândiți-vă la asincronicitate, închideri și ridicare. Programatorii cu experiență în alte limbi presupun în mod rezonabil că lucrurile cu nume și înfățișări similare vor funcționa la fel în JavaScript și greșesc adesea. Matricele nu sunt cu adevărat matrice; care este treaba cu this , ce este un prototip și ce face de fapt new ?

Problema cu clasele ES6

Cel mai grav infractor este de departe nou în ultima versiune JavaScript, ECMAScript 6 (ES6): clase . O parte din discuțiile din jurul cursurilor sunt sincer alarmante și dezvăluie o neînțelegere adânc înrădăcinată a modului în care funcționează de fapt limbajul:

„JavaScript este în sfârșit un adevărat limbaj orientat pe obiecte acum că are clase!”

Sau:

„Clasele ne eliberează de a ne gândi la modelul de moștenire deteriorat al JavaScript.”

Sau chiar:

„Cursele sunt o abordare mai sigură și mai ușoară pentru a crea tipuri în JavaScript.”

Aceste afirmații nu mă deranjează pentru că sugerează că este ceva în neregulă cu moștenirea prototipului; să lăsăm deoparte acele argumente. Aceste afirmații mă deranjează pentru că niciuna dintre ele nu este adevărată și demonstrează consecințele abordării JavaScript „totul pentru toată lumea” pentru proiectarea limbajului: paralizează înțelegerea limbajului de către programator mai des decât permite. Înainte de a merge mai departe, hai să ilustrăm.

Test JavaScript Pop #1: Care este diferența esențială dintre aceste blocuri de cod?

 function PrototypicalGreeting(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } PrototypicalGreeting.prototype.greet = function() { return `${this.greeting}, ${this.name}!` } const greetProto = new PrototypicalGreeting("Hey", "folks") console.log(greetProto.greet())
 class ClassicalGreeting { constructor(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } greet() { return `${this.greeting}, ${this.name}!` } } const classyGreeting = new ClassicalGreeting("Hey", "folks") console.log(classyGreeting.greet())

Răspunsul aici este că nu există unul . Acestea fac în mod efectiv același lucru, este doar o întrebare dacă a fost folosită sintaxa clasei ES6.

Adevărat, al doilea exemplu este mai expresiv. Numai din acest motiv, ați putea argumenta că class este un plus bun la limbă. Din păcate, problema este puțin mai subtilă.

Test JavaScript Pop #2: Ce face următorul cod?

 function Proto() { this.name = 'Proto' return this; } Proto.prototype.getName = function() { return this.name } class MyClass extends Proto { constructor() { super() this.name = 'MyClass' } } const instance = new MyClass() console.log(instance.getName()) Proto.prototype.getName = function() { return 'Overridden in Proto' } console.log(instance.getName()) MyClass.prototype.getName = function() { return 'Overridden in MyClass' } console.log(instance.getName()) instance.getName = function() { return 'Overridden in instance' } console.log(instance.getName())

Răspunsul corect este că se imprimă pe consolă:

 > MyClass > Overridden in Proto > Overridden in MyClass > Overridden in instance

Dacă ai răspuns greșit, nu înțelegi ce este de fapt class . Asta nu este vina ta. La fel ca Array , class nu este o caracteristică a limbajului, este obscurantism sintactic . Încearcă să ascundă modelul prototip de moștenire și idiomurile stângace care vin cu el și implică faptul că JavaScript face ceva ce nu este.

S-ar putea să ți s-a spus că class a fost introdusă în JavaScript pentru a face dezvoltatorii OOP clasici care provin din limbaje precum Java să fie mai confortabili cu modelul de moștenire a clasei ES6. Dacă ești unul dintre acești dezvoltatori, probabil că acel exemplu te-a îngrozit. Ar trebui. Arată că cuvântul cheie de class JavaScript nu vine cu niciuna dintre garanțiile pe care o clasă este menită să le ofere. De asemenea, demonstrează una dintre diferențele cheie în modelul de moștenire prototip: prototipurile sunt instanțe de obiect , nu tipuri .

Prototipuri vs. Clase

Cea mai importantă diferență între moștenirea bazată pe clasă și prototip este că o clasă definește un tip care poate fi instanțiat în timpul execuției, în timp ce un prototip este el însuși o instanță de obiect.

Un copil al unei clase ES6 este o altă definiție a tipului care extinde părintele cu noi proprietăți și metode, care la rândul lor pot fi instanțiate în timpul execuției. Un copil al unui prototip este o altă instanță de obiect care deleagă părintelui orice proprietăți care nu sunt implementate pe copil.

Notă laterală: S-ar putea să vă întrebați de ce am menționat metodele de clasă, dar nu metodele prototip. Asta pentru că JavaScript nu are un concept de metode. Funcțiile sunt de primă clasă în JavaScript și pot avea proprietăți sau pot fi proprietăți ale altor obiecte.

Un constructor de clasă creează o instanță a clasei. Un constructor în JavaScript este doar o funcție veche simplă care returnează un obiect. Singurul lucru special despre un constructor JavaScript este că, atunci când este invocat cu new cuvânt cheie, își atribuie prototipul ca prototip al obiectului returnat. Dacă ți se pare puțin confuz, nu ești singur – este și este o mare parte din motivul pentru care prototipurile sunt prost înțelese.

Pentru a pune o idee foarte bună, un copil al unui prototip nu este o copie a prototipului său și nici un obiect cu aceeași formă ca prototipul său. Copilul are o referință vie la prototip și orice proprietate a prototipului care nu există pe copil este o referință unidirecțională la o proprietate cu același nume de pe prototip.

Luați în considerare următoarele:

 let parent = { foo: 'foo' } let child = { } Object.setPrototypeOf(child, parent) console.log(child.foo) // 'foo' child.foo = 'bar' console.log(child.foo) // 'bar' console.log(parent.foo) // 'foo' delete child.foo console.log(child.foo) // 'foo' parent.foo = 'baz' console.log(child.foo) // 'baz'
Notă: Aproape că nu ai scrie un astfel de cod în viața reală – este o practică groaznică – dar demonstrează succint principiul.

În exemplul anterior, în timp ce child.foo a fost undefined , a făcut referire la parent.foo . De îndată ce am definit foo pe child , child.foo avea valoarea 'bar' , dar parent.foo și-a păstrat valoarea inițială. Odată ce delete child.foo , se referă din nou la parent.foo , ceea ce înseamnă că atunci când schimbăm valoarea părintelui, child.foo se referă la noua valoare.

Să ne uităm la ceea ce tocmai s-a întâmplat (în scopul unei ilustrații mai clare, vom pretinde că acestea sunt Strings și nu literale, diferența nu contează aici):

Trecerea prin lanțul de prototipuri pentru a arăta cum sunt tratate referințele lipsă în JavaScript.

Modul în care funcționează sub capotă, și mai ales particularitățile new și this , sunt un subiect pentru altă zi, dar Mozilla are un articol amănunțit despre lanțul de moștenire prototip al JavaScript, dacă doriți să citiți mai multe.

Principala concluzie este că prototipurile nu definesc un type ; ele sunt ele însele instances și sunt modificabile în timpul execuției, cu tot ceea ce implică și implică.

Încă cu mine? Să revenim la disecția claselor JavaScript.

JavaScript Pop Quiz #3: Cum implementați confidențialitatea în cursuri?

Prototipul și proprietățile noastre de clasă de mai sus nu sunt atât de „încapsulate”, cât „atârnat precar pe fereastră”. Ar trebui să reparăm asta, dar cum?

Nu există exemple de cod aici. Răspunsul este că nu poți.

JavaScript nu are niciun concept de confidențialitate, dar are închideri:

 function SecretiveProto() { const secret = "The Class is a lie!" this.spillTheBeans = function() { console.log(secret) } } const blabbermouth = new SecretiveProto() try { console.log(blabbermouth.secret) } catch(e) { // TypeError: SecretiveClass.secret is not defined } blabbermouth.spillTheBeans() // "The Class is a lie!"

Înțelegi ce tocmai s-a întâmplat? Dacă nu, nu înțelegeți închiderile. Este în regulă, într-adevăr – nu sunt atât de intimidanți pe cât s-au crezut că sunt, sunt super utile și ar trebui să ai ceva timp pentru a afla despre ele.

JavaScript Pop Quiz #4: Care este echivalentul cu cel de mai sus folosind cuvântul cheie al class ?

Ne pare rău, aceasta este o altă întrebare truc. Puteți face în esență același lucru, dar arată astfel:

 class SecretiveClass { constructor() { const secret = "I am a lie!" this.spillTheBeans = function() { console.log(secret) } } looseLips() { console.log(secret) } } const liar = new SecretiveClass() try { console.log(liar.secret) } catch(e) { console.log(e) // TypeError: SecretiveClass.secret is not defined } liar.spillTheBeans() // "I am a lie!"

Spune-mi dacă pare mai ușor sau mai clar decât în SecretiveProto . Din punctul meu de vedere personal, este oarecum mai rău – încalcă utilizarea idiomatică a declarațiilor de class în JavaScript și nu funcționează așa cum te-ai aștepta, de exemplu, din Java. Acest lucru va fi clarificat prin următoarele:

JavaScript Pop Quiz #5: Ce face SecretiveClass::looseLips() ?

Să aflăm:

 try { liar.looseLips() } catch(e) { // ReferenceError: secret is not defined }

Ei bine... a fost ciudat.

JavaScript Pop Quiz #6: Ce preferă dezvoltatorii JavaScript experimentați: prototipuri sau clase?

Ai ghicit, asta este o altă întrebare trucă — dezvoltatorii JavaScript experimentați tind să le evite pe ambele atunci când pot. Iată o modalitate bună de a face cele de mai sus cu JavaScript idiomatic:

 function secretFactory() { const secret = "Favor composition over inheritance, `new` is considered harmful, and the end is near!" const spillTheBeans = () => console.log(secret) return { spillTheBeans } } const leaker = secretFactory() leaker.spillTheBeans()

Nu este vorba doar de a evita urâțenia inerentă a moștenirii sau de a impune încapsularea. Gândiți-vă ce altceva ați putea face cu secretFactory și leaker , ceea ce nu ați putea face cu ușurință cu un prototip sau o clasă.

În primul rând, îl puteți destructura pentru că nu trebuie să vă faceți griji cu privire la contextul this :

 const { spillTheBeans } = secretFactory() spillTheBeans() // Favor composition over inheritance, (...)

E destul de frumos. Pe lângă faptul că evităm new și this prostii, ne permite să ne folosim obiectele în mod interschimbabil cu modulele CommonJS și ES6. De asemenea, ușurează puțin compoziția:

 function spyFactory(infiltrationTarget) { return { exfiltrate: infiltrationTarget.spillTheBeans } } const blackHat = spyFactory(leaker) blackHat.exfiltrate() // Favor composition over inheritance, (...) console.log(blackHat.infiltrationTarget) // undefined (looks like we got away with it)

Clienții de la blackHat nu trebuie să-și facă griji de unde provin exfiltrate , iar spyFactory nu trebuie să se încurce cu Function::bind jonglarea contextului sau cu proprietățile profund imbricate. Rețineți, nu trebuie să ne îngrijorăm prea mult despre this în codul procedural sincron simplu, dar provoacă tot felul de probleme în codul asincron, care ar fi mai bine evitate.

Cu puțină gândire, spyFactory ar putea fi dezvoltată într-un instrument de spionaj extrem de sofisticat, care ar putea gestiona toate tipurile de ținte de infiltrare – sau cu alte cuvinte, o fațadă.

Bineînțeles că ai putea face asta și cu o clasă, sau mai degrabă, cu un sortiment de clase, toate moștenind dintr-o abstract class sau interface abstractă ... cu excepția faptului că JavaScript nu are niciun concept de rezumate sau interfețe.

Să revenim la exemplul de întâmpinare pentru a vedea cum l-am implementa cu o fabrică:

 function greeterFactory(greeting = "Hello", name = "World") { return { greet: () => `${greeting}, ${name}!` } } console.log(greeterFactory("Hey", "folks").greet()) // Hey, folks!

S-ar putea să fi observat că aceste fabrici devin din ce în ce mai concise pe măsură ce mergem, dar nu vă faceți griji, ele fac același lucru. Se desprind roțile de antrenament, oameni buni!

Acesta este deja mai puțin standard decât prototipul sau versiunea de clasă a aceluiași cod. În al doilea rând, realizează încapsularea proprietăților sale mai eficient. De asemenea, are o memorie mai mică și o amprentă de performanță în unele cazuri (s-ar putea să nu pară așa la prima vedere, dar compilatorul JIT lucrează în liniște în spatele scenei pentru a reduce duplicarea și a deduce tipuri).

Deci, este mai sigur, este adesea mai rapid și este mai ușor să scrieți cod ca acesta. De ce avem nevoie din nou de cursuri? Oh, desigur, reutilizare. Ce se întâmplă dacă vrem variante de salutare nefericite și entuziaste? Ei bine, dacă folosim clasa ClassicalGreeting , probabil că vom sări direct în a crea o ierarhie de clasă. Știm că va trebui să parametrizăm punctuația, așa că vom face puțină refactorizare și vom adăuga câțiva copii:

 // Greeting class class ClassicalGreeting { constructor(greeting = "Hello", name = "World", punctuation = "!") { this.greeting = greeting this.name = name this.punctuation = punctuation } greet() { return `${this.greeting}, ${this.name}${this.punctuation}` } } // An unhappy greeting class UnhappyGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, " :(") } } const classyUnhappyGreeting = new UnhappyGreeting("Hello", "everyone") console.log(classyUnhappyGreeting.greet()) // Hello, everyone :( // An enthusiastic greeting class EnthusiasticGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, "!!") } greet() { return super.greet().toUpperCase() } } const greetingWithEnthusiasm = new EnthusiasticGreeting() console.log(greetingWithEnthusiasm.greet()) // HELLO, WORLD!!

Este o abordare bună, până când vine cineva și cere o caracteristică care nu se potrivește clar în ierarhie și totul încetează să mai aibă sens. Pune un ac în acest gând în timp ce încercăm să scriem aceeași funcționalitate cu fabrici:

 const greeterFactory = (greeting = "Hello", name = "World", punctuation = "!") => ({ greet: () => `${greeting}, ${name}${punctuation}` }) // Makes a greeter unhappy const unhappy = (greeter) => (greeting, name) => greeter(greeting, name, ":(") console.log(unhappy(greeterFactory)("Hello", "everyone").greet()) // Hello, everyone :( // Makes a greeter enthusiastic const enthusiastic = (greeter) => (greeting, name) => ({ greet: () => greeter(greeting, name, "!!").greet().toUpperCase() }) console.log(enthusiastic(greeterFactory)().greet()) // HELLO, WORLD!!

Nu este evident că acest cod este mai bun, deși este puțin mai scurt. De fapt, ai putea argumenta că este mai greu de citit și poate că aceasta este o abordare obtuză. N-am putea avea doar o unhappyGreeterFactory și o GreeterFactory enthusiasticGreeterFactory ?

Apoi, clientul tău vine și spune: „Am nevoie de un nou greeter care este nemulțumit și vrea ca întreaga cameră să știe despre asta!”

 console.log(enthusiastic(unhappy(greeterFactory))().greet()) // HELLO, WORLD :(

Dacă ar fi nevoie să folosim acest mesaj de întâmpinare cu entuziasm nefericit de mai multe ori, ne-am putea face mai ușor:

 const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory)) console.log(aggressiveGreeterFactory("You're late", "Jim").greet())

Există abordări ale acestui stil de compoziție care funcționează cu prototipuri sau clase. De exemplu, ați putea regândi UnhappyGreeting și EnthusiasticGreeting Greeting ca decoratori. Tot ar fi nevoie de mai mult decât abordarea în stil funcțional folosită mai sus, dar acesta este prețul pe care îl plătiți pentru siguranța și încapsularea claselor reale .

Chestia este că, în JavaScript, nu obțineți acea siguranță automată. Cadrele JavaScript care pun accentul pe utilizarea class fac multă „magie” pentru a trata aceste tipuri de probleme și forțează clasele să se comporte singure. Aruncați o privire la codul sursă ElementMixin de la Polymer cândva, vă îndrăznesc. Sunt niveluri de arhivrăjitor de arcane JavaScript și mă refer la asta fără ironie sau sarcasm.

Desigur, putem rezolva unele dintre problemele discutate mai sus cu Object.freeze sau Object.defineProperties cu un efect mai mare sau mai mic. Dar de ce să imitem forma fără funcție, ignorând în același timp instrumentele pe care JavaScript ne oferă în mod nativ pe care s-ar putea să nu le găsim în limbaje precum Java? Ați folosi un ciocan etichetat „șurubelniță” pentru a acționa un șurub, când cutia dvs. de instrumente avea ca șurubelniță adevărată așezată chiar lângă ea?

Găsirea părților bune

Dezvoltatorii JavaScript subliniază adesea părțile bune ale limbajului, atât colocvial, cât și cu referire la cartea cu același nume. Încercăm să evităm capcanele întinse de alegerile sale de proiectare a limbajului mai îndoielnică și să rămânem la părțile care ne permit să scriem cod curat, lizibil, care minimizează erorile și reutilizabil.

Există argumente rezonabile cu privire la care părți din JavaScript se califică, dar sper că v-am convins că class nu este una dintre ele. În caz contrar, sperăm că înțelegi că moștenirea în JavaScript poate fi o mizerie confuză și acea class nici nu o rezolvă și nici nu te scutește de a înțelege prototipurile. Credit suplimentar dacă ați înțeles indicii că modelele de design orientate pe obiecte funcționează bine fără clase sau moștenire ES6.

Nu-ți spun să eviți complet class . Uneori aveți nevoie de moștenire, iar class oferă o sintaxă mai curată pentru a face asta. În special, class X extends Y este mult mai plăcută decât vechea abordare prototip. Pe lângă asta, multe framework-uri front-end populare încurajează utilizarea acestuia și probabil că ar trebui să evitați să scrieți cod ciudat non-standard doar din principiu. Pur și simplu nu-mi place unde se duce asta.

În coșmarurile mele, o întreagă generație de biblioteci JavaScript sunt scrise folosind class , cu așteptarea că se va comporta similar cu alte limbaje populare. Sunt descoperite clase cu totul noi de bug-uri. Cei vechi au înviat care ar fi putut fi lăsați cu ușurință în Cimitirul JavaScriptului Malformat dacă nu am fi căzut neglijent în capcana class . Dezvoltatorii JavaScript experimentați sunt afectați de acești monștri, deoarece ceea ce este popular nu este întotdeauna ceea ce este bun.

În cele din urmă, renunțăm cu toții în frustrări și începem să reinventăm roțile în Rust, Go, Haskell sau cine știe ce altceva, apoi compilăm în Wasm pentru web, iar noi cadre web și biblioteci proliferează în infinitul multilingv.

Chiar mă ține treaz noaptea.