Organisation von JavaScripten: Module und Kapselung

Unstrukturierte Scripte

Zahlreiche Scripte, die JavaScript-Programmierer im Netz anbieten, liegen in einer gesonderten Datei vor und sind darüber hinaus unstrukturiert. Es handelt sich um eine lose Sammlung von dutzenden globalen Variablen und Funktionen:

var variable1 = "wert";
var variable2 = "wert";
var variable3 = "wert";

function funktion1 () {
    /* ... */
}
function funktion2 () {
    /* ... */
}
function funktion3 () {
    /* ... */
}

Diese Organisation bringt in der Regel mit sich, dass das Script nicht einfach konfigurierbar, anpassbar und erweiterbar ist. Am schwersten wiegt jedoch, dass es sich um eine große Zahl von losen Objekten im globalen Scope (Variablen-Gültigkeitsbereich) handelt. Globale Variablen und Funktionen sind Eigenschaften des window-Objektes. Das obige Beispiel definiert daher sechs Eigenschaften beim window-Objekt: window.variable1 bis indow.variable3 sowie window.funktion1 bis window.funktion3.

Das Yin und Yang von JavaScript: Kapselung vs. Verfügbarkeit

Clientseitige JavaScripte arbeiten unter besonderen Bedingungen: Ein Script operiert auf einem HTML-Dokument, auf das es über das DOM zugreift. Ferner operiert es im Kontext des sogenannten globalen Objektes. Das ist in JavaScript das window-Objektes, welches den obersten Namensraum bereitstellt. Sowohl das globale Objekt als auch das DOM teilt es sich mit anderen Scripten. Diese »öffentlichen Güter« darf kein Script für sich alleine beanspruchen.

Wenn Scripte unterschiedlicher Herkunft zusammenkommen, kann das schnell zu Konflikten führen. Die Vermeidung von Konflikten setzt bereits beim Unobtrusive JavaScript an: Indem wir JavaScript-Code nicht direkt ins HTML-Dokument einbetten, sondern fortgeschrittenes Event-Handling verwenden, reduzieren wir Überschneidungen im DOM.

Konfliktfeld Nummer Eins bleibt das window-Objekt. Darüber können über das zwei Scripte zusammenarbeiten, aber auch in Konflikt geraten, wenn sie gleichnamige Variablen definieren. In JavaScript gilt es daher, ein Gleichgewicht zwischen Kapselung und Verfügbarkeit herzustellen.

Datenkapselung bedeutet, dass das Erweitern des globalen Objekts sowie der DOM-Objekte auf ein Minimum reduziert wird. Ein Script sollte den globalen Scope nicht für seine Arbeitsdaten verwenden und globale Variablen möglichst vermeiden. Es sollte nur die Objekte am window-Objekt speichern, die für den Zugriff von außen unbedingt vonnöten sind.

Bei manchen Aufgaben ist es möglich, ein Script konsequent zu kapseln, sodass es das globale window-Objekt nicht antastet. In anderen Fällen ist es nötig, zumindest einige Objekte global verfügbar zu machen. Gründe dafür können sein:

Es kommt daher auf das richtige Gleichgewicht an. Zwei Beispiele: Das riesige jQuery-Framework definiert standardmäßig nur zwei globale Variablen: window.jQuery() und als Alias window.$(). Das YUI-Framework definiert lediglich window.YUI(). window.jQuery() und window.YUI() sind beides Funktionen, denen man beim Aufruf letztlich Funktionen übergibt - dazu später mehr. Beide Frameworks schaffen es, nicht mehr als ein globales Objekt anzulegen, ohne auf die obigen Features wie Erweiterbarkeit zu verzichten.

Einfache Module mit dem Objekt-Literal

Eine einfache Möglichkeit, um den globalen Scope zu schonen, ist die Gruppierung aller Variablen und Funktionen eines Scripts in einer JavaScript-Objektstruktur. Im globalen Geltungsbereich taucht dann nur noch diese eine Objektstruktur auf, andere globale Variablen oder Funktionen werden nicht belegt. Das Script ist in der Objektstruktur in sich abgeschlossen. Damit sind Wechselwirkungen mit anderen Scripten ausgeschlossen, solange der Bezeichner der Objektstruktur eindeutig ist.

