Prototypen: das Objektmodell hinter JavaScript-Klassen
class in ES2015 ist im Kern Syntax-Zucker über Prototypen: Wer die [[Prototype]]-Kette, Object.create und den new-Ablauf versteht, debuggt this-Fehler und geteilte Methoden souveräner.
In jeder JavaScript-Schulung kommt der Moment, in dem jemand class an die Tafel schreibt und erleichtert sagt: „Endlich richtige Objektorientierung.“ Ich lasse das kurz stehen, denn die Freude ist berechtigt – class liest sich sauber. Und dann stelle ich eine unbequeme Frage: Was passiert eigentlich, wenn ich zwei Instanzen erzeuge und mir ihre Methoden anschaue? Sind das zwei Funktionen oder eine? Wo genau liegt speak? Warum verliert this seinen Bezug, sobald ich eine Methode als Callback weiterreiche?
An dieser Stelle wird es still. Und genau da fängt das Verständnis an. Denn class ist in ES2015 kein neues Objektmodell – es ist eine andere Schreibweise für ein Modell, das JavaScript seit jeher trägt: Prototypen. Wer die Prototyp-Kette einmal wirklich gesehen hat, dem erklären sich die klassischen Fehlerbilder von selbst. Dieser Artikel zeigt das Modell von unten nach oben: erst reine Prototypen, dann Konstruktor-Funktionen, dann class als das, was es ist – eine bequeme Fassade davor.
Jedes Objekt hat einen Link nach oben
Das Fundament ist erstaunlich schlicht. Jedes Objekt in JavaScript trägt einen internen Verweis, in der Spezifikation [[Prototype]] genannt. Dieser Verweis zeigt entweder auf ein anderes Objekt – den Prototyp – oder auf null. Wenn du auf eine Property zugreifst, die das Objekt selbst nicht besitzt, schaut die Sprache nicht auf und gibt undefined zurück. Sie folgt zunächst dem [[Prototype]]-Link und sucht dort weiter. Und findet sie dort nichts, geht sie einen Link höher. So entsteht die Prototyp-Kette, und sie endet bei null.
Am klarsten sieht man das ohne jede class und ohne new, nur mit Object.create:
const animal = {
describe() {
return this.name + ' makes ' + this.sound;
}
};
const dog = Object.create(animal);
dog.name = 'Rex';
dog.sound = 'woof';
dog.describe(); // 'Rex makes woof'
Object.getPrototypeOf(dog) === animal; // true
dog.hasOwnProperty('describe'); // false
dog.hasOwnProperty('name'); // true
Schau dir an, was hier geschieht. Das Objekt dog besitzt selbst nur name und sound – das bestätigt hasOwnProperty. Die Methode describe findet es nicht bei sich, sondern erst einen Schritt höher, bei animal. Beim Aufruf zählt aber, wer der Empfänger war: this ist dog, deshalb liest describe Rex und woof. Die Methode liegt am Prototyp, die Daten liegen an der Instanz, und der Lookup verbindet beides. Das ist der ganze Mechanismus – keine Klassen, keine Konstruktoren, nur ein Objekt, das auf ein anderes zeigt.
Object.create macht diesen Link explizit sichtbar: Der erste Parameter wird direkt zum [[Prototype]] des neuen Objekts. Du kannst sogar Object.create(null) schreiben und bekommst ein Objekt ganz ohne Prototyp – ohne toString, ohne hasOwnProperty, ein blankes Wörterbuch. Nützlich, wenn man ein Objekt als reine Key-Value-Ablage will und keine geerbten Eigenschaften im Weg haben möchte.
Die Kette, visualisiert
Wenn dog.describe() aufgerufen wird, läuft der Lookup die Kette hoch. Das folgende Diagramm zeigt die Stationen bis zum Ende bei null:
graph TD A["dog<br/>own: name, sound"] -->|"[[Prototype]]"| B["animal<br/>own: describe"] B -->|"[[Prototype]]"| C["Object.prototype<br/>own: hasOwnProperty, toString"] C -->|"[[Prototype]]"| D["null"]
Jeder Pfeil ist ein [[Prototype]]-Verweis, und der Lookup steigt an ihnen entlang auf. Sucht die Sprache describe, wird sie bei animal fündig. Suchte sie toString, ginge es eine Station weiter bis Object.prototype. Und suchte sie etwas, das es nirgends gibt, käme sie bei null an und lieferte undefined. Diese Kette ist kein Sonderfall für selbstgebaute Objekte – jedes Objekt, jedes Array, jede Funktion hängt an so einer Kette, deren Wurzel Object.prototype ist.
Konstruktor-Funktionen: der Weg vor class
Vor ES2015 baute man wiederverwendbare „Typen“ mit Konstruktor-Funktionen. Der Trick sitzt in zwei Teilen, die man auseinanderhalten muss. Erstens: Jede Funktion besitzt eine Property namens prototype – ein gewöhnliches Objekt, das als Vorlage dient. Zweitens: Der new-Operator verbindet ein frisches Objekt mit genau dieser Vorlage.
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function () {
return this.name + ' makes a sound';
};
const cat = new Animal('Minka');
cat.speak(); // 'Minka makes a sound'
Object.getPrototypeOf(cat) === Animal.prototype; // true
cat.hasOwnProperty('speak'); // false
Was tut new Animal('Minka') konkret? Der Ablauf ist – vereinfacht – dreistufig. Zuerst wird ein neues, leeres Objekt erzeugt, dessen [[Prototype]] auf Animal.prototype gesetzt wird. Dann wird Animal aufgerufen, wobei this auf dieses neue Objekt zeigt – so landet this.name = name als eigene Property an der Instanz. Und schließlich: Gibt die Funktion selbst kein Objekt zurück, wird das neue Objekt das Ergebnis. Deshalb steht speak nicht an der Instanz, sondern an Animal.prototype, und wird von allen Katzen geteilt.
Vererbung war vor class Handarbeit – und genau diese Handarbeit zeigt, was extends später automatisiert:
function Dog(name) {
Animal.call(this, name); // Parent-Konstruktor auf eigenem this
}
Dog.prototype = Object.create(Animal.prototype); // Kette einhängen
Dog.prototype.constructor = Dog; // constructor wieder setzen
Dog.prototype.speak = function () {
return this.name + ' makes woof';
};
const rex = new Dog('Rex');
rex.speak(); // 'Rex makes woof'
rex instanceof Dog; // true
rex instanceof Animal; // true
Drei Zeilen tragen hier die Last. Animal.call(this, name) ruft den Eltern-Konstruktor auf dem eigenen this auf, damit name gesetzt wird. Dog.prototype = Object.create(Animal.prototype) hängt die Prototyp-Kette ein, sodass eine Dog-Instanz erst bei Dog.prototype, dann bei Animal.prototype, dann bei Object.prototype sucht. Und die dritte Zeile repariert etwas, das die zweite kaputtgemacht hat: Beim Überschreiben von Dog.prototype ging die constructor-Rückverweisung verloren, die wir manuell wiederherstellen. Genau solche Details sind es, die man in der Praxis vergisst – und die class einem abnimmt.
Dieselbe Sache als class
Jetzt derselbe Code in ES2015-Syntax, Seite an Seite gedacht. Das Ergebnis ist identisch im Objektmodell – dieselben Prototyp-Objekte, dieselbe Kette, dieselben geteilten Methoden:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return this.name + ' makes a sound';
}
}
class Dog extends Animal {
constructor(name) {
super(name);
}
speak() {
return this.name + ' makes woof';
}
}
const rex = new Dog('Rex');
Object.getPrototypeOf(rex) === Dog.prototype; // true
Object.getPrototypeOf(Dog.prototype) === Animal.prototype; // true
Vergleiche das mit der ES5-Fassung, und die Übersetzung wird direkt sichtbar. Die Methoden speak im Class-Body landen auf Animal.prototype bzw. Dog.prototype – nicht an der Instanz. extends Animal erledigt das Einhängen der Kette, für das wir eben Object.create(Animal.prototype) von Hand geschrieben haben. Und super(name) ersetzt das manuelle Animal.call(this, name). Die letzte Zeile beweist es: rex zeigt auf Dog.prototype, und dieses zeigt auf Animal.prototype. Dieselbe Kette wie vorher, nur ohne den Boilerplate und ohne die vergessene constructor-Zeile.
Ehrlich bleiben muss man trotzdem: class ist Syntax-Zucker, aber nicht ausschließlich. Ein Class-Body läuft immer im Strict Mode. Eine Klasse lässt sich nicht ohne new aufrufen – der Versuch wirft einen Fehler, während eine Konstruktor-Funktion sich auch als normale Funktion missbrauchen ließe. Und class-Deklarationen werden nicht wie function-Deklarationen an den Anfang gezogen und initialisiert; sie liegen in einer temporalen toten Zone und sind vor ihrer Definitionszeile nicht benutzbar. Auch super in Methoden greift auf einen internen Mechanismus ([[HomeObject]]) zurück, den man mit reinem ES5 nicht 1
prototype, __proto__ und Object.create sauber trennen
Die häufigste Verwirrung in meinen Schulungen dreht sich um zwei ähnlich klingende Namen. prototype und __proto__ sind nicht dasselbe, und die Verwechslung produziert Fehler, die man lange anstarrt. Diese Liste sortiert die drei Werkzeuge:
prototypeist eine Property, die nur auf Funktionen existiert. Sie ist die Vorlage, dienewan die Instanzen hängt. Eine gewöhnliche Instanz hat keinprototype–rex.prototypeistundefined.__proto__(bzw. das interne[[Prototype]]) ist der tatsächliche Kettenlink, den jedes Objekt trägt. Der kanonische Zugriff läuft überObject.getPrototypeOf(obj);__proto__selbst ist in ES2015 nur über Annex B standardisiert und gilt als Legacy.Object.create(proto)erzeugt ein Objekt und setzt dessen[[Prototype]]direkt aufproto– ganz ohne Konstruktor-Funktion. Das ist der direkte Weg, einen Kettenlink zu legen.
Der entscheidende Merksatz: Fn.prototype ist die Vorlage auf der Funktion, instance.[[Prototype]] ist der Link auf dem erzeugten Objekt. Beim Aufruf von new Fn() wird das eine zum anderen. Und um es endgültig zu entwirren – die Funktion selbst ist auch ein Objekt und hat ihren eigenen Link:
function Fn() {}
const obj = new Fn();
Object.getPrototypeOf(obj) === Fn.prototype; // true (Instanz-Link)
obj.prototype; // undefined (Instanz hat keine)
Object.getPrototypeOf(Fn) === Function.prototype; // true (Fn ist selbst Objekt)
Achte auf die letzte Zeile. Fn.__proto__ ist Function.prototype, nicht Fn.prototype. Wer diese beiden verwechselt, sucht Methoden an der falschen Stelle. Im Zweifel hilft immer Object.getPrototypeOf, um die Kette Schritt für Schritt abzugehen, statt zu raten.
Der teuerste Fallstrick: Methoden im Konstruktor
Jetzt zu dem Punkt, der in echten Codebasen Speicher kostet und in Reviews oft durchrutscht. Es macht einen handfesten Unterschied, ob eine Methode am Prototyp liegt oder im Konstruktor erzeugt wird:
function WidgetBad() {
this.render = function () { /* ... */ }; // pro Instanz eine neue Funktion
}
function WidgetGood() {}
WidgetGood.prototype.render = function () { /* ... */ }; // geteilt
const a1 = new WidgetBad();
const a2 = new WidgetBad();
a1.render === a2.render; // false -> zwei getrennte Funktionen
const b1 = new WidgetGood();
const b2 = new WidgetGood();
b1.render === b2.render; // true -> eine Funktion, geteilt
Der Beweis steht in den Vergleichen. Bei WidgetBad bekommt jede Instanz ihre eigene render-Funktion – bei tausend Widgets sind das tausend identische Funktionen im Speicher. Bei WidgetGood liegt render einmal an WidgetGood.prototype, und alle Instanzen teilen sie sich. Genau das tut class automatisch: Methoden im Class-Body landen am Prototyp, nicht an der Instanz. Man muss sich für den guten Fall in class also gar nicht anstrengen – man muss ihn nur nicht durch eine Zuweisung im constructor zerstören.
Der zweite häufige Fallstrick ist der Verlust von this. Eine Prototyp-Methode kennt ihren Empfänger nur, wenn sie als obj.method() aufgerufen wird. Reißt man sie los, ist der Bezug weg:
const cat = new Animal('Minka');
const fn = cat.speak;
fn(); // this ist nicht mehr cat -> Fehler oder falsches Ergebnis
const bound = cat.speak.bind(cat);
bound(); // 'Minka makes a sound' -> this wieder gebunden
Das ist kein Bug, sondern eine direkte Folge des Modells: this wird beim Aufruf bestimmt, nicht bei der Definition. Löst man cat.speak in eine freie Variable, fehlt der Empfänger. Die Werkzeuge dagegen sind 2017 etabliert: .bind(cat) erzeugt eine fest gebundene Variante, oder man wickelt den Aufruf in eine Arrow-Function, die this aus dem umgebenden Bereich übernimmt. Ein Ausblick am Rande: An Klassen-Feldern mit Arrow-Funktionen wird gearbeitet, die genau dieses Binden eleganter machen sollen – das ist Ende 2017 aber noch ein TC39-Proposal und kein Standard. Wer es heute produktiv braucht, greift zu bind oder Arrow-Wrapper.
Warum sich das auszahlt
Man kann JavaScript jahrelang mit class schreiben, ohne je „Prototyp“ zu sagen. Bis zu dem Tag, an dem this in einem Event-Handler undefined ist, an dem eine geerbte Methode nicht gefunden wird, oder an dem ein Profiler tausendfach dieselbe Funktion im Heap zeigt. Dann trennt sich, wer das Modell kennt, von wem, der rät. Die drei wiederkehrenden Symptome – verlorenes this, Methode an der falschen Stelle, Speicher pro Instanz – sind allesamt keine Marotten der Sprache, sondern logische Konsequenzen einer Kette aus Objekten, die aufeinander zeigen.
class ist ein guter Freund: lesbar, strikt, und es nimmt einem den fehleranfälligen Boilerplate der ES5-Vererbung ab. Aber es ist eine Fassade, und hinter der Fassade steht immer noch Object.create, Fn.prototype und ein new, das beides verbindet. Wenn du das nächste Mal extends schreibst, denk kurz an die drei Zeilen, die es ersetzt – die eingehängte Kette, den super-Aufruf, den constructor-Rückverweis. Dann ist class nicht mehr Magie, sondern eine Abkürzung, deren Langform du liest wie einen offenen Brief.
Dieses prototypische Denken ist derselbe rote Faden, der sich durch die JavaScript-Patterns zieht, die geblieben sind – Objekte, die auf Objekte zeigen, Funktionen als Bausteine. Und es gehört zur Grundausstattung, die ich in HTML5, CSS3 und ECMAScript als Baseline voraussetze, bevor es an Frameworks geht. Wer das Fundament versteht, baut darauf ruhiger.
Weiterführende Quellen
- MDN, Inheritance and the prototype chain: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_prototype_chain
- MDN, Object.create: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create
- MDN, Object.getPrototypeOf: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getPrototypeOf
- MDN, Object.setPrototypeOf: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/setPrototypeOf
- MDN, Object.prototype.__proto__: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/proto
- MDN, Classes: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes
- MDN, new operator: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/new
- ECMAScript 2015 (ES6) Spec: https://www.ecma-international.org/ecma-262/6.0/
- ECMAScript 2017 (ES8) Spec: https://www.ecma-international.org/ecma-262/8.0/
Kommentare