Startseite / Artikel

Die wahren Grundlagen von JavaScript: Prototypen, Delegation und Funktionen

Warum Klassen im Vergleich zu Prototypen als syntaktischer „Zucker“ gelten, wie Delegation sich von klassischem Erbe unterscheidet, wo dies zu Problemen führt und welche Vorteile der funktionale Stil in JavaScript bietet.

Verlag: REACTAPP.TOP11 Min. Lesezeit2593 Wörter

Quellen: The True Blueprints to JavaScript

Vergessen Sie starre, objektorientierte Blueprints, die aus anderen Sprachen importiert wurden. Die eigentliche Architektur von JavaScript basiert seit Langem auf funktionaler Programmierung und Prototyp-Delegation. Gegen dieses Konzept anzukämpfen, lässt die Sprache unbrauchbar erscheinen; sich darauf zu verlassen, macht sie vielseitig.

Etwa ein Jahrzehnt lang sah JavaScript aus wie eine gewöhnliche OOP-Sprache. Man gibt class, extends, constructor und super() ein – bei genauerem Hinsehen ähnelt es Java oder C#. Diese Ähnlichkeit ist absichtlich erzeugt. Im Hintergrund tut der Laufzeitumgebung etwas völlig anderes. In dem Moment, in dem jemand die „Verkleidung“ vergisst – besonders jemand, der zuerst klassische OOP gelernt hat – tauchen „unmögliche“ Fehler auf. JavaScript war nie klassisch. Nur wenn man erkennt, was unter der Syntax liegt, kann man dem, was die Oberfläche verspricht, vertrauen.

Das Schlüsselwort class ist ein Kostüm

Vor ECMAScript 2015 (ES6) hatte JavaScript kein Schlüsselwort class. Entwickler schrieben weiterhin objektorientiert aussehenden Code mit Konstruktorfunktionen und manueller Zuweisung von Prototypen. Als class eingeführt wurde, galt es als Syntaxzucker: eine freundlichere Schreibweise desselben Mechanismus, kein neues Erbeitsmodell, das daraufgepackt wurde.

TC39, der Standardsausschuss, war klar: Eine JavaScript-class ist kein in Instanzen eingestempeltes藍圖 wie eine Java-Klasse. Es handelt sich dabei um eine Funktion, die einen anderen „Hut“ trägt.

Eine Handvoll Zeilen macht das sichtbar:

class Dog {
  constructor(name) {
    this.name = name;
  }
  bark() {
    return `${this.name} says woof`;
  }
};
// Then just ask JS what Dog actually is.
console.log(typeof Dog);    // "function"

Die Methode bark() befindet sich nicht in einer Tabelle abstrakter Klassen – sie liegt bei Dog.prototype, genau dort, wo eine Methode vor 2015 platziert wäre. Konzeptionell wird jede Klasse weiterhin zu einer Funktion zusammen mit einem Prototypobjekt mit angehängten Methoden kompiliert. Das Schlüsselwort hat sich geändert; die Mechanismen selbst sind unverändert geblieben.

Das ist wichtig, weil klassische Erwartungen Garantien beinhalten, die JavaScript nie angeboten hat: eine festgelegte Hierarchie, die zur Kompilierzeit bestimmt wird, private Felder standardmäßig sowie eine Methodenaufruflogik, die unabhängig von der Art des Aufrufs gleich funktioniert. Unter der class-Syntax bietet JavaScript hingegen eine lebendige, veränderliche Suchkette zur Laufzeit, die diesen Garantien *ähnelt*, bis ein Fall auftritt, in dem diese Ähnlichkeit endet.

Das ist kein Designfehler, für den man sich entschuldigen muss. Es handelt sich um eine Verkleidung, die ihre Aufgabe erfüllt: einem anderen Lebewesen zu ermöglichen, sich in einem Raum zu bewegen, der für etwas anderes konstruiert wurde. Probleme entstehen, wenn die Verkleidung als Anatomie angesehen wird. Das eigentliche Vorbild, das man lernen sollte, ist Delegation, nicht die klassische Vererbung.