Ein JavaScript-Objekt ist erst einmal nichts anderes als ein Container für weitere Daten. Ein Objekt ist eine Liste, in der unter einem Bezeichner gewisse Unterobjekte gespeichert sind. Aus anderen Programmiersprachen ist diese solche Datenstruktur als Hash oder assoziativer Array bekannt. In JavaScript sind alle vorgegebenen Objekte und Methoden in solchen verschachtelten Objektstrukturen organisiert, z.B. window.document.body.

In JavaScript gibt es den allgemeinen Objekttyp Object, von dessen Prototypen alle anderen JavaScript-Objekte abstammen. Das heißt, jedes JavaScript-Objekt ist immer auch ein Object-Objekt. Object ist die Grundlage, auf der die restlichen spezifischeren Objekttypen aufbauen.

Für die Organisation von eigenen Scripten bieten sich solche unspezifischen Object-Objekte an. Über new Object() lässt sich ein Object-Objekt erzeugen:

var Modul = new Object();
Modul.eigenschaft = "wert";
Modul.methode = function () {
    alert("Modul-Eigenschaft: " + Modul.eigenschaft);
};
Modul.methode();

Über die gewohnte Schreibweise zum Ansprechen von Unterobjekten (objekt.unterobjekt) werden dem Object weitere Objekte angehängt. Im Beispiel werden zwei Objekte angehängt, ein String und eine Funktion.

Der Name Modul ist selbstverständlich nur als Platzhalter gemeint. Sie sollten das Object-Objekt (im Folgenden kurz Object genannt) eindeutig und wiedererkennbar nach der Aufgabe bzw. dem Zweck ihres Scriptes benennen.

JavaScript bietet für das Definieren von Object-Objekten eine Kurzschreibweise an, den sogenannten Object-Literal. Ein Object-Literal beginnt mit einer öffnenden geschweiften Klammer { und endet mit einer schließenden geschweiften Klammer }. Dazwischen befinden sich, durch Kommas getrennt, die Zuweisungen von Namen zu Objekten. Zwischen Name und Objekt wird ein Doppelpunkt notiert. Das Schema ist also: { name1 : objekt1, name2 : objekt2, … nameN : objektN }

Das obige Beispiel-Object lässt sich in der Literalschreibweise so umsetzen:

 
var Modul = {
    eigenschaft : "wert",
    methode : function () {
        alert("Modul-Eigenschaft (über window.Modul): " + Modul.eigenschaft);
        // Alternativ:
        alert("Modul-Eigenschaft (über this): " + this.eigenschaft);
    }
};
Modul.methode();

Eine Illustration der entstehenden Verschachtelung:

Der Zugriff auf die Unterobjekte des Object-Containers ist von außen über den globale Namen nach dem Schema Modul.eigenschaft möglich. Im Beispiel wird über Modul.methode() die zuvor angehängte Funktion aufgerufen.

Kapselung mit privatem Funktions-Scope

Beim Objekt-Literal wird ein globales Objekt als Namensraum benutzt, um darin eigene Objekte unterzubringen. All diese Objekte sind über das Containerobjekt für andere Scripte zugänglich. Es gibt also keine Trennung zwischen öffentlichen und privaten Daten. Während es sinnvoll ist, dass z.B. eine Methode Modul.methode() von außen aufrufbar ist, ist es unnötig und potenziell problematisch, dass jede Objekteigenschaft gelesen und manipuliert werden kann.

Der nächste Schritt ist daher, eine wirksame Kapselung zu implementieren. Das Mittel dazu ist ein eigener, privater Scope (Variablen-Gültigkeitsbereich). Darin können beliebig viele lokale Variablen und Methoden definiert werden. Die einzige Möglichkeit, in JavaScript einen Scope zu erzeugen, ist eine Funktion. Wir definieren also eine Funktion, um darin das gesamte Script zu kapseln. Solange durchgehend lokale Variablen und Funktionen verwendet werden, wird der globale Scope nicht angetastet.

Ein mittlerweile stark verbreitetes Muster ist daher folgender Codeschnipsel:

(function () {
    /* ... */
})();

