Wie die Scopechain von JavaScript Variablen tatsächlich auflöst
Erklärt das Mechanismus der lexikalischen Scope-Kette hinter der Variablenabrufung, warum „var“ zu Schleifenfehlern führt und wie Schließungen daraus natürlicherweise entstehen.
Die Abfrage von Variablen hat nichts mit geschweiften Klammern zu tun
Die übliche Art und Weise, wie Menschen zunächst etwas über den Scope lernen, ist die Aussage „Scope ist alles, was innerhalb der Klammern steht.“ Diese Beschreibung hilft zwar bei einem Quiz, doch sie unterstützt einen nicht dabei, vorherzusagen, was ein Codeabschnitt tatsächlich tut. Der eigentliche Mechanismus ist mechanischer und vorhersehbarer, als diese Abkürzung vermuten lässt – sobald man ihn versteht, wirken Schließungen nicht mehr wie Magie.
Der Scope wird durch den Ort bestimmt, an dem der Code geschrieben wird, nicht durch die Art und Weise seiner Ausführung
JavaScript löst Variablen lexikalisch auf. Das bedeutet, der Scope einer Variable ist bereits bei ihrer Erstellung anhand ihrer physischen Position im Quellcode festgelegt – und nicht dadurch, welche Funktion während des Programmablaufs zufällig welche andere Funktion aufruft.
const value = "outer";
function readValue() {
console.log(value);
}
function runWithDifferentValue() {
const value = "inner";
readValue(); // still logs "outer", not "inner"
}
runWithDifferentValue();
readValue kümmert sich nicht darum, wer es aufruft oder welche Variablen im Umfeld des Aufrufers vorhanden sind. Einzig wichtig ist, wo es physisch definiert wurde – direkt neben const value = „outer“. Das ist die einzige value, die es jemals sehen kann, unabhängig davon, von wo aus im Programm es aufgerufen wird. Genau hier geraten Entwickler, die aus Sprachen mit dynamischem Scoping kommen (oder eigentlich alle Entwickler, die noch nie darüber nachdenken mussten, da lexikales Scoping fast überall Standard ist), in Verwirrung. Der Scope ist bereits im Moment der Code-Schreibung festgelegt; er ändert sich nicht je nach Aufrufstapel zur Laufzeit.
Der eigentliche Suchmechanismus: Das Durchlaufen der Scope-Kette
Wenn der Motor eine Variable auflösen muss, durchsucht er nicht den gesamten Codebasis. Er beginnt genau an der Stelle, an der die Variable verwendet wird, und bewegt sich nach außen, Schritt für Schritt durch jede umschließende Scope-Ebene, wobei er sofort anhält, sobald er eine Übereinstimmung findet:
const a = "global";
function outer() {
const b = "outer";
function inner() {
const c = "inner";
console.log(a, b, c); // "global outer inner"
}
inner();
}
outer();
inner prüft zunächst nach c und findet es direkt dort – es ist kein Wechsel in einen anderen Bereich nötig. Anschließend wird nach b gesucht. Da dieses nicht lokal definiert ist, erfolgt die Suche im Scope von outer, wo es schließlich gefunden wird. Danach wird nach a gesucht, das weder in inner noch in outer vorhanden ist; daher setzt sich die Suche weiter nach außen fort, bis sie den globalen Scope erreicht, in dem es schließlich lokalisiert wird. Diese Abfolge – zunächst der innere Scope, dann der darüber liegende Scope und so weiter bis zum globalen Scope – stellt den gesamten Suchalgorithmus dar. Es findet sich nichts Komplexeres als „schau hierhin, dann einen Level weiter suchen, wiederholen, bis es gefunden wird.“
Derselbe Mechanismus erklärt das variable Schattieren ohne den Bedarf an einer separaten Regel. Wenn inner seine eigene const b deklariert hätte, würde die Suche bereits im Moment des Findens dieser lokalen b stoppen und würde niemals die in outer definierte b erreichen. In diesem Szenario wird nichts überschrieben; es ist einfach so, dass die näher liegende Übereinstimmung zuerst gefunden wird, weshalb die Suche keinen Grund mehr hat, fortzusetzen.
Warum var sich anders verhält und warum das zu einem bekannten Fehler führt
let und const werden dem nächstgelegenen umschließenden Block zugeordnet, also jedem Paar von geschweiften Klammern – sei es eine if-Anweisung oder der Körper einer Schleife. var folgt dieser Regel überhaupt nicht. Stattdessen wird var der nächstgelegenen umschließenden Funktion zugeordnet und ignoriert dabei völlig die Grenzen der Blöcke.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// logs: 3, 3, 3
In diesem Codeausschnitt gibt es genau eine i, die auf die Funktion beschränkt ist, und jede Iteration der Schleife teilt sich diese einzige Variable. Wenn die setTimeout-Callback-Funktionen schließlich ausgeführt werden, hat die Schleife bereits aufgehört zu laufen und i hat den Wert 3 angenommen. Alle drei Callbacks beziehen sich auf dieselbe Variable, anstatt jeweils eine eigene Kopie zu erhalten, weshalb sie alle den endgültigen Wert anzeigen, den i zu diesem Zeitpunkt hatte.
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// logs: 0, 1, 2
Weil let blockbasiert gebunden ist und die Sprache für jede Durchlaufschleife eine neue Bindung von i erstellt, besitzt jeder Callback letztendlich seine eigene, unveränderliche Instanz von i, die auf dem Wert festgehalten wird, den sie während dieser bestimmten Iteration hatte. Der Code sieht fast identisch aus wie im vorherigen Beispiel, doch die zugrunde liegende Bindungsregel ist unterschiedlich, was zu einem weitaus vorhersehbareren Ergebnis führt.
Closures sind keine eigenständige Funktion, sondern nur eine Folge der Scope-Chain
Sobald man verstanden hat, wie die Scope-Kette funktioniert, benötigen Schließungen fast keine weitere Erklärung. Eine Schließung ist kein zusätzlicher Mechanismus, der auf dem Scope aufgebaut wird. Es handelt sich einfach um das, was natürlich geschieht, sobald man eine Funktion innerhalb einer anderen Funktion definiert und diese innere Funktion anschließend an einem Ort verwendet wird, nachdem der äußere Scope normalerweise bereits verworfen worden wäre.
function debounce(fn, delayMs) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delayMs);
};
}
const debouncedSearch = debounce((query) => runSearch(query), 300);
debounce wird genau einmal ausgeführt. Wenn man an Sprachen ohne Closures gewöhnt ist, könnte der Eindruck entstehen, timeoutId sollte sofort verschwinden, sobald debounce abgeschlossen ist. Das geschieht jedoch nicht, weil die zurückgegebene Funktion debounce innerhalb dieser Funktion definiert wurde, wodurch die Scope-Kette der zurückgegebenen Funktion dauerhaft den eigenen Scope von debounce, einschließlich timeoutId, enthält. Jeder nachfolgende Aufruf von debouncedSearch, egal wie spät er stattfindet, folgt dieser gleichen Scope-Kette zurück zu genau diesem timeoutId. Genau deshalb funktioniert die Debounce-Logik: Es muss eine einzige, persistente Variable geben, die den ausstehenden Timeout bei jedem Aufruf verfolgt – und die Closure sorgt dafür.
Dieselbe Eigenschaft, die Closures ermöglicht, kann auch zu Speicherverlusten führen
Was Schließungen nützlich macht, ist genau dieselbe Eigenschaft, die ein spezifisches, wiederkehrendes Problem verursacht: Eine Schließung behält ihren gesamten umgebenden Kontext bei – nicht nur die Variablen, auf die sie sich bezieht – und hält eine aktuelle Referenz auf die Variable selbst, anstatt eine eingefrorene Kopie ihres Wertes zum Zeitpunkt der Erstellung der Schließung zu besitzen.
function createHandlers() {
let clickCount = 0;
const massiveDataset = loadHugeArray(); // large, no longer needed after setup
return {
onClick: () => {
clickCount++; // only this variable is actually used
console.log(clickCount);
},
};
}
onClick’s Closure fängt den gesamten Scope von createHandlers ein, einschließlich massiveDataset, obwohl onClick diesen tatsächlich nie liest. Solange onClick aktiv ist, bleibt auch alles andere, was von ihm eingefangen wurde, bestehen – was ein echtes, wenn auch meist geringes, Speicherproblem darstellt, insbesondere dann, wenn eine lange lebende Closure Verweise auf große oder unnötige Daten enthält. Da die Closure die eigentliche Variable und nicht einen kopierten Wert speichert, sieht sie stets den aktuellen, aktualisierten Zustand. Das ist dieselbe zugrunde liegende Regel, die dafür sorgte, dass das Debounce-Beispiel korrekt funktionierte und dass die var-Schleife 3, 3, 3 ausgibt – ein und dasselbe Mechanismus, der in einem Kontext als nützliche Eigenschaft und in einem anderen als Fehler auftritt.
Der Mechanismus verstehen ist besser als die Definition auswendig lernen
Die Lehrbuchformulierung „Eine Schließung ist eine Funktion in Verbindung mit ihrem lexikalischen Umfeld“ ist technisch korrekt, wird aber nur selten verstanden, solange man nicht mehrmals manuell die Scope-Kette nachverfolgt und genau beobachtet, wo die Abfrage endet. Sobald das Nachverfolgen dieser Kette zur Gewohnheit wird, erfordern Schließungen überhaupt keine besondere Erklärung mehr. Sie sind einfach eine gewöhnliche Folge davon, wie Scope bereits immer funktioniert hat – angewandt auf eine Funktion, die zufällig länger existiert als das Umfeld, in dem sie erstellt wurde.
Verwandte Literatur
- Was Node.js Native TypeScript-Support tatsächlich kann und nicht kann – Dieser Artikel erklärt, wie Node.js .ts-Dateien durch Entfernung von Typinformationen nativ ausführt, warum er die Typüberprüfung überspringt und wann dennoch ein echter Build-Schritt erforderlich ist.