Was ist eine Closure in JavaScript?
| |
makeCounter läuft einmal, gibt eine Funktion zurück, und damit ist es erledigt: Der Stack-Frame existiert nicht mehr. Trotzdem liest und aktualisiert jeder Aufruf von counter() weiterhin count, eine Variable, die innerhalb einer bereits beendeten Funktion deklariert wurde. count wird nicht auf 0 zurückgesetzt und nicht kopiert. Es ist dieselbe Variable, immer noch lebendig, drei Aufrufe später.
Das ist eine Closure: eine Funktion gepaart mit einer Referenz auf die Variablen, die in ihrem Scope lagen, als sie definiert wurde. Kein Schnappschuss ihrer Werte: eine lebende Referenz. Ändert etwas anderes count, sieht der Counter den neuen Wert.
Warum überlebt die Variable, nachdem die Funktion zurückkehrt?
JavaScript löst Variablennamen lexikalisch auf, nach dem Ort, an dem sie im Quellcode stehen, nicht danach, wer die Funktion aufruft. Für count += 1 innerhalb der inneren Funktion sucht die Engine nach außen durch die umschließenden Scopes, bis sie eine Deklaration findet: count in makeCounter. Diese Verknüpfung wird dort festgelegt, wo die innere Funktion definiert ist, nicht dort, wo sie aufgerufen wird.
Normalerweise kann nichts mehr auf die lokalen Variablen einer Funktion zugreifen, sobald sie zurückkehrt, und der Garbage Collector gibt sie frei. Eine Closure ist die Ausnahme. Die zurückgegebene Funktion hält eine Referenz auf den Scope, in dem sie erstellt wurde, sodass dieser Scope so lange lebendig bleibt wie die Funktion selbst. Rufen Sie makeCounter() erneut auf, erhalten Sie einen zweiten Counter mit seinem eigenen count in seinem eigenen Scope:
| |
Jede Funktion, die Sie in JavaScript erstellen, schließt über ihren umgebenden Scope, ob Sie das nutzen oder nicht. Normalerweise überlebt nichts den Aufruf, der sie erstellt hat, sodass Sie es nie bemerken. Es wird relevant, wenn Sie die innere Funktion zurückgeben, sie weitergeben oder speichern: Sie läuft dann weiter, auch nachdem der Scope des übergeordneten Aufrufs sonst verschwunden wäre.
Wie erstellt man private Variablen mit einer Closure?
Der Counter zeigt bereits den wichtigsten Anwendungsfall: Daten, auf die nur die zurückgegebene Funktion zugreifen kann. Nichts außerhalb von makeCounter kann count lesen oder setzen. Es gibt keine Property dafür und nichts, was sich über die Konsole untersuchen lässt. Sie erhalten die Datenkapselung eines privaten Klassenfelds, aufgebaut aus einem einzigen Funktionsaufruf:
| |
Alle drei Methoden schließen über dasselbe balance, teilen es sich also. Externer Code kann es nur über die Methoden erreichen, die Sie bewusst freigegeben haben. Das ist das Modul-Pattern: privaten Zustand und eine öffentliche Schnittstelle in einem einzigen Funktionsaufruf einschließen und nur die Schnittstelle zurückgeben.
Wie memoisiert man eine Funktion mit einer Closure?
Schließen Sie über eine Map, und Sie haben einen Memoization-Wrapper mit einem cache, der von jedem Aufruf der zurückgegebenen Funktion geteilt wird:
| |
Beide Beispiele folgen derselben Form: Sie rufen eine Funktion auf, die einen Zustand aufbaut, und erhalten dafür eine oder mehrere Funktionen, die ihn weiter nutzen. Der Cache hier verwirft nie etwas. Memoisieren Sie damit also keine Funktion, die unbegrenzt viele unterschiedliche Eingaben sieht. Python-Decorators beruhen auf demselben Mechanismus: Ein Decorator schließt über die Funktion, die er umschließt, und gibt einen Ersatz zurück, der sie trotzdem noch aufrufen kann.
Warum bricht var in einer Schleife Closures?
Das ist der Closure-Fehler, dem fast jeder einmal begegnet:
| |
Sie wollten 0 1 2. Sie haben 3 3 3 bekommen. var hat Funktions-Scope, keinen Block-Scope, also existiert genau ein i für die gesamte Schleife, und alle drei Callbacks schließen darüber. Wenn eine von ihnen läuft, ist die Schleife bereits beendet, und i ist 3.
Deklarieren Sie es mit let, und der Bug verschwindet:
| |
let hat Block-Scope, und eine for-Schleife mit let im Kopf erzeugt für jede Iteration ein eigenes Binding. Drei getrennte i. Jeder Callback schließt über das seiner eigenen Iteration.
Vor let war die Lösung eine IIFE, die pro Iteration einen neuen Scope erzwang, indem sie i als Parameter übergab:
| |
Das finden Sie noch in älterem Code. Schreiben Sie es heute nicht mehr so: Setzen Sie let in den Schleifenkopf.
Können Closures Memory Leaks verursachen?
Ja. Eine Closure hält die tatsächliche lexikalische Umgebung, in der ihre Variablen leben, solange die Closure selbst erreichbar ist. Hängen Sie eine an etwas Langlebiges, wie einen DOM-Event-Listener, ein Array auf Modulebene oder einen Cache ohne Eviction, bleibt alles, was diese Umgebung mitträgt, ebenfalls im Speicher.
Der häufige Fall: Ein Listener an einem DOM-Knoten schließt über ein großes Objekt, das der Handler kaum nutzt. Der Knoten wird aus der Seite entfernt, der Listener wird nie entfernt, und sowohl Knoten als auch Objekt bleiben im Speicher, weil die Closure sie weiterhin referenziert. Rufen Sie removeEventListener auf, wenn das Element verschwindet. Und halten Sie Closures eng: Braucht ein Callback nur ein Feld, schließen Sie über dieses Feld, nicht über das ganze Objekt.
Die zweite Kostenquelle ist leiser. Eine Methode, die innerhalb eines Konstruktors definiert wird statt auf dem Prototype, wird für jede Instanz mit ihrer eigenen Closure neu erzeugt, statt zwischen allen geteilt zu werden. Fängt die innere Funktion nichts von der äußeren ein, verschachteln Sie sie nicht.
Wann sollten Sie eine Closure verwenden?
Sie haben wahrscheinlich schon welche benutzt. Ein Debounce oder Throttle in JavaScript gibt einen Wrapper zurück, der über eine timeoutId schließt, die zwischen Aufrufen bestehen bleibt — so bricht er den vorherigen, noch ausstehenden Aufruf ab. Jeder Callback, den Sie an setTimeout oder einen Promise-Handler übergeben und der eine Variable aus der planenden Funktion liest, funktioniert genauso: Die Event-Loop führt ihn lange nach dem Ende dieser Funktion aus.
Setzen Sie eine Closure bewusst ein, wenn Sie Zustand brauchen, der zwischen Aufrufen bestehen bleibt, aber nicht als Property offengelegt werden soll: ein Counter, ein Cache, ein Debounce-Timer, eine einmal konfigurierte und später aufgerufene Funktion. Für ein zustandsloses Utility, das nur seine Argumente umformt, lassen Sie den Wrapper weg. Es gibt nichts, worüber geschlossen werden müsste, und die Verschachtelung fügt einen Scope hinzu, über den Sie ohne jeden Nutzen nachdenken müssen.