Come sviluppatore JS, questo è ciò che mi tiene sveglio la notte

Pubblicato: 2022-03-11

JavaScript è un linguaggio strano. Sebbene ispirato a Smalltalk, utilizza una sintassi simile al C. Combina aspetti dei paradigmi procedurali, funzionali e di programmazione orientata agli oggetti (OOP). Ha numerosi approcci, spesso ridondanti, per risolvere quasi tutti i problemi di programmazione immaginabili e non è fortemente supponente su quale sia preferito. È digitato in modo debole e dinamico, con un approccio labirintico alla coercizione del tipo che fa inciampare anche gli sviluppatori esperti.

JavaScript ha anche le sue verruche, trappole e caratteristiche discutibili. I nuovi programmatori lottano con alcuni dei suoi concetti più difficili: pensa all'asincronicità, alle chiusure e al sollevamento. I programmatori con esperienza in altre lingue presumono ragionevolmente che cose con nomi e aspetti simili funzioneranno allo stesso modo in JavaScript e spesso si sbagliano. Gli array non sono realmente array; qual è il problema con this , cos'è un prototipo e cosa fa effettivamente il new ?

Il problema con le classi ES6

Il peggior trasgressore di gran lunga è nuovo nell'ultima versione di JavaScript, ECMAScript 6 (ES6): classes . Alcuni dei discorsi in classe sono francamente allarmanti e rivelano un malinteso profondamente radicato su come funziona effettivamente la lingua:

"JavaScript è finalmente un vero linguaggio orientato agli oggetti ora che ha le classi!"

O:

"Le classi ci liberano dal pensare al modello di ereditarietà non funzionante di JavaScript."

O anche:

"Le classi sono un approccio più sicuro e semplice alla creazione di tipi in JavaScript."

Queste affermazioni non mi infastidiscono perché implicano che c'è qualcosa di sbagliato nell'ereditarietà prototipica; mettiamo da parte queste argomentazioni. Queste affermazioni mi infastidiscono perché nessuna di esse è vera e dimostrano le conseguenze dell'approccio "tutto per tutti" di JavaScript alla progettazione del linguaggio: paralizza la comprensione del linguaggio da parte di un programmatore più spesso di quanto non consenta. Prima di andare oltre, illustriamo.

JavaScript Pop Quiz n. 1: qual è la differenza essenziale tra questi blocchi di codice?

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

La risposta qui è che non ce n'è una . Questi fanno effettivamente la stessa cosa, è solo una questione di se sia stata utilizzata la sintassi della classe ES6.

È vero, il secondo esempio è più espressivo. Solo per questo motivo, potresti sostenere che class è una bella aggiunta alla lingua. Sfortunatamente, il problema è un po' più sottile.

JavaScript Pop Quiz n. 2: cosa fa il codice seguente?

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

La risposta corretta è che stampa su console:

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

Se hai risposto in modo errato, non capisci cosa sia effettivamente la class . Non è colpa tua. Proprio come Array , class non è una caratteristica del linguaggio, è oscurantismo sintattico . Cerca di nascondere il modello di ereditarietà prototipo e gli idiomi goffi che ne derivano, e implica che JavaScript stia facendo qualcosa che non è.

Ti è stato detto che class è stata introdotta in JavaScript per rendere gli sviluppatori OOP classici provenienti da linguaggi come Java più a loro agio con il modello di ereditarietà delle classi ES6. Se sei uno di quegli sviluppatori, quell'esempio probabilmente ti ha inorridito. Dovrebbe. Mostra che la parola chiave class di JavaScript non ha nessuna delle garanzie che una classe dovrebbe fornire. Dimostra anche una delle differenze chiave nel modello di ereditarietà del prototipo: i prototipi sono istanze di oggetti , non tipi .

Prototipi vs Classi

La differenza più importante tra l'ereditarietà basata su classi e prototipi è che una classe definisce un tipo che può essere istanziato in fase di esecuzione, mentre un prototipo è esso stesso un'istanza di oggetto.

Un figlio di una classe ES6 è un'altra definizione di tipo che estende il genitore con nuove proprietà e metodi, che a loro volta possono essere istanziati in fase di esecuzione. Un figlio di un prototipo è un'altra istanza dell'oggetto che delega al genitore tutte le proprietà che non sono implementate nel figlio.

Nota a margine: potresti chiederti perché ho menzionato i metodi di classe, ma non i metodi prototipo. Questo perché JavaScript non ha un concetto di metodi. Le funzioni sono di prima classe in JavaScript e possono avere proprietà o essere proprietà di altri oggetti.

Un costruttore di classe crea un'istanza della classe. Un costruttore in JavaScript è solo una semplice vecchia funzione che restituisce un oggetto. L'unica cosa speciale di un costruttore JavaScript è che, quando viene invocato con la parola chiave new , assegna il suo prototipo come prototipo dell'oggetto restituito. Se questo ti sembra un po' confuso, non sei il solo: lo è, ed è una grande parte del motivo per cui i prototipi sono poco conosciuti.

