Geschrieben von

Einführung in Klassen in JavaScript

WebDev

Einführung in Klassen

In JavaScript wird eine Klasse für die Erstellung von Objekten genutzt. Man kann Klassen als ein Blueprint oder eine Vorlage für die Objekt-Erstellung sehen. Bei der Definition einer Klasse werden vereinfacht ausgedrückt zwei Elemente gesetzt:

  • Eigenschaften: Mit Eigenschaften definiert man, was ein Objekt konkret als Merkmale besitzen soll. Diese Eigenschaften werden als “Instance Properties” genannt.
  • Methoden: Damit wird definiert, was die Objekte tun sollen. Diese Methoden werden auch als “Instance Methods” genannt.

Nehmen wir als klassisches Beispiel eine Form (stell dir vor wir möchten Form-Objekte wie Quadrat, Rechteck, etc. definieren), dann wären Höhe und Breite Beispiele für Eigenschaften und die Berechnung der Fläche ein Beispiel für eine Methode. Oft nutzen dabei die Methoden die definierten Eigenschaften, um ihre Ziele zu erreichen. Um eine Klasse in JavaScript zu erstellen, wird das class-Keyword genutzt. Die Namen werden typischerweise in Pascal Case geschrieben:

class Form {
 // Logik der Klasse
}

Jede Klasse hat auch einen Konstruktur. Beim Konstruktor handelt es sich um eine Methode, die beim Erstellen eines Objekt mittels der Klasse genau einmal durchlaufen wird. Dieser wird mittels dem constructor-Keyword erstellt:

class Form {
 constructor () {
  // Logik des Konstruktors
 }
}

Um den Konstruktor in Aktion zu sehen, definieren wir nun innerhalb des Konstruktors eine einfache console.log-Angabe und erstellen ein einfaches Objekt mittels der Klasse:

class Form {
 constructor () {
  console.log("Die Form wurde erstellt.");
 }
}

let myForm = new Form();

Die Erstellung eines Objekts mittels der Klasse erfolgt über das new-Keyword, gefolgt vom Klassennamen. Da der Konstruktor genau einmal während der Erstellung des Objekts aufgerufen wird, sehen wir nun auch unseren Text in der Konsole.

Die Form wurde erstellt.

Kommen wir zu den Klassen-Eigenschaften aka Instance Properties zurück, dann werden diese üblicherweise innerhalb des Konstruktors definiert. Das geschieht über das this-Keyword:

class Form {
 constructor () {
  this.width = 10;
  this.height = 5;
  this.name = "Rechteck";
 }
}

let myForm = new Form();

Das this-Keyword referenziert dabei auf das aktuelle Objekt. Wenn wir also nun das erstellte Objekt näher betrachten, sehen wir, dass die Eigenschaften im Objekt gesetzt wurden:

Es gibt jedoch ein Problem mit unserer Klasse. Die Werte der Eigenschaften sind innerhalb des Konstruktors fix gesetzt. Ziel sollte es sein, diese dynamisch zu übergeben und zu setzen. Zum Beispiel wie folgt:

let myForm = new Form(10, 5, "Rechteck");

Um das zu erreichen, muss die Konstruktur-Funktion Argumente entgegegen nehmen und sie auf das Objekt setzen können. Das geschieht wie folgt:

class Form {
 constructor (width, height, name) {
  this.width = width;
  this.height = height;
  this.name = name;
 }
}

let myForm = new Form(10, 5, "Rechteck");

Damit können weitere Formen dynamisch erstellt werden. Neben myForm können wir auch myNextForm mit anderen Werten erstellen:

let myNextForm = new Form(8, 8, "Quadrat");

Wenn es nun um die Instanz-Methoden geht, werden diese standardmäßig unter dem Konstruktor definiert:

class Form {
 constructor (width, height, name) {
  this.width = width;
  this.height = height;
  this.name = name;
 }

 getArea() {
  return this.width * this.height;
 }

}

let myForm = new Form(10, 5, "Rechteck");

Hier haben wir eine Methode namens “getArea” definiert, die uns die Fläche der Form ausrechnet, indem Breite und Höhe multipliziert werden. Um diese Instanz-Methode aufzurufen, reicht es diese an die erstellte Form dranzuhängen:

myForm.getArea();

Getters und Setters

Getters und Setters sind Methoden innerhalb Klassen, die dann jedoch so genutzt werden, als wären sie Properties bzw. Eigenschaften. Über Getters und Setters hatte ich in diesem Artikel schon geschrieben.

Zur Erklärung gehen wir vom folgenden Beispiel aus:

class Form {
 constructor (width, height) {
  this.width = width;
  this.height = height;
 }

}

Um nun die Fläche der Form zu erhalten, müssen wir die Höhe mit der Breite multiplizieren. Das können wir erreichen, indem wir einen Getter definieren. Ein Getter wird mit einem get-Keyword eingeleitet, gefolgt vom Getter-Namen:

class Form {
 constructor (width, height) {
  this.width = width;
  this.height = height;
 }

 get area () {
  return this.width * this.height;
 }

}

Um dies zu testen, können wir nun eine Form erstellen und die Methode wie folgt aufrufen:

let myForm = new Form(2, 2);
myForm.area;

Die area-Methode sieht beim Aufruf wie eine Property aus, ist jedoch im Hintergrund eine Funktion. Sprich, die Funktion muss nicht mehr mit Klammern aufgerufen werden. Wir können aber auch den anderen Weg gehen, indem wir eine Fläche übergeben und dann Höhe und Breite zurückbekommen. Dies wird über Setter erreicht. Konkret würde das heißen, dass wir folgende Zuweisung machen können:

myForm.area = 8;

Im Anschluss rechnet uns unser Programm dann die Höhe und Breite dieser Fläche aus. Durch die Zuweisung erweckt das den Anschein, also ob dies eine normale Eigenschaft ist. Im Hintergrund jedoch wird eine Setter-Funktion ausgeführt. Setters werden mit dem set-Keyword eingeleitet, gefolgt vom Namen:

class Form {
 constructor (width, height) {
  this.width = width;
  this.height = height;
 }

 get area () {
  return this.width * this.height;
 }

 set area (area) {
  this.width = Math.sqrt(area);
  this.height = Math.sqrt(area);
 }

}

Wie man sehen kann nimmt der Setter ein Argument entgegen, welches dem gesetzten Wert von “myForm.area = 8” entspricht. Innerhalb des Setters setzen wir Breite und Höhe der Form, indem wir jeweils die Quadratwurzel daraus ausrechnen. Wie das nun alles zusammen funktioniert, sehen wir, wenn wir folgenden Code ausführen:

let myForm = new Form(4, 4);
myForm.width;
myForm.height;
myForm.area = 8;
myForm.width;
myForm.height;

Wir erstellen zunächst eine Form mit einer Breite und Höhe von jeweils 4. Lassen wir uns diese Werte ausgeben, dann bekommen wir unsere initial übergebenen Parameter-Werte von 4 und 4 zurück:

let myForm = new Form(4, 4);
myForm.width; // Gibt 4 zurück
myForm.height; // Gibt 4 zurück

Dann übergeben wir dem Setter den Wert 8:

myForm.area = 8;

Dies führt dazu, dass unsere oben definierte Setter-Logik ausgeführt wird. Rufen wir Breite und Höhe auf, bekommen wir die neuen Werte:

myForm.width; // Gibt 2.8284271247461903 zurück
myForm.height; // Gibt 2.8284271247461903 zurück

Nun könnte man das gleiche auch mit Funktionen erreichen. Zum Vergleich erstelle ich nachfolgend zwei Objekte. Das erste Objekt erstellt Getter und Setter mittels einfachen Funktionen, das zweite Objekte enthält Getter und Setter über das get- und set-Keyword:

let obj1 = {
    name: 'Demir',
    getName: function () {
        return this.name; 
    }, 
    setName: function (val) { 
        this.name = val; 
    } 
} 

let obj2 = {
    myName: 'Demir', 
    get name () {
        return this.myName; 
    }, 
    set name (val) {
        this.myName = val; 
    }
} 

