Als JS-Entwickler hält mich das nachts wach

Veröffentlicht: 2022-03-11

JavaScript ist eine seltsame Sprache. Obwohl von Smalltalk inspiriert, verwendet es eine C-ähnliche Syntax. Es kombiniert Aspekte von prozeduralen, funktionalen und objektorientierten Programmierparadigmen (OOP). Es hat zahlreiche, oft redundante Ansätze zur Lösung fast aller vorstellbaren Programmierprobleme und ist nicht sehr eigensinnig darüber, welche bevorzugt werden. Es ist schwach und dynamisch typisiert, mit einem labyrinthartigen Ansatz zur Typisierung, der selbst erfahrene Entwickler zum Stolpern bringt.

JavaScript hat auch seine Warzen, Fallen und fragwürdigen Funktionen. Neue Programmierer haben mit einigen seiner schwierigeren Konzepte zu kämpfen – denken Sie an Asynchronität, Schließungen und Heben. Programmierer mit Erfahrung in anderen Sprachen gehen vernünftigerweise davon aus, dass Dinge mit ähnlichen Namen und Aussehen in JavaScript genauso funktionieren und liegen oft falsch. Arrays sind nicht wirklich Arrays; was hat es damit auf sich, this ist ein prototyp und was macht new eigentlich?

Das Problem mit ES6-Klassen

Der mit Abstand schlimmste Übeltäter ist neu in der neuesten Release-Version von JavaScript, ECMAScript 6 (ES6): classes . Manches Gerede im Unterricht ist ehrlich gesagt alarmierend und offenbart ein tief verwurzeltes Missverständnis darüber, wie die Sprache tatsächlich funktioniert:

„JavaScript ist jetzt endlich eine echte objektorientierte Sprache, da es Klassen hat!“

Oder:

„Klassen befreien uns davon, über das kaputte Vererbungsmodell von JavaScript nachzudenken.“

Oder auch:

„Klassen sind ein sichererer und einfacherer Ansatz zum Erstellen von Typen in JavaScript.“

Diese Aussagen stören mich nicht, weil sie implizieren, dass mit der prototypischen Vererbung etwas nicht stimmt; Lassen wir diese Argumente beiseite. Diese Aussagen beunruhigen mich, weil keine davon wahr ist, und sie demonstrieren die Konsequenzen von JavaScripts „Alles-für-alle“-Ansatz für das Sprachdesign: Er lähmt das Verständnis eines Programmierers für die Sprache öfter als es ermöglicht. Bevor ich weiter gehe, wollen wir es veranschaulichen.

JavaScript-Pop-Quiz Nr. 1: Was ist der wesentliche Unterschied zwischen diesen Codeblöcken?

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

Die Antwort hier ist , dass es keinen gibt . Diese machen praktisch dasselbe, es ist nur eine Frage, ob die ES6-Klassensyntax verwendet wurde.

Stimmt, das zweite Beispiel ist aussagekräftiger. Allein aus diesem Grund könnten Sie argumentieren, dass der class eine nette Ergänzung der Sprache ist. Leider ist das Problem etwas subtiler.

JavaScript-Pop-Quiz Nr. 2: Was macht der folgende Code?

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

Die richtige Antwort ist, dass es auf der Konsole gedruckt wird:

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

Wenn Sie falsch geantwortet haben, verstehen Sie nicht, was class eigentlich ist. Das ist nicht deine Schuld. Ähnlich wie Array ist class kein Sprachfeature, sondern syntaktischer Obskurantismus . Es versucht, das prototypische Vererbungsmodell und die damit verbundenen schwerfälligen Redewendungen zu verbergen, und impliziert, dass JavaScript etwas tut, was es nicht tut.

Vielleicht wurde Ihnen gesagt, dass die class in JavaScript eingeführt wurde, um klassischen OOP-Entwicklern, die aus Sprachen wie Java kommen, den Umgang mit dem Klassenvererbungsmodell von ES6 angenehmer zu machen. Wenn Sie einer dieser Entwickler sind , hat Sie dieses Beispiel wahrscheinlich entsetzt. Es sollte. Es zeigt, dass das class -Schlüsselwort von JavaScript keine der Garantien enthält, die eine Klasse bieten soll. Es demonstriert auch einen der Hauptunterschiede im Prototyp-Vererbungsmodell: Prototypen sind Objektinstanzen , keine Typen .

Prototypen vs. Klassen

Der wichtigste Unterschied zwischen klassen- und prototypbasierter Vererbung besteht darin, dass eine Klasse einen Typ definiert, der zur Laufzeit instanziiert werden kann, während ein Prototyp selbst eine Objektinstanz ist.