Per dirla tutta, un figlio di un prototipo non è una copia del suo prototipo, né un oggetto con la stessa forma del suo prototipo. Il bambino ha un riferimento vivente al prototipo e qualsiasi proprietà prototipo che non esiste sul bambino è un riferimento unidirezionale a una proprietà con lo stesso nome sul prototipo.

Considera quanto segue:

 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'
Nota: non scriveresti quasi mai codice come questo nella vita reale, è una pratica terribile, ma dimostra in modo succinto il principio.

Nell'esempio precedente, mentre child.foo era undefined , faceva riferimento a parent.foo . Non appena abbiamo definito foo on child , child.foo aveva il valore 'bar' , ma parent.foo mantenuto il suo valore originale. Una volta che delete child.foo , si riferisce di nuovo a parent.foo , il che significa che quando cambiamo il valore del genitore, child.foo si riferisce al nuovo valore.

Diamo un'occhiata a quello che è appena successo (a scopo illustrativo, faremo finta che si tratti di Strings e non di stringhe letterali, la differenza non ha importanza qui):

Percorrere la catena di prototipi per mostrare come vengono gestiti i riferimenti mancanti in JavaScript.

Il modo in cui funziona sotto il cofano, e in particolare le peculiarità di new e this , sono un argomento per un altro giorno, ma Mozilla ha un articolo completo sulla catena di eredità del prototipo di JavaScript se desideri saperne di più.

Il punto chiave è che i prototipi non definiscono un type ; sono esse stesse instances e sono mutevoli in fase di esecuzione, con tutto ciò che implica e comporta.

Ancora con me? Torniamo a sezionare le classi JavaScript.

JavaScript Pop Quiz n. 3: come si implementa la privacy nelle classi?

Il nostro prototipo e le proprietà di classe sopra non sono tanto "incapsulati" quanto "appesi precariamente fuori dalla finestra". Dovremmo risolverlo, ma come?

Nessun esempio di codice qui. La risposta è che non puoi.

JavaScript non ha alcun concetto di privacy, ma ha delle chiusure:

 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!"

Capisci cosa è appena successo? In caso contrario, non capisci le chiusure. Va bene, davvero: non sono così intimidatori come dovrebbero essere, sono super utili e dovresti prenderti del tempo per conoscerli.

JavaScript Pop Quiz n. 4: qual è l'equivalente di quanto sopra utilizzando la parola chiave della class ?

Scusa, questa è un'altra domanda trabocchetto. Puoi fare praticamente la stessa cosa, ma sembra così:

 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!"

Fammi sapere se sembra più facile o più chiaro che in SecretiveProto . Dal mio punto di vista personale, è un po' peggio: interrompe l'uso idiomatico delle dichiarazioni di class in JavaScript e non funziona molto come ti aspetteresti, ad esempio, da Java. Ciò sarà chiarito da quanto segue:

JavaScript Pop Quiz n. 5: cosa fa SecretiveClass::looseLips() ?

Scopriamolo:

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

Beh... è stato imbarazzante.

JavaScript Pop Quiz n. 6: cosa preferiscono gli sviluppatori JavaScript esperti: prototipi o classi?

Hai indovinato, questa è un'altra domanda trabocchetto: gli sviluppatori JavaScript esperti tendono a evitare entrambi quando possono. Ecco un bel modo per fare quanto sopra con JavaScript idiomatico:

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

Non si tratta solo di evitare la bruttezza intrinseca dell'ereditarietà o di imporre l'incapsulamento. Pensa a cos'altro potresti fare con secretFactory e leaker che non potresti fare facilmente con un prototipo o una classe.

Per prima cosa, puoi destrutturarlo perché non devi preoccuparti del contesto di this :

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

È abbastanza carino. Oltre a evitare new sciocchezze, ci consente di utilizzare i this oggetti in modo intercambiabile con i moduli CommonJS ed ES6. Inoltre rende la composizione un po' più semplice:

 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)

I clienti di blackHat non devono preoccuparsi della provenienza exfiltrate e spyFactory non deve scherzare con Function::bind contestuale giocoleria o proprietà profondamente nidificate. Intendiamoci, non dobbiamo preoccuparci molto di this nel semplice codice procedurale sincrono, ma causa tutti i tipi di problemi nel codice asincrono che è meglio evitare.

Con un piccolo pensiero, spyFactory potrebbe essere sviluppato in uno strumento di spionaggio altamente sofisticato in grado di gestire tutti i tipi di obiettivi di infiltrazione, o in altre parole, una facciata.

Ovviamente potresti farlo anche con una classe, o meglio, un assortimento di classi, che ereditano tutte da una abstract class o interface astratta ... tranne per il fatto che JavaScript non ha alcun concetto di abstract o interfacce.

Torniamo all'esempio del greeter per vedere come lo implementeremmo con una fabbrica:

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

Potresti aver notato che queste fabbriche stanno diventando più concise man mano che procediamo, ma non preoccuparti: fanno la stessa cosa. Le ruote di allenamento si stanno staccando, gente!

