JavaScript/Scope
Der Begriff des Gültigkeitsbereichs (Scope) ist in JavaScript an etlichen Stellen von Bedeutung und soll in diesem Artikel zusammenhängend beschrieben werden.
Inhaltsverzeichnis
Einführung
JavaScript ist eine Sprache, die die Erreichbarkeit von Variablen, Funktionen und Klassen basierend auf der Struktur des Quellcodes festlegt. Diese Struktur wird durch Module, Funktionen und Anweisungsblöcke gebildet. Man nennt dies einen lexikalischen oder statischen Scope. Wie sich die Funktionen während der Programmausführung gegenseitig aufrufen, ist für einen statischen Scope nicht von Bedeutung.
Scopes bilden eine Hierarchie. Wird beispielsweise eine Funktion in einem Modul definiert, ist der Modulscope der Elternscope ihres Funktionsscopes. Wird eine Funktion B innerhalb einer Funktion A definiert, ist der Funktionsscope von A der Elternscope des Funktionsscopes von B. Die oberste Stufe dieser Hierarchie wird vom globalen Scope gebildet.
Innerhalb eines bestimmten Scopes kann ein Programm auf alle Namen zugreifen, die in diesem Scope deklariert wurden. Wird ein Name in diesem Scope nicht gefunden, schaut JavaScript in den Elternscope, ob dort eine Deklaration vorliegt, und so fort, bis der globale Scope erreicht ist. Dieser Prozess geschieht einmalig beim Laden und Vorbereiten des Programms, nicht bei jedem Zugriff. Kann kein Scope gefunden werden, in dem der Name deklariert ist, wird im strikten Modus ein ReferenceError ausgelöst. Außerhalb des strikten Modus führen nur Lesezugriffe zu einem ReferenceError, bei einem Schreibzugriff wird automatisch eine Variable im globalen Scope erzeugt.
Der Bereich, in dem ein bestimmter Name verwendet werden kann, ist also der Scope, in dem dieser Name deklariert wurde, und alle Scopes, die den deklarierenden Scope direkt oder indirekt als Elternscope haben.
Der Begriff Scope ist zum einen bei der Frage relevant, wo auf welche Deklarationen zugegriffen werden kann. Zum anderen ist aber jedem Scope auch Speicherplatz zugeordnet, und damit kommt die Frage der Lebensdauer eines Scopes ins Spiel, also wann dieser Speicherplatz bereitgestellt und wann wieder entfernt wird. Darauf werden wir bei der folgenden Besprechung der Scope-Arten eingehen.
Außer dem Scope gibt es noch den Begriff des Realm (Gebiet, Reich). Mit dem Realm ist die jeweilige JavaScript-Ausführungsumgebung gemeint. Beim Laden eines Dokuments erzeugt der Browser einen Realm für das Dokument. Weitere Realms können entstehen, wenn Web Worker gestartet werden oder ein Serviceworker installiert wird. Jeder Realm hat seine eigenen Variablen und Scopes, und ein direkter Zugriff auf die Daten eines anderen Realms ist nicht möglich.
Grundsätzlich werden vier unterschiedliche Arten von Scopes unterschieden.
Globaler Scope
Der globale Scope ist der Scope, der dann gilt, wenn nicht ausdrücklich ein anderer Scope erzeugt wurde. Variablen und Funktionen, die im globalen Scope deklariert wurden, sind in allen Scripten sichtbar, der globale Scope ist direkt oder indirekt Elternscope aller anderen Scopes.
Für jeden JavaScript-Realm wird ein eigener globaler Scope angelegt, und er existiert so lange wie der Realm.
Eine Besonderheit des globalen Scope ist, dass Variablen, die im globalen Scope mit var
deklariert werden, automatisch als Eigenschaften des globalen Objekts zur Verfügung stehen. Dies gilt nicht für Variablendeklarationen mit let
oder const
!
Modulscope
Für jedes Modul nach dem ECMAScript 2015 Standard, das Sie laden, wird ein eigener Modulscope gebildet. Er gilt im Modul überall dort, wo kein anderer Scope erzeugt wurde und ist direkt oder indirekt Elternscope aller in diesem Modul gebildeten Scopes.
Modulscopes sind untereinander nicht hierarchisch. Der Elternscope jedes Modulscopes ist der globale Scope des Realms, in dem das Modul geladen wird. Wenn Sie in einem Modul A ein Modul B importieren, sind globale Variablen aus A nicht in B verfügbar.
Der Speicherplatz für den Modulscope wird beim Laden des Moduls bereitgestellt. Da sich Module nicht entladen lassen, bleibt er ab dann bis zum Ende der Lebensdauer des Realms bestehen. Wenn Sie ein Modul in mehr als einen Realm laden, erhält es in jedem Realm seinen eigenen Modulscope.
Funktionsscope
Wenn Sie eine Funktion definieren, gehören ihre Parameter und alle Namen, die innerhalb der geschweiften Klammern des Funktionsanweisungsblocks deklariert werden, zum Scope dieser Funktion. Funktionen, die Sie im Funktionsanweisungsblock definieren, gehören ebenfalls zu diesem Scope.
Variablen und Funktionen, die zum Funktionscope gehören, sind in jeder Programmzeile verwendbar, die sich zwischen den geschweiften Klammern des Funktionsanweisungsblocks befindet. Alle anderen Programmteile können auf diese Namen nicht zugreifen; aber die Funktion kann die in ihrem Scope gespeicherten Daten beliebig anderswo speichern, sie als Argumente an Funktionen übergeben oder an ihren Aufrufer zurückgeben.
Solange die Funktion nicht aufgerufen wird, ist ihr Scope abstrakt und belegt auch keinen Speicher. Erst, wenn die Funktion aufgerufen wird, wird Speicher bereitgestellt, und zwar für jeden Aufruf der Funktion erneut. Der Speicherplatz für einen Scope kann in dieser Sichtweise wie ein Objekt aufgefasst werden: es wird beim Aufruf der Funktion erzeugt, und jede Variable (oder nicht-anonyme Funktion) des Funktionsscopes kann als eine Eigenschaft dieses Objekts gedeutet werden. Allerdings ist dieses „Scope-Objekt“ wirklich nur eine Vorstellungshilfe, es ist eine rein interne Angelegenheit von JavaScript.
Das Ende einer Scope-Lebensdauer, mit dem auch der bereitgestellte Speicher wieder freigegeben wird, ist dagegen nicht so einfach zu bestimmen. Im einfachen Fall endet die Lebensdauer, wenn die Funktion zu ihrem Aufrufer zurückkehrt. Es gibt aber auch Fälle, in denen ein Scope länger existiert als der Funktionsaufruf dauert, für den er erzeugt wurde. Das ist das Thema des folgenden Abschnitts.
Closure
Zu jeder Funktion gehört ein Funktionsobjekt. Es wird in dem Moment erzeugt, in dem die Funktion definiert wird, und es enthält einen Verweis auf den Scope, in dem die Funktion definiert wurde. Der vorhin gezogene Vergleich eines Scopes mit einem Objekt trägt auch hier: solange irgendwo ein Verweis auf ihn gespeichert ist, bleibt er im Speicher erhalten.
Ein Standardbeispiel für diese Situation ist eine Funktion A, innerhalb der eine Funktion B definiert wird, und A gibt das Funktionsobjekt von B dann an den Aufrufer zurück.
function erstelleAddierer(summand1) {
return function add(summand2) {
return summand1 + summand2;
}
}
const add7 = erstelleAddierer(7);
console.log(add7(15)); // gibt 22 aus
Innerhalb der Funktion erstelleAddierer
wird eine weitere Funktion definiert. Sie müsste keinen Namen bekommen, aber um die Beschreibung zu vereinfachen, soll sie hier add
heißen. Mit add
geschieht innerhalb von erstelleAddierer
nichts weiter, als dass sie definiert und dann mit der return
-Anweisung an den Aufrufer zurückgegeben wird. Den Programmcode von add
gibt es nur einmal, aber bei jedem Aufruf von erstelleAddierer
werden zwei Dinge neu erzeugt: Der Funktionsscope von erstelleAddierer
, und ein Funktionsobjekt für add
. Und dieses Funktionsobjekt enthält zwei Dinge: einen Verweis auf den Programmcode für add
und einen Verweis auf den Scope von erstelleAddierer
, in dem es erstellt wurde.
Dadurch, dass dieses add
-Funktionsobjekt an den Aufrufer zurückgegeben wird, wird ein Verweis auf den Funktionsscope des erstelleAddierer
-Aufrufs außerhalb der Funktion verfügbar! Nachdem das add
-Objekt zurückgegeben und in der Konstanten add7
abgelegt wurde, ist der Aufruf von erstelleAddierer
beendet. Normalerweise könnte nun der Funktionsscope für diesen Aufruf gelöscht werden - aber hier ist es so, dass es nach dem Ende des Funktionsaufrufs noch das add
-Objekt gibt, das in add7
gespeichert ist und das einen Verweis auf diesen Scope enthält. Damit existiert weiterhin ein Verweis auf den Scope, und die Speicherbereinigung von JavaScript löscht ihn nicht.
Und so kommt es, dass man add7(15)
aufrufen kann und während dieses Aufrufs immer noch bekannt ist, dass summand1
in dem Moment, wo das add7
-Funktionsobjekt entstand, den Wert 7 enthielt. Die gedankliche Vorstellung ist, dass das Funktionsobjekt von add7
den Scope, in dem es defininiert wurde, in sich eingeschlossen behält, so dass er weiter genutzt werden kann. Daher stammt der Begriff Closure.
Eventhandler sind ein weiteres Beispiel für eine Situation, in der Closures nützlich sind. Betrachten Sie die folgende Funktion registerTripleClickHandler
:
function registerTripleClickHandler(element, handler) {
element.addEventListener('click', function(clickEvent) {
if (clickEvent.detail == 3) {
handler(clickEvent);
}
});
}
registerTripleClickHandler
registriert auf einem Element einen click
-Handler, der die detail
-Eigenschaft des MouseEvent
-Objekts abfragt. In einem click
-Event gibt die Eigenschaft detail
des Event-Objekts an, wie oft geklickt wurde, bevor zwischen zwei Klicks mehr als die im Betriebssystem eingestellte Doppelklick-Zeit verging. Ein Triple-Klick bestünde also aus drei Mausklicks, die schnell hintereinander erfolgen. Beim dritten Klick hat detail
den Wert 3.
Die anonyme Funktion, die als Eventlistener registriert wird, nimmt eine Referenz auf den Scope des registerTripleClickHandler
-Aufrufs mit. Da der EventListener im DOM gespeichert ist, bleibt das Funktionsobjekt der anonymen Funktion auch nach Ende von registerTripleClickHandler
erhalten, und durch seine Referenz auf den Aufrufscope auch dieser Scope. Deshalb kann, wenn das click-Event behandelt wird und clickEvent.detail == 3
wahr wird, noch auf den handler
-Parameter des registerTripleClickHandler
-Aufrufs zugegriffen werden.
Scopeketten
Beim Aufruf der anonymen EventListener-Funktion, die im Triple-Click Beispiel verwendet wurde, ist genau genommen mehr als nur einen Scope im Spiel. Zum einen gibt es einen eigenen Funktionsscope für den Aufruf des Eventhandlers. In diesem Scope befindet sich der Parameter clickEvent. Sein Elternscope ist der Funktionsscope des registerTripleClickHandler
-Aufrufs, in dem die Eventhandler-Funktion definiert wurde und auf den sie als Closure verweist.
Wenn registerTripleClickHandler
eine Funktion im globalen Scope ist, würde jetzt nur noch der globale Scope folgen. Es kann aber genauso gut sein, dass registerTripleClickHandler
eine Funktion in einem ECMAScript-Modul ist. In diesem Fall wäre der Elternscope nicht der globale Scope, sondern der Modulscope
registerTripleClickHandler
könnte aber auch genauso gut innerhalb einer ganz anderen Funktion definiert worden sein. Die Scopekette würde sich dann um den Aufruf dieser Elternfunktion verlängern.
Das Entscheidende an dieser Kette ist: jeder Scope in der Kette belegt Speicher, und jeder dieser Scopes bleibt im Speicher, bis die Eventhandler-Registrierung wieder gelöscht wird. In komplexen Anwendungen können auf diese Weise schnell größere Speichermengen verloren gehen, deswegen muss man vorsichtig damit sein, wie tief man Funktionen schachtelt, die Closures verwenden.
Blockscope
Blockscopes wurden mit ECMAScript 2015 eingeführt und bewirken, dass jeder Anweisungsblock seinen eigenen Scope besitzt. Wenn Sie Variablen mit const
oder let
in einem Anweisungsblock deklarieren, gelten sie nur innerhalb dieses Anweisungsblocks. Mit var
deklarierte Variablen und als Statement deklarierte Funktionen lassen sich nicht in einen Blockscope einschließen.
Der Blockscope entsteht, wenn die Programmausführung diesen Anweisungsblock betritt, und endet, wenn der Block verlassen wird. Aber auch ein Blockscope kann zur Closure werden, wenn Sie innerhalb dieses Blocks eine Funktion definieren.
Scopes und Sichtbarkeit
Der Name einer Variablen oder Funktion kann in jedem Scope neu vergeben werden.
var x = 1;
function hypotenuse(x, y) {
return Math.sqrt(x*x + y*y);
}
console.log(test(3,4)); // Ausgabe ist 7
In diesem Beispiel definiert die Funktion test
zwei Parameter, x
und y
. Parameter werden wie lokale Variablen im Scope der Funktion behandelt. Bei der Berechnung von x + y
muss JavaScript nun entscheiden, welches x
gemeint ist: der Parameter oder die globale Variable. Das Entscheidungskriterium ist, welche Scopes an dieser Stelle aktiv sind und welcher dieser Scopes der Rechenoperation am nächsten liegt. Dies ist der Funktionsscope der test
-Funktion, und deshalb wird der Parameter verwendet.
Das Math-Objekt, dessen sqrt
-Funktion zum Berechnen der Quadratwurzel aufgerufen wird, existiert hingegen im Funktionsscope nicht. Deshalb geht JavaScript nun den Scope-Stapel weiter nach unten durch und findet Math
im globalen Scope.