Dies erscheint zunächst sehr kryptisch, daher eine schrittweise Zerlegung der Syntax:

  1. Erzeuge eine namenlose Funktion per Funktionsausdruck: function () { ... }
  2. Umschließe diesen Funktionsausdruck mit runden Klammern: (function () {})
  3. Führe die Funktion sofort aus mit dem Call-Operator, das sind die beiden runden Klammern: (function () { ... })(). Die Parameterliste bleibt in diesem Beispiel leer.
  4. Schließe die Anweisung mit einem ; ab.

Diese anonyme Funktion wird nur notiert, um einen Scope zu erzeugen, und sie wird sofort ausgeführt, ohne dass sie irgendwo gespeichert wird. Innerhalb der Funktion wird nun der gewünschte Code untergebracht:

(function () {
    
    /* Lokale Variable */
    var variable = 123;
    
    /* Lokale Funktion */
    function funktion () {
        /* ... */
    }
    
    /* Rufe lokale Funktion auf: */
    funktion();
    
    /* Zugriff auf globale Objekte ist ebenfalls möglich: */
    alert(document.title);
    
})();

Im Beispiel finden sich eine Variablendeklarationen und eine Funktionsdeklaration. Beide sind lokal, sind also nur innerhalb der Kapselfunktion zugänglich. Wir können auf die Variablen und Funktionen direkt zugreifen.

Das Beispiel macht noch nichts sinnvolles. Die Nützlichkeit von Funktionen zur Kapselung ergibt sich z.B. bei einem Anwendungsbeispiel mit Event-Handling.