Was Delegation wirklich bedeutet

Prototypbasierte Delegation und klassische Vererbung klingen ähnlich, verhalten sich aber unterschiedlich. In einer klassischen Programmiersprache ist die Unterklassestruktur bereits bei der Definition oder während der Kompilierung festgelegt – eine Instanz von Dog ist strukturell eine Kopie dessen, was Animal definiert.

In JavaScript erhält ein Objekt keine Kopie. Es erhält vielmehr eine lebende Referenz auf ein anderes Objekt (sein Prototyp). Wenn eine Eigenschaft fehlt, versagt der Engine nicht sofort; sie folgt stattdessen der Referenz und fragt dort nach. Dieser Vorgang wird Delegation genannt:

  • Nicht: „Enthält eine Fotokopie des Verhaltens des Elternobjekts“
  • Sondern: „Fragt das Elternobjekt, wenn die Antwort lokal fehlt“

Der Abfragesalgorithmus ist präzise: Zuerst werden die eigenen Eigenschaften des Objekts überprüft; falls diese fehlen, wird der interne [[Prototype]]-Verweis verfolgt (historisch als __proto__ bereitgestellt und korrekt über Object.getPrototypeOf() abgerufen); dieser Vorgang wird wiederholt, bis die Eigenschaft gefunden wird oder die Kette bei Object.prototype endet, dessen Prototyp null ist. Es handelt sich dabei um eine Laufzeitkette des Prinzips „frage das nächste Objekt“, nicht um eine einmal festgefrorene statische Hierarchie.

Weil der Prototyp-Verweis dynamisch und veränderbar ist – es handelt sich dabei nicht um eine eingebettete Kopie – kann man einer gemeinsamen Prototypenstruktur nach dem Entstehen von tausend Objekten eine Methode hinzufügen, wodurch alle Objekte diese sofort erhalten. Sie besitzen einen Zeiger, keine Fotokopie. Man kann sogar den Prototyp eines Objekts zur Laufzeit mit Object.setPrototypeOf() neu zuweisen, wodurch spätere Abfragen die neue Kette folgen.

Klassische Sprachen können das ohne Neukompilierung nicht tun. Das Modell von JavaScript ist keine schwächere Form der klassischen Vererbung; es handelt sich um eine andere Mechanik, die flexibel genug ist, um bei Bedarf klassische Vererbung zu imitieren.

Object.create() zeigt Delegation ohne zusätzliche Konstruktionen:

const animal = {
  speak() {
    return '...';
  },
};
const dog = Object.create(animal);
// creates dog w/animal set directly as its pro

Kein Konstruktor, kein new, keine versteckten Schlüsselwörter, die „für einen selbst“ handeln. dog.speak() funktioniert, weil die Abfrage an animal delegiert wird, wenn dog keinen eigenen speak-Methoden hat. Das ist die Mechanik, die class und extends in einer freundlicheren Syntax verpacken.

Wo die Illusion zerbricht

Teams, die aus C# oder Java kommen, entdecken den Fehler oft bei der Verkabelung von Ereignissen oder in Timer-Callback-Funktionen, nicht bei Aufrufen von Methoden auf dem normalen Pfad. Die zugrunde liegende Konzeption lehrt, dass „Methoden zu Instanzen gehören“, weshalb das Verlieren von this wie ein Sprachfehler erscheint und nicht als Regel am Aufrufort. Frühzeitige Dokumentation dieser Regel am Aufrufort verhindert wochenlanges, unreflektiertes Verwenden von .bind(this).

Auch der Druck durch privaten Zustand zeigt sich in Bibliotheken, die „sichere“ Objekte exportieren, aber dennoch veränderbare Strukturen bereitstellen. Wenn ein Nutzer in diese Strukturen eingreifen und Felder ändern kann, waren die ursprünglich als intern geltenden Invarianten lediglich konventionell. Wird eine Invarianz aufrechterhalten müssen, sollten Factory-Closures oder #-Felder bevorzugt werden; bei absichtlich geteilteren Daten sind einfache Objekte besser geeignet.