Im ersten Beispiel sieht man, dass es eine Eigenschaft gibt und zwei Funktionen mit den Namen getName und setName. Beim zweiten Beispiel gibt es jedoch nur eine Methode mit den Namen “name”, die einmal mit dem get- und einmal mit dem set-Keyword eingeleitet wird. Beachte zudem, dass sich der Eigenschaften-Name nun vom Namen des Getters und Setters unterscheiden muss. Daher habe ich im zweiten Beispiel für die Eigenschaft den Namen “myName” gewählt. “name” als Eigenschaften-Name wäre nicht zulässig. Getter und Setter haben jedoch gegenüber den üblichen Funktionsdeklarationen folgende Vorteile:

  • Aus Sicht der Syntax müssen keine Klammern gesetzt werden, um die Getter und Setter aufzurufen. Wir bekommen dadurch Funktionen, die jedoch wie Eigenschaften aussehen.
  • Beim Aufruf von Getters und Setters können zusätzlich Logiken implementiert werden, die während dem Aufruf bzw. der Zuweisung von Werten mitausgeführt werden sollen. Dadurch können die Werte unter anderem überprüft oder geändert werden, bevor sie tatsächlich gesetzt werden.
  • Im ersten Beispiel besteht die Möglichkeit, dass die Methoden “getName” und “setName” versehentlich mittels obj1.getName = “Anderer Name” überschrieben werden. Dadurch verliert man die Funktionslogik komplett. Beim zweiten Beispiel kann das nicht passieren, da obj2.name = “Anderer Name” zwar einen anderen Namen setzt, aber die Funktionslogik nicht überschreibt. Im ersteren Fall könnte man einen ähnlichen Schutz ebenso erreichen. Jedoch müssten die Getter- und Setter-Eigenschaften über Object.defineProperties definiert werden.

Statische Methoden

Bei einer statischen Methode handelt es sich um eine Methode, die zwar innerhalb der Klasse definiert wird, jedoch wird die Methode nicht Teil des erstellten Objekts. Eine statische Methode braucht also keine Instanz der Klasse um ausgeführt werden zu können. Daher werden sie oft auch als Helper-Funktionen angesehen. Sie gehören zwar zur Klasse, werden aber eben nicht an das Objekt gebunden. Nun folgt dazu ein Beispiel. Starten wir wieder mit einer einfachen Klasse:

class Form {
 constructor (width, height) {
  this.width = width;
  this.height = height;
 }
}

Nun brauchen wir eine Methode, die prüft ob die Breite zweier Formen (Objekte) gleich sind. Da der Abgleich über zwei Objekte hinweg stattfinden, brauchen wir die Methode nicht im Objekt selbst, sondern nur in der Klasse. Grundsätzlich wird eine statische Methode mit dem static-Keyword eingeleitet. Danach folgt die Logik:

class Form {
 constructor (width, height) {
  this.width = width;
  this.height = height;
 }
 
 static checkForm(form1, form2) {
  return form1.width === form2.width;
 }
}

Nun erstellen wir zwei Objekte davon:

let myForm1 = new Form(4, 4);
let myForm2 = new Form(4, 6);

Um nun die statische Methode aufzurufen, müssen wir dies über die Klasse machen und nicht über das Objekt. Heißt:

Form.checkForm(myForm1, myForm2);

Vererbung (Inheritance) mit extends-Keyword

Mit dem extends-Keyword lässt sich das Konzept der Vererbung (Inheritance) in JavaScript umsetzen. Dabei wird zunächst eine allgemeine Klasse definiert, die dann als Basis für die Definition weiterer Sub- oder Child-Klassen dient. Das Resultat der Child-Klassen ist dann die Beibehaltung aller Eigenschaften und Methoden der Eltern-Klasse mit zusätzlichen extra Funktionalitäten, die dann nur in der Child-Klassen enthalten sind.

Gehen wir vom folgenden Beispiel aus. Wir definieren zunächst die Eltern-Klasse:

class Person {
 constructor (name, age) {
  this.name = name;
  this.age = age;
 }
}

Von dieser Eltern-Klasse möchten wir nun eine Child-Klassen ableiten, die einen bestimmten Beruf darstellt. Um das zu erreichen, nutzen wir das Keyword “extends” und erreichen damit eine Vererbung. Hier die Syntax:

class Worker extends Person {
// Logik
}

“Worker” übernimmt nun alles von der Klasse “Person” und kann nun eigene Logiken dazu definieren. Im nächsten Schritt möchten wir die Eigenschaften der Eltern-Klasse übernehmen aber auch eine eigene Eigenschaft definieren, die nur in der Child-Klasse vorkommt. Dazu erstellen wir den Konstruktur wie gewohnt. Die gewünschten zu übernehmenden Eigenschaften der Eltern-Klasse müssen dabei zunächst gesetzt werden:

class Worker extends Person {
 constructor (name, age) {
  // Logik
 }
}

Um diese nun von der Eltern-Klasse aufzurufen und zu setzen, kommt das super-Keyword zum Einsatz:

class Worker extends Person {
 constructor (name, age) {
  super(name, age);
 }
}

Das super-Keyword ruft dabei den Konstruktor der Eltern-Klasse auf, damit die Logik innerhalb der Child-Klasse erhalten bleibt. Nun können zusätzliche Logiken implementiert werden:

class Worker extends Person {
 constructor (name, age, job) {
  super(name, age);
  this.job = job;
 }
}

Polymorphismus (Polymorphism)

Polymorphismus in JavaScript-Klassen beschreibt den Vorgang eine Methode innerhalb einer Child-Klasse zu überschreiben, die von einer Eltern-Klasse vererbt wurde. Am besten schauen wir uns das anhand eines Beispiels an. Nehmen wir dazu wieder unsere Klasse von oben:

class Person {
 constructor (name, age) {
  this.name = name;
  this.age = age;
 }

 saySomething() {
  console.log("Log aus Elternklasse");
 }

}

Wie man sehen kann haben wir eine sehr einfache Methode namens “saySomething” definiert, die ein console.log durchführt. Wenn wir nun eine Instanz davon erstellen:

const p1 = new Person("Max", 20);

Beim Aufruf der soeben definierten Methode sehen wir wie erwartet unseren Text in der Konsole:

p1.saySomething();
// Konsole: "Log aus Elternklasse"

Jetzt erstellen wir eine Child-Klasse und überschreiben die Methode “saySomething”:

class Worker extends Person {
 constructor (name, age) {
  super(name, age);
 }

 saySomething() {
  console.log("Log aus Child-Klasse");
 }

}

Wie man sehen kann, überschreiben wir nun die Methode “saySomething”. Dieses Konzept wird als Polymorphismus bezeichnet. Testen wir nun das Ergebnis:

const p2 = new Worker("Tom", 22);
p2.saySomething();
// Konsole: "Log aus Child-Klasse"

Wie wir sehen, wird nun der überschriebene Werte geloggt. Was konkret passiert ist relativ einfach:

  • Beim Aufruf “p2.saySomething()” wird zunächst geprüft, ob die Methode innerhalb der definierten Klasse existiert. Falls ja, wird sie aufgerufen.
  • Falls nein, prüft JavaScript weiter bei der Elternklasse, ob diese Methode existiert. Falls ja, wird sie aufgerufen.

Du kannst das auch prüfen, indem du die Methode “saySomething” bei der Child-Klasse entfernst oder auskommentierst und dann über “p2.saySomething()” aufrufst. In diesem Fall würdest du den console.log “Log aus Elternklasse” sehen. Es besteht aber auch die Möglichkeit mit dem super-Keyword in der Child-Klasse beide Logs auszuführen. Sehen wir uns folgendes Beispiel an:

class Worker extends Person {
 constructor (name, age) {
  super(name, age);
 }

 saySomething() {
  super.saySomething();
  console.log("Log aus Child-Klasse");
 }

}

Wenn wir innerhalb der Child-Klasse und Methode “saySomething” ein “super.saySomething()” definieren, dann wird vor dem überschriebenen Log zuerst der Log aus der Elter-Klasse durchgeführt. Sprich:

const p2 = new Worker("Tom", 22);
p2.saySomething();
// Konsole: "Log aus Elternklasse" und "Log aus Child-Klasse"