Questo è già meno standard rispetto al prototipo o alla versione di classe dello stesso codice. In secondo luogo, ottiene l'incapsulamento delle sue proprietà in modo più efficace. Inoltre, in alcuni casi ha una memoria inferiore e un footprint di prestazioni (potrebbe non sembrare a prima vista, ma il compilatore JIT sta lavorando silenziosamente dietro le quinte per ridurre la duplicazione e dedurre i tipi).

Quindi è più sicuro, spesso è più veloce ed è più facile scrivere codice come questo. Perché abbiamo bisogno di nuovo delle lezioni? Oh, certo, riutilizzabilità. Cosa succede se vogliamo varianti di saluto infelici ed entusiaste? Bene, se stiamo usando la classe ClassicalGreeting , probabilmente saltiamo direttamente nel sognare una gerarchia di classi. Sappiamo che dovremo parametrizzare la punteggiatura, quindi faremo un piccolo refactoring e aggiungeremo alcuni figli:

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

È un ottimo approccio, finché qualcuno arriva e chiede una funzione che non si adatta perfettamente alla gerarchia e l'intera faccenda smette di avere senso. Metti uno spillo in quel pensiero mentre proviamo a scrivere la stessa funzionalità con le fabbriche:

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

Non è ovvio che questo codice sia migliore, anche se è un po' più breve. In effetti, potresti sostenere che è più difficile da leggere, e forse questo è un approccio ottuso. Non potremmo semplicemente avere unhappyGreeterFactory e enthusiasticGreeterFactory ?

Poi arriva il tuo cliente e dice: "Ho bisogno di un nuovo saluto che sia infelice e vuole che l'intera stanza lo sappia!"

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

Se avessimo bisogno di usare questo saluto entusiasta e infelice più di una volta, potremmo renderlo più facile per noi stessi:

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

Ci sono approcci a questo stile di composizione che funzionano con prototipi o classi. Ad esempio, potresti ripensare UnhappyGreeting ed EnthusiasticGreeting come decoratori. Ci vorrebbe ancora più standard rispetto all'approccio in stile funzionale usato sopra, ma questo è il prezzo da pagare per la sicurezza e l'incapsulamento delle classi reali .

Il fatto è che, in JavaScript, non ottieni quella sicurezza automatica. I framework JavaScript che enfatizzano l'uso delle class fanno molta "magia" per nascondere questo tipo di problemi e costringere le classi a comportarsi da sole. Dai un'occhiata al codice sorgente ElementMixin di Polymer qualche volta, ti sfido. Sono livelli da arcimago degli arcani JavaScript, e lo intendo senza ironia o sarcasmo.

Naturalmente, possiamo risolvere alcuni dei problemi discussi sopra con Object.freeze o Object.defineProperties con un effetto maggiore o minore. Ma perché imitare il form senza la funzione, ignorando gli strumenti che JavaScript ci fornisce nativamente che potremmo non trovare in linguaggi come Java? Useresti un martello etichettato come "cacciavite" per guidare una vite, quando la tua cassetta degli attrezzi aveva un vero cacciavite seduto proprio accanto ad essa?

Trovare le parti buone

Gli sviluppatori JavaScript spesso enfatizzano le parti buone del linguaggio, sia colloquialmente che in riferimento al libro con lo stesso nome. Cerchiamo di evitare le trappole poste dalle sue scelte di progettazione del linguaggio più discutibili e ci atteniamo alle parti che ci consentono di scrivere codice pulito, leggibile, che riduce al minimo gli errori e riutilizzabile.

Ci sono argomenti ragionevoli su quali parti di JavaScript si qualificano, ma spero di averti convinto che la class non è una di queste. In caso contrario, si spera che tu capisca che l'ereditarietà in JavaScript può essere un pasticcio confuso e che class non lo risolve né ti risparmia la comprensione dei prototipi. Credito extra se hai raccolto i suggerimenti che i modelli di progettazione orientati agli oggetti funzionano bene senza classi o ereditarietà ES6.

Non ti sto dicendo di evitare del tutto la class . A volte è necessaria l'ereditarietà e class fornisce una sintassi più pulita per farlo. In particolare, class X extends Y è molto più piacevole dell'approccio del vecchio prototipo. Oltre a ciò, molti framework front-end popolari ne incoraggiano l'uso e probabilmente dovresti evitare di scrivere codice strano non standard solo in linea di principio. Semplicemente non mi piace dove sta andando.

Nei miei incubi, un'intera generazione di librerie JavaScript viene scritta usando class , con l'aspettativa che si comporterà in modo simile ad altri linguaggi popolari. Vengono scoperte nuove classi di bug (gioco di parole). Sono resuscitati quelli vecchi che avrebbero potuto facilmente essere lasciati nel cimitero di JavaScript malformato se non fossimo caduti con noncuranza nella trappola class . Gli sviluppatori JavaScript esperti sono afflitti da questi mostri, perché ciò che è popolare non è sempre ciò che è buono.

Alla fine ci arrendiamo tutti per la frustrazione e iniziamo a reinventare le ruote in Rust, Go, Haskell o chissà cos'altro, quindi compilando Wasm per il web, e nuovi framework e librerie web proliferano nell'infinito multilingue.

Mi tiene davvero sveglio la notte.