Einfache Tutorials bewahren das Kostüm unversehrt. Nahtstellen entstehen, wenn this mit etwas Komplexerem konfrontiert wird als dem direkten Aufruf einer Methode auf einem sichtbaren Objekt. In JavaScript war this niemals auf die „Instanz“ auf die klassische Weise gebunden. Die Mechanik am Aufrufort bestimmt den Empfänger – nicht der lexikalische Ort der Funktion und auch nicht der Klassenblock, in dem die Methode geschrieben wurde.

Der Fehler, den fast jeder macht: Eine Methode als Callback übergeben:

setTimeout(instance.greet, 1000);
// or, wiring up an event listener directly
const el = document.getElementById('btn');
el.addEventListener('click', instance.handleClick);
// NOTE: The `this` inside that function is no longer the instance
// you extracted it from.

In einer klassischen Sprache wie C# trägt eine gebundene Methode ihre Instanz mit sich. In JavaScript ist eine Methode eine gewöhnliche Funktion. Wenn sie vom Aufrufort, der this bereitstellt, getrennt wird, verliert sie die Instanz. setTimeout und DOM-Listener sind häufige Beispiele für solche Trennungen. Lösungen umfassen .bind, Pfeilfunktionen oder eine Neugestaltung, sodass der Aufrufort weiterhin den richtigen Empfänger bereitstellt – Dinge, über die man in klassischen Sprachen bei gewöhnlichen Methoden nicht nachdenken muss.

Ein leisereres Knacken ist der wirklich private Zustand. Unterstrichene Konventionen (this._internal) sind soziale Vereinbarungen, keine Durchsetzungsvorgaben. Moderne #private-Felder schließen diesen Unterschied teilweise, kamen aber zu spät und existieren weiterhin neben einer Kultur öffentlicher, veränderbarer Eigenschaften. Wenn man von vornherein eine Java-ähnliche Inkapselung erwartet, führt das zu Überraschungen, wenn externer Code das, was als „intern“ galt, verändert.

Klasse von Prototypen aus, per Hand neu aufbauen

Der schnellste Weg, das Modell zu internalisieren, besteht darin, dasselbe Verhalten ohne class mithilfe gewöhnlicher Konstruktorfunktionen sowie Prototypobjekten neu aufzubauen – im Stil vor 2015, den der Engine noch unter der Verkleidung verwendet. Die MDN-Dokumentation zu grundlegenden Skriptfunktionen sowie fortgeschrittenen Objektprototypen bleibt eine nützliche Referenz beim Üben dieser Muster.

// function (using the `this` keyword)
function Dog(name) {
  this.name = name;
};

// prototype (doing the same thing)
Dog.prototype.bark = function() {
  return `${this.name} says woof`;
};

// assigning a variable to a new instance
const rex = new Dog('Rex');
console.log(rex.bark());
// Output: "Rex says woof"

Durch das Betrachten von new Dog('Rex') wird ein Schlüsselbegriff aufgeklärt, den viele Menschen jahrelang verwenden, ohne die Schritte zu kennen: Ein neues, leeres Objekt erstellen; [[Prototype]] dieses Objekts auf Dog.prototype setzen; Dog aufrufen, wobei this auf das neue Objekt verknüpft wird; dieses Objekt zurückgeben, es sei denn, der Konstruktor gibt explizit ein anderes Objekt zurück. Es gibt kein verstecktes Klassenregister und keinen kompilierten Entwurf – nur Objekterstellung, eine Prototypenverknüpfung sowie einen Funktionsaufruf mit einem bestimmten this.

Vererbung ohne extends sieht so aus – das genaue Format, zu dem extends kompiliert wird:

// an extension of the Dog
function Puppy(name) {
  Dog.call(this, name);
};