Ein Kind einer ES6-Klasse ist eine weitere Typdefinition , die das Elternteil um neue Eigenschaften und Methoden erweitert, die wiederum zur Laufzeit instanziiert werden können. Ein Kind eines Prototyps ist eine weitere Objektinstanz , die alle Eigenschaften, die nicht auf dem Kind implementiert sind, an das Elternteil delegiert.

Randnotiz: Sie fragen sich vielleicht, warum ich Klassenmethoden erwähnt habe, aber keine Prototypmethoden. Das liegt daran, dass JavaScript kein Methodenkonzept hat. Funktionen sind in JavaScript erstklassig und können Eigenschaften haben oder Eigenschaften anderer Objekte sein.

Ein Klassenkonstruktor erstellt eine Instanz der Klasse. Ein Konstruktor in JavaScript ist nur eine einfache alte Funktion, die ein Objekt zurückgibt. Das einzige Besondere an einem JavaScript-Konstruktor ist, dass er, wenn er mit dem Schlüsselwort new aufgerufen wird, seinen Prototyp als Prototyp des zurückgegebenen Objekts zuweist. Wenn Ihnen das ein wenig verwirrend vorkommt, sind Sie damit nicht allein – das ist es, und es ist ein großer Teil des Grundes, warum Prototypen kaum verstanden werden.

Um es ganz genau zu sagen: Ein Kind eines Prototyps ist weder eine Kopie seines Prototyps noch ein Objekt mit der gleichen Form wie sein Prototyp. Das untergeordnete Element hat einen lebendigen Verweis auf den Prototyp, und jede Prototyp-Eigenschaft, die auf dem untergeordneten Element nicht vorhanden ist, ist eine unidirektionale Referenz auf eine gleichnamige Eigenschaft des Prototyps.

Folgendes berücksichtigen:

 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'
Hinweis: Im wirklichen Leben würden Sie fast nie Code wie diesen schreiben – es ist eine schreckliche Übung –, aber es demonstriert das Prinzip kurz und bündig.

Im vorherigen Beispiel war child.foo zwar undefined , es verwies jedoch auf parent.foo . Sobald wir foo auf child definiert haben, hatte child.foo den Wert 'bar' , aber parent.foo behielt seinen ursprünglichen Wert. Sobald wir delete child.foo , verweist es wieder auf parent.foo , was bedeutet, dass, wenn wir den Wert des übergeordneten Elements ändern, child.foo auf den neuen Wert verweist.

Schauen wir uns an, was gerade passiert ist (zur besseren Veranschaulichung werden wir so tun, als wären dies Strings und keine String-Literale, der Unterschied spielt hier keine Rolle):

Gehen Sie durch die Prototypkette, um zu zeigen, wie fehlende Referenzen in JavaScript behandelt werden.

Die Art und Weise, wie dies unter der Haube funktioniert, und insbesondere die Besonderheiten von new und this , sind ein Thema für einen anderen Tag, aber Mozilla hat einen ausführlichen Artikel über die Prototyp-Vererbungskette von JavaScript, wenn Sie mehr lesen möchten.

Das Wichtigste zum Mitnehmen ist, dass Prototypen keinen type definieren; Sie sind selbst instances und zur Laufzeit veränderbar, mit allem, was dies impliziert und mit sich bringt.

Immer noch bei mir? Kommen wir zurück zum Analysieren von JavaScript-Klassen.

JavaScript-Pop-Quiz Nr. 3: Wie implementiert man Datenschutz im Unterricht?

Unsere obigen Prototyp- und Klasseneigenschaften sind weniger „eingekapselt“ als vielmehr „gefährlich aus dem Fenster hängend“. Das sollten wir beheben, aber wie?

Keine Codebeispiele hier. Die Antwort ist, dass Sie es nicht können.

JavaScript hat kein Datenschutzkonzept, aber es hat Schließungen:

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

Verstehst du, was gerade passiert ist? Wenn nicht, verstehst du Schließungen nicht. Das ist in Ordnung, wirklich – sie sind nicht so einschüchternd, wie sie dargestellt werden, sie sind super nützlich, und Sie sollten sich etwas Zeit nehmen, um mehr über sie zu erfahren.

JavaScript-Pop-Quiz Nr. 4: Was ist das Äquivalent zum obigen Schlüsselwort „Using the class “?

Entschuldigung, das ist eine weitere Fangfrage. Sie können im Grunde dasselbe tun, aber es sieht so aus:

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

Lassen Sie mich wissen, ob das einfacher oder klarer aussieht als in SecretiveProto . Meiner persönlichen Meinung nach ist es etwas schlimmer – es unterbricht die idiomatische Verwendung von class in JavaScript und funktioniert nicht so, wie Sie es beispielsweise von Java erwarten würden. Dies wird durch Folgendes verdeutlicht:

JavaScript-Pop-Quiz Nr. 5: Was macht SecretiveClass::looseLips() ?

Lass es uns herausfinden:

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

Nun… das war umständlich.

JavaScript-Pop-Quiz Nr. 6: Was bevorzugen erfahrene JavaScript-Entwickler – Prototypen oder Klassen?

Sie haben es erraten, das ist eine weitere Fangfrage – erfahrene JavaScript-Entwickler neigen dazu, beides zu vermeiden, wenn sie können. Hier ist eine nette Möglichkeit, das Obige mit idiomatischem JavaScript zu tun:

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

Dabei geht es nicht nur darum, die inhärente Hässlichkeit der Vererbung zu vermeiden oder die Kapselung zu erzwingen. Denken Sie darüber nach, was Sie sonst noch mit secretFactory und leaker machen könnten, was Sie mit einem Prototyp oder einer Klasse nicht so einfach machen könnten.

Zum einen können Sie es destrukturieren, weil Sie sich nicht this den Kontext kümmern müssen:

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

Das ist ziemlich nett. Abgesehen davon, dass wir new und this Dummheiten vermeiden, können wir unsere Objekte austauschbar mit CommonJS- und ES6-Modulen verwenden. Es macht auch die Komposition ein wenig einfacher:

 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)

Kunden von blackHat müssen sich keine Gedanken darüber machen, woher das exfiltrate kommt, und spyFactory muss sich nicht mit Function::bind -Kontextjonglieren oder tief verschachtelten Eigenschaften herumschlagen. Wohlgemerkt, wir müssen uns this in einfachem synchronem prozeduralem Code keine großen Gedanken machen, aber es verursacht alle möglichen Probleme in asynchronem Code, die besser vermieden werden sollten.

Mit ein wenig Überlegung könnte spyFactory zu einem hochentwickelten Spionagetool entwickelt werden, das mit allen Arten von Infiltrationszielen umgehen kann – oder mit anderen Worten, zu einer Fassade.

Natürlich könnten Sie das auch mit einer Klasse tun, oder besser gesagt mit einer Auswahl von Klassen, die alle von einer abstract class oder interface erben … außer dass JavaScript kein Konzept von Abstrakten oder Schnittstellen hat.

Kehren wir zum Greeter-Beispiel zurück, um zu sehen, wie wir es mit einer Factory implementieren würden:

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

Sie haben vielleicht bemerkt, dass diese Fabriken im Laufe der Zeit knapper werden, aber keine Sorge – sie tun dasselbe. Die Stützräder kommen ab, Leute!

Das ist bereits weniger Boilerplate als entweder der Prototyp oder die Klassenversion desselben Codes. Zweitens erreicht es eine effektivere Einkapselung seiner Eigenschaften. Außerdem hat es in einigen Fällen einen geringeren Speicher- und Leistungsbedarf (es mag auf den ersten Blick nicht so erscheinen, aber der JIT-Compiler arbeitet leise hinter den Kulissen, um Duplikate zu reduzieren und Typen abzuleiten).

Es ist also sicherer, oft schneller und einfacher, Code wie diesen zu schreiben. Warum brauchen wir wieder Unterricht? Oh, natürlich Wiederverwendbarkeit. Was passiert, wenn wir unglückliche und begeisterte Greeter-Varianten wollen? Nun, wenn wir die ClassicalGreeting -Klasse verwenden, stürzen wir uns wahrscheinlich direkt in die Entwicklung einer Klassenhierarchie. Wir wissen, dass wir die Interpunktion parametrisieren müssen, also werden wir ein wenig umgestalten und einige Kinder hinzufügen:

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

Es ist ein guter Ansatz, bis jemand kommt und nach einem Feature fragt, das nicht sauber in die Hierarchie passt, und das Ganze keinen Sinn mehr macht. Setzen Sie diesen Gedanken fest, während wir versuchen, die gleiche Funktionalität mit Fabriken zu schreiben:

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

Es ist nicht offensichtlich, dass dieser Code besser ist, auch wenn er etwas kürzer ist. Tatsächlich könnte man argumentieren, dass es schwieriger zu lesen ist, und vielleicht ist dies ein stumpfer Ansatz. Könnten wir nicht einfach eine unhappyGreeterFactory und eine enthusiasticGreeterFactory GreeterFactory haben?

Dann kommt Ihr Kunde und sagt: „Ich brauche einen neuen Greeter, der unzufrieden ist und möchte, dass der ganze Raum davon erfährt!“

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

Wenn wir diesen enthusiastisch unglücklichen Greeter mehr als einmal verwenden müssten, könnten wir es uns leichter machen:

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

Es gibt Ansätze für diesen Kompositionsstil, die mit Prototypen oder Klassen arbeiten. Beispielsweise könnten Sie UnhappyGreeting und EnthusiasticGreeting als Decorators überdenken. Es würde immer noch mehr Boilerplate erfordern als der oben verwendete funktionale Ansatz, aber das ist der Preis, den Sie für die Sicherheit und Kapselung echter Klassen zahlen.

Die Sache ist, dass Sie in JavaScript diese automatische Sicherheit nicht erhalten. JavaScript-Frameworks, die die Verwendung von class betonen, tun viel „Magie“, um diese Art von Problemen zu überspielen und Klassen zu zwingen, sich selbst zu verhalten. Werfen Sie doch mal einen Blick auf Polymers ElementMixin -Quellcode, ich fordere Sie auf. Es sind Arch-Wizard-Ebenen von JavaScript-Arkanen, und ich meine das ohne Ironie oder Sarkasmus.

Natürlich können wir einige der oben diskutierten Probleme mit Object.freeze oder Object.defineProperties mehr oder weniger effektiv beheben. Aber warum die Form ohne die Funktion imitieren und dabei die Tools ignorieren, die uns JavaScript von Haus aus bietet und die wir in Sprachen wie Java möglicherweise nicht finden? Würden Sie einen Hammer mit der Aufschrift „Schraubendreher“ verwenden, um eine Schraube zu drehen, wenn Ihr Werkzeugkasten einen echten Schraubendreher direkt daneben hätte?

Finden der guten Teile

JavaScript-Entwickler betonen oft die guten Seiten der Sprache, sowohl umgangssprachlich als auch in Bezug auf das gleichnamige Buch. Wir versuchen, die Fallen zu vermeiden, die durch die fragwürdigeren Sprachdesign-Entscheidungen entstehen, und halten uns an die Teile, die es uns ermöglichen, sauberen, lesbaren, fehlerminimierenden und wiederverwendbaren Code zu schreiben.

Es gibt vernünftige Argumente darüber, welche Teile von JavaScript in Frage kommen, aber ich hoffe, ich habe Sie davon überzeugt, dass class nicht dazu gehört. Andernfalls verstehen Sie hoffentlich, dass die Vererbung in JavaScript ein verwirrendes Durcheinander sein kann und diese class weder das Problem behebt noch Ihnen das Verständnis von Prototypen erspart. Zusätzliche Anerkennung, wenn Sie die Hinweise aufgegriffen haben, dass objektorientierte Entwurfsmuster ohne Klassen oder ES6-Vererbung gut funktionieren.

Ich sage dir nicht, dass du den class komplett meiden sollst. Manchmal benötigen Sie Vererbung, und die class bietet dafür eine sauberere Syntax. Insbesondere class X extends Y ist viel schöner als der alte Prototyp-Ansatz. Abgesehen davon ermutigen viele beliebte Front-End-Frameworks zu seiner Verwendung, und Sie sollten es wahrscheinlich vermeiden, aus Prinzip seltsamen Nicht-Standard-Code zu schreiben. Mir gefällt einfach nicht, wohin das führt.

In meinen Albträumen wird eine ganze Generation von JavaScript-Bibliotheken mit class geschrieben, mit der Erwartung, dass sie sich ähnlich wie andere populäre Sprachen verhalten wird. Ganz neue Klassen von Fehlern (Wortspiel beabsichtigt) werden entdeckt. Alte werden wiederbelebt, die leicht auf dem Friedhof des fehlerhaften JavaScripts hätten liegen bleiben können, wenn wir nicht leichtsinnig in die class getappt wären. Erfahrene JavaScript-Entwickler werden von diesen Monstern geplagt, denn was beliebt ist, ist nicht immer gut.

Irgendwann geben wir alle frustriert auf und fangen an, Räder in Rust, Go, Haskell oder wer weiß was sonst noch neu zu erfinden und dann zu Wasm für das Web zu kompilieren, und neue Web-Frameworks und Bibliotheken vermehren sich in die mehrsprachige Unendlichkeit.

Es hält mich nachts wirklich wach.