(function () {
    
    var clickNumber = 0;
    var outputEl;
    
    function buttonClicked () {
        clickNumber++;
        outputEl.html('Button wurde ' + clickNumber + ' Mal angeklickt');
    }
    
    function init () {
        outputEl = jQuery('#output);
        jQuery('#button').click(buttonClicked);
    }
    
    jQuery(document).ready(init);
    
})();

Das zugehörige HTML:

<button id="button">Klick mich</button>
<p id="output">Button wurde noch nicht angeklickt</p>
<script type="text/javascript" src="beispiel.js"></script>

Der Code nutzt die jQuery-Bibliothek, um eine Initialisierungsfunktion bei DOM ready auszuführen. Diese registriert bei einem Button einen Event-Handler. Wird der Button geklickt, wird eine Zahl erhöht. Zudem wird die bisherige Anzahl der Klicks im Dokument ausgegeben.

Das Besondere an diesem Script sind die vier lokalen Variablen bzw. Funktionen. Sie werden direkt im Funktions-Scope notiert, anstatt sie an einen Object-Container zu hängen. Innerhalb der verschachtelten Funktionen sind die Variablen des äußeren Funktions-Scope verfügbar (siehe Closures). init() füllt die Variable outputEl und greift auf die Funktion buttonClicked() zu. buttonClicked() greift auf die Variablen clickNumber und outputEl zu. Das Script funktioniert, ohne dass Objekte am globalen window-Objekt angelegt werden.

DOM-ready-Handler als privater Scope

Bei der Verwendung mit jQuery ist das Anlegen solcher Funktions-Scope gang und gäbe. Wenn die Initialisierung eines Scriptes auf DOM ready warten soll, dann übergibt man einen Funktionausdruck an jQuery(document).ready(). Diese Funktion wird als Handler beim Eintreten des DOM-ready-Ereignisses ausgeführt. Man nutzt sie gleichzeitig als privaten Scope für weitere Objekte. Das obige Beispiel können wir also folgendermaßen anpassen:

jQuery(function ($) {
    
    var clickNumber = 0;
    var outputEl;
    
    function buttonClicked () {
        clickNumber++;
        outputEl.html('Button wurde ' + clickNumber + ' Mal angeklickt');
    }
    
    function init () {
        outputEl = $('#output);
        $('#button').click(buttonClicked);
    }
    
    init();
    
});

Die übergebene DOM-ready-Funktion bekommt das globale jQuery-Objekt als ersten Parameter. Wir nennen den Parameter hier $. Funktionsparameter sind automatisch lokale Variablen, das heißt, wir können mit $ genauso umgehen wie mit clickNumber oder buttonClicked.

Globale Objekte importieren

jQuery stellt standardmäßig window.$ als Abkürzung für window.jQuery zur Verfügung, wenn nicht der noConflict-Modus aktiviert wird. Es ergibt jedoch Sinn, das jQuery-Objekt als lokale Variable zu definieren, denn das beschleunigt den Zugriff darauf (Stichwort Scope-Chain).

Aus demselben Grund hat es sich eingebürgert, das window-Objekt sowie weitere häufig benutzte Objekte wie document mittels Parametern in den Funktions-Scope zu übergeben:

(function (window, document, undefined) {

    /* ... */
    
})(window, document);

Gleichzeitig wird hier sichergestellt, dass innerhalb der Funktion der Bezeichner undefined immer den Typ Undefined besitzt. Wir definieren einen solchen Parameter, aber übergeben keinen Wert dafür – sodass eine lokale Variable namens undefined mit einem leeren Wert angelegt wird. Das ist andernfalls nicht garantiert, denn window.undefined ist durch Scripte überschreibbar.

Innerhalb der Funktion können die Objekte genauso heißen wie außerhalb. Dennoch handelt es z.B. bei document innerhalb der Funktion um eine lokale Variable, auch wenn sie natürlich auf window.document verweist.

Das Revealing Module Pattern: Kapselung plus öffentliche Schnittstelle

Wir haben nun beide Extreme kennengelernt: Bei Object-Containern sind alle Unterobjekte öffentlich. Bei einer Kapselfunktion ist kein Objekt nach außen hin zugänglich. Wenn wir ein wiederverwendbares Script schreiben wollen, wollen wir meist eine öffentliche Schnittstelle (API) anbieten. Dazu müssen einige ausgewählte Objekte, in der Regel Methoden, sowohl nach außen sichtbar sein als auch Zugriff auf die internen, privaten Objekte haben. Man spricht in diesem Fall von privilegierten Methoden.

Diesen Kompromiss erreichen wir durch eine Kombination aus Object-Literalen und einer Kapselfunktion. Dieses Entwurfsmuster nennt sich Revealing Module Pattern. Kurz gesagt gibt die Kapselfunktion ein Objekt nach draußen, bevor sie sich beendet. Über dieses Objekt können gewisse privilegierte Methoden aufgerufen werden.

Wir beginnen mit dem bereits beschriebenen Funktionsausdruck, der sofort ausgeführt wird:

(function () {
    /* ... private Objekte ... */
})();

Das Neue ist, dass diese Funktion einen Wert zurückgibt, der in einer Variable gespeichert wird:

var Modul = (function () {
    /* ... private Objekte ... */
})();

Dieser Wert ist ein Objekt, welches wir in der Funktion mit einem Objekt-Literal notieren und mittels return nach draußen geben. An dem Objekt hängen die öffentlichen Eigenschaften und Methoden:

var Modul = (function () {
    
    /* ... private Objekte ... */
    
    /* Gebe öffentliche API zurück: */
    return {
        öffentlicheMethode : function () { ... }
    };
    
})();

Innerhalb der anonymen Funktion notieren wir wie üblich unsere privaten Objekte. Das folgende Beispiel definiert eine öffentliche, privilegierte Methode. Sie hat Zugriff auf sämtliche internen, privaten Objekte, welche direkt von außen nicht zugänglich sind.

var Modul = (function () {

    // Private Objekte
    var privateVariable = "privat";
    function privateFunktion () {
        alert("privateFunktion wurde aufgerufen\n" +
            "Private Variable: " + privateVariable);
    }
    
    // Gebe öffentliches Schnittstellen-Objekt zurück
    return {
        öffentlicheMethode : function () {
            alert("öffentlicheMethode wurde aufgerufen\n" +
                "Private Variable: " + privateVariable);
            privateFunktion();
        }
    };
    
})();

// Rufe öffentliche Methode auf
Modul.öffentlicheMethode();

// Ergibt undefined, weil von außen nicht sichtbar:
window.alert("Modul.privateFunktion von außerhalb: " + Modul.privateFunktion);

Da die privilegierten Methoden innerhalb des Funktions-Scope notiert werden, haben sie darauf Zugriff. Das liegt daran, dass sie Closures sind.

Es ist natürlich möglich, solche Module nicht direkt als globale Variablen zu speichern, sondern verschiedene in einem Object-Literal zu speichern. Dieser dient dann als Namensraum für zusammengehörige Module. So ist letztlich mehrere Module unter nur einer globalen Variable gespeichert.

var Namensraum = {};
Namensraum.Modul1 = (function () { ... })();
Namensraum.Modul2 = (function () { ... })();

Erweiterbare Module

Ben Cherry schlägt eine Erweiterbarkeit von Modulen auf Basis des Revealing Module Patterns vor. Er unterscheidet zwischen fester und lockerer Kopplung der Teile. Das heißt, entweder setzt ein Aufbaumodul ein Basismodul zwingend voraus. Oder beide Module ergänzen sich gegenseitig, sind aber auch separat funktionsfähig.

Feste Kopplung

/* Grundmodul */
var Modul = (function (Modul) {
    /* ... private Objekte ... */
    return {
        methode1 : function () { ... }
    };
})();

/* Erweiterung des Grundmoduls */
(function (modul) {
    /* ... private Objekte ... */
    /* Erweitere Modul um neue Methoden: */
    modul.methode2 = function () { ... };
})(Modul);

Die Definition des Grundmoduls erfolgt wie beim Revealing Module Pattern besprochen. Zur Erweiterung des Moduls wird eine weitere anonyme Funktion angelegt und ausgeführt. Diese Funktion bekommt das Modulobjekt als Parameter übergeben und fügt diesem neue Methoden hinzu oder überschreibt vorhandene. Innerhalb der Funktion können wie üblich private Objekte und Methoden angelegt werden.

Nach der Ausführung des obigen Codes besitzt das Modul zwei öffentliche Methoden:

Modul.methode1();
Modul.methode2();

Zu beachten ist, dass die Methoden der Erweiterung keinen Zugriff auf die privaten Objekte des Grundmoduls haben – denn sie befinden sich in einem anderen Funktions-Scope. Zur Lösung dieses Problems schlägt Ben Cherry eine Methode vor, die die privaten Objekte kurzzeitig öffentlich macht, sodass ein übergreifender Zugriff möglich ist. Das erscheint mir jedoch besonders umständlich – in diesem Fall würde ich privaten Objekte zu dauerhaft öffentlichen Eigenschaften machen und auf die vollständige Kapselung verzichten.

Lose Kopplung

Bei der losen Kopplung können die Teilmodule alleine oder zusammen stehen. Ferner ist die Reihenfolge, in der die Teilmodule notiert werden, unwichtig. Dafür können sie nicht stillschweigend auf die gegenseitigen öffentlichen Methoden zugreifen, sondern müssen gegebenenfalls prüfen, ob diese definiert sind.

var Modul = (function (modul) {
    /* ... private Objekte ... */
    
    /* Lege Methode am Modulobjekt an: */
    modul.methode1 = function () { ... };
    
    return modul; 
}(Modul || {}));

var Modul = (function (modul) {
    /* ... private Objekte ... */
    
    /* Lege Methode am Modulobjekt an: */
    modul.methode2 = function () { ... };
    
    return modul; 
}(Modul || {}));

Die Moduldeklarationen sind gleich aufgebaut: Es gibt eine anonyme Funktion, um einen privaten Scope zu erzeugen. Diese Funktion bekommt das bestehende Modul übergeben. Der Ausdruck Modul || {} prüft, ob das Modul bereits definiert wurde. Falls ja, wird dieses der Funktion übergeben. Andernfalls wird mit dem Object-Literal ein leeres Objekt erzeugt und übergeben. Somit ist gesichert, dass die Funktion ein Objekt als Parameter entgegennimmt. Innerhalb der Funktion können wir private Objekte notieren und das Modulobjekt um neue Eigenschaften erweitern. Am Ende wird das Modul zurückgegeben und der Rückgabewert in einer Variable gespeichert.

Das Resultat ist ebenfalls, dass das Modul zwei öffentliche Methoden besitzt:

Modul.methode1();
Modul.methode2();