Puppy.prototype = Object.create(Dog.prototype);
Puppy.prototype.constructor = Puppy;

// This line here doesn't work, because arrow functions can't use `this`.
// Puppy.prototype.bark = () => { return `${this.name} whines instead of barks`; };

// Use a standard function like this instead:
Puppy.prototype.bark = function() {
  return `${this.name} whines instead of barks`;
};

const buddy = new Puppy('Buddy');
console.log(buddy.bark());
// Output: "Buddy whines instead of barks"

Object.create(Dog.prototype) erledigt das, was extends Dog automatisiert: Es verbindet Puppy.prototype’s [[Prototype]] mit Dog.prototype, sodass fehlende Eigenschaften nach oben delegiert werden. Dog.call(this, name) erledigt die gleiche Arbeit wie super(name): Es ruft den Initialisierer des Elterntyps auf derselben Instanz auf. Sobald diese Verbindung mehrmals manuell hergestellt wurde, ist class nicht mehr „Zauber“ – es handelt sich dabei lediglich um eine vereinfachte Schreibweise eines bekannten Mechanismus.

Funktionen waren schon immer die natürliche Sprache

Das Denken in Funktionen klärt auch das Moduldesign. Das Exportieren einiger fokussierter Funktionen aus einer Datei ist oft klarer als das Exportieren einer Klasse, deren Methoden die gemeinsamen Instanzfelder meist ignorieren. Wenn eine „Klasse“ keinen sinnvollen Zustand pro Instanz hat, handelt es sich um einen Namespace in OOP-Kleidung. Ein Funktionsmodul sagt dasselbe mit weniger Formalitäten und weniger Überraschungen bezüglich von this.

Closures passen zu diesem Stil: Eine Fabrik kann Konfigurationen festhalten und ein kleines API-Objekt zurückgeben, dessen Methoden keine klassische Hierarchie benötigen. Das Ergebnis erscheint am Aufrufort (z. B. api.save()) weiterhin objektorientiert, während die Implementierung bei besserer Eignung lexikalisch statt prototypbasiert bleibt.

Sobald das klassische Konzept als optional erscheint, stellt sich eine dringendere Frage:

Wie sieht das eigentliche native Fundament von JavaScript im Inneren aus?

Die Antwort lautet Funktionen als Erstklassewerte – sie können übergeben, gespeichert und zurückgegeben werden – nicht Klassenhierarchien. In diesem Sinne gab es Funktionen bereits seit der ersten Veröffentlichung von JavaScript im 1995. Klassen kamen etwa zwanzig Jahre später. Der größte Teil der Geschichte dieser Sprache hatte wenig mit OOP zu tun. Wenn man nach der Eigenschaft sucht, auf der JavaScript aufgebaut ist, dann war es niemals die Objekthierarchie. Es waren die Funktionen.

Zu den häufig genannten historischen Einflüssen gehören:

  • Scheme – eine Sprache aus der Lisp-Familie, die Erstklassenfunktionen betont
  • Self – eine auf Prototypen basierende Sprache, die das Delegationsmodell prägte
  • Java – eine Syntax, die später das Konzept der class-Struktur kommerziell attraktiv machte

Klammern ergeben sich natürlicherweise aus erstklassigen Funktionen: Eine Funktion behält Zugriff auf die lexikalische Umgebung, in der sie erstellt wurde. Durch diesen Zugriff können „Fabriken“ Zustände ohne klassische private Felder verbergen, und Hooks sowie Middleware können den Kontext pro Anfrage ohne Unterklassen beibehalten.

Was Funktionsprogrammierung Ihnen tatsächlich bringt

In der Praxis übernehmen Teams diese Gewohnheiten schrittweise. Behalten Sie Eingaben und Ausgaben an den Rändern – Netzwerk, Festplatte, DOM – und halten Sie den Mittelteil des Programms als Transformationen einfacher Daten. Allein diese Trennung beseitigt viele Konkurrenzsituationen, die auftreten, wenn UI-Ereignisse denselben Objektgraphen von mehreren Handlern verändern.

Hilfsmittel für Immutabilität (Spreads, structuredClone oder Bibliotheken) sind nur Taktiken; die Strategie besteht darin, illegale Zwischenzustände schwerer ausdrücken zu lassen. Diskriminierte Unionen sowie reine Aktualisierungsfunktionen ergänzen sich gut mit den oben genannten Prototypenkonzepten: Objekte bleiben nützlich, doch die Aktualisierungswege werden zu expliziten Werten anstelle stummer Feldschreibvorgänge.

Auch das Debuggen verändert sich. Mit reinen Aktualisierungen kann man Eingaben und Ausgaben einer Transformation protokollieren und Fehler offline nachstellen. Bei verschachtelten Instanzmutationen benötigt man oft eine vollständige Anwendungssession, um genau jenen „Feldsalat“ wiederherzustellen, der einen Fehler ausgelöst hat.

Wenn man den akademischen Rahmen sowie die funktionale Programmierung beiseitelässt, bedeutet das in der Praxis, Programme hauptsächlich aus reinen Funktionen zu erstellen: derselbe Eingang, dasselbe Ausgangsergebnis, keine Nebeneffekte außerhalb des Rückgabewerts. Programme kombinieren diese Funktionen zu Pipelines von Transformationen, anstatt im Laufe der Zeit Instanzfelder über Methoden zu verändern.

Der erste Vorteil ist die Testbarkeit. Eine reine Funktion benötigt weder Setup- noch Teardown-Prozeduren, weder gemachte this-Objekte noch sorgfältig vorbereitete Instanzen. Man gibt einen Eingang ein und überprüft das Ausgangsergebnis. Ein Großteil dessen, was OOP-Tests instabil macht, sind die notwendigen Schritte, um ein Objekt in den richtigen internen Zustand zu versetzen, bevor das gewünschte Verhalten getestet wird. Reine Funktionen umgehen diese Schritte, da es keinen versteckten Instanzzustand gibt, den man zuerst vorbereiten müsste.

Der zweite Vorteil beantwortet direkt den Abschnitt this: Ein gemeinsamer, veränderlicher Zustand ist die Ursache für die meisten klassischen Probleme bei der Bindung. Eine Methode, die gemeinsame Instanzfelder liest und schreibt, kann von allem beeinflusst werden, was die Instanz berührt hat – in einer Reihenfolge, die der Aufrufort nicht eindeutig macht. Eine reine Funktion verfügt über keinen gemeinsamen, veränderlichen Zustand, wodurch eine Umordnung der Aufrufer die Bedeutung nicht heimlich verändert.

Der dritte Vorteil ist die Kompositionsfähigkeit. Kleine, reine Hilfsfunktionen lassen sich gut verschachteln: Jeder Rückgabewert versorgt den nächsten Schritt und bildet so Pipelines, die nach unten gelesen werden können und Schritte für Tests zulassen. Verwickelte Klassenhierarchien erschweren diese Rekombination, da sich das Verhalten über Erbe-Verbindungen und veränderliche Felder verteilt, anstatt in unabhängig testbaren Einheiten zu existieren.

Nichts davon verbietet Objekte oder Methoden. Es verändert lediglich den Blickwinkel, wenn sie nützlich sind: als Grenzen und Namensräume, nicht als der Standardort, an dem jedes Verhaltensmerkmal existieren muss.

Letzter Punkt

Dieses Modell an Anfänger zu vermitteln verkürzt die Zeit zwischen dem Kopieren eines Klassentutorials und dem Verständnis dafür, warum ein Click-Handler seinen Empfänger verloren hat. Zudem mildert es religiöse Konflikte ab: Klassische Muster bleiben als syntaktischer „Zuckerguss“ verfügbar, solange sie Domängrenzen klar machen, während tägliche Array-Pipelines, Reducer und Hooks die ursprüngliche Struktur der Sprache sichtbar halten.

Beim Überprüfen von Code sollte man prüfen, ob eine neue Klasse eine echte Identität mit Lebensdauer und Invarianten abbildet oder lediglich Funktionen gruppiert. Ist es Letzteres, kommuniziert in der Regel ein Modul mit Funktionen – oder eine einzige Factory – besser. Ist es Ersteres, sollte man die Klasse beibehalten, aber bedenken, dass sie weiterhin auf Prototypen sowie dem this-Wert am Aufrufort basiert, und Tests schreiben, die getrennte Callbacks ausführen, falls solche Aufruforte in der Produktion vorhanden sind.

In der alltäglichen JavaScript-Programmierung ist nichts davon theoretisch. Array-Methoden wie map, filter und reduce sind funktionale Werkzeuge und gehören zu den am häufigsten genutzten APIs der Sprache. Reacts Umstieg von Klassenkomponenten auf Funktionskomponenten und Hooks – beginnend mit React 16.8 und fortgesetzt in späteren Major-Versionen – war ein bewusster Schritt hin zu einer Behandlung der Komponente als reine Funktion von Props und State, was einfacher zu verstehen ist als eine Klasse, die mit Lebenszyklusmethoden und veränderlichen Feldern umgehen muss.

Redux-Reducer folgen einem Reinheitskontrakt, der im offiziellen Grundlagen-Tutorial zu State, Actions und Reducern erläutert wird: Identische Eingaben für State und Actions müssen zum selben nächsten State führen, ohne den vorherigen State vor Ort zu verändern – genau deshalb ist das Debuggen mit Zeitreisen möglich. Siehe den Redux-Grundlagenleitfaden für die kanonische Formulierung. Viele Teams bevorzugen außerdem leichtere Stores wie Zustand für den Anwendungsstate; dieselben Reinheitsprinzipien gelten weiterhin dort, wo Updates vorhersehbar bleiben müssen.

Stream- und Ereignisverarbeitungspipelines – Bibliotheken, die kleine Transformationen auf fortlaufenden Ereignissequenzen anwenden – sind funktionale Programmierung, die auf einem der chaotischsten Probleme im Frontend angewandt wird: asynchrone, kontinuierliche Benutzereingaben.

Fazit: JavaScript war niemals klassisch. OOP-Muster können an bestimmten Stellen weiterhin hilfreich sein, doch die ursprünglichen Grundprinzipien waren stets funktional. Zwei Jahrzehnte lang trug die Sprache ein Kostüm, das sie anders erscheinen ließ – so überzeugend, dass eine ganze Generation durch dieses Kostüm lernte, einschließlich der dazugehörigen Fehler. Im Grunde blieb sie jedoch eine Sprache von Funktionen und Delegation – eher einem Scheme in einer Java-Jacke ähnlich als dem Java selbst – was bedeutet, dass der funktionale Stil in JavaScript niemals eine fremde Einführung war. Es handelte sich dabei stets um den ursprünglichen Akzent der Sprache.

Anders ausgedrückt: Lernen Sie die Konventionen, damit die Zusammenarbeit reibungslos verläuft, und lernen Sie die Anatomie des Codes, damit Produktionsfehler verständlich werden. Teams, die nur die class-Syntax auswendig lernen, entdecken ständig erneut abgetrennte Callbacks sowie überraschende Mutationen des Prototyps. Teams, die außerdem Object.create, Konstruktorfunktionen und reine Aktualisierungen anwenden, verbringen weniger Zeit damit, mit dem Laufzeitumfeld zu streiten, und mehr Zeit damit, Domänendaten zu gestalten.

Mit dieser Grundlage wird class zu einem optionalen Konstrukt statt zur Definition der Sprache – und das Debuggen beginnt an den Aufrufstellen sowie Prototypverknüpfungen statt an Annahmen, die aus klassischen Laufzeitumgebungen stammen.

Bewerter, die frühzeitig nach dieser Unterscheidung fragen, verhindern versehentliche „Gott-Objekte“, die als Domänenmodelle getarnt sind.