Startseite / Artikel

Wie V8-Closure-Kontexte Speicher außerhalb dessen, was eine Funktion verwendet, beibehalten

V8 erstellt für jeden Aufruf einen gemeinsamen Kontext pro jeder äußeren Variable, die von einer inneren Funktion erwähnt wird – sodass ungenutzte Variablen sowie dauerhafte Zuhörer große Werte speichern können.

Verlag: REACTAPP.TOP8 Min. Lesezeit1849 Wörter

Quellen: Interviewer: You have 5 years of experience and still don’t know how closures work internally

Prüfen Sie es, rezitieren Sie es nicht: [[Scopes]]

In einem Vorstellungsgespräch für die Swiggy-Frontend-Entwicklung wurde ein kurzer DOM-Auszug auf die Tafel geschrieben. Der Kandidat verfügte über jahrelange JavaScript-Erfahrung und beantwortete die erste Frage mit der üblichen Erklärung. Die weitere Frage zerstörte diese Erklärung genauso, wie es bei den meisten praktisch tätigen Entwicklern der Fall ist.

Hier ist der Ausgangsauszug:

function update() {
  const elem = document.getElementById("root");
  const allH1s = document.querySelectorAll("h1");

  function handleClick() {
    console.dir(handleClick);
  }

  elem.addEventListener("click", handleClick);
}

update();

Nachdem update() zurückgegeben wurde, bleibt der Klick-Listener auf #root. Umfasst handleClick weiterhin allH1s und hält somit die gesamte NodeList am Leben?

Die offizielle Antwort lautet ja: Eine Schließung behält ihre lexikalische Umgebung bei, sodass alles im Scope von update – einschließlich elem und allH1s – bestehen bleibt, solange der Listener angehängt ist. Diese Antwort beschreibt eher die Spezifikationssprache als das, was Chrome tatsächlich beibehält. Der Interviewer bat darum, die DevTools zu öffnen und nachzusehen.

Jedes Funktionsobjekt in Chrome stellt eine interne [[Scopes]]-Liste der Scopes bereit, die die Schließung weiterhin enthält. console.dir zeigt diese an. Der Handler protokolliert sich bei jedem Klick, sodass Sie Folgendes tun können:

  1. Eine Seite öffnen, die <h1 id="root"> enthält (oder eine erstellen), den Codeausschnitt einfügen und ausführen.
  2. Auf die Überschrift klicken, damit console.dir(handleClick) die Funktion ausgibt.
  3. [[Scopes]] erweitern und anschließend Closure (update).

Was angezeigt wird, ist weder allH1s noch elem. Die einzige außenliegende Bindung, die beibehalten wird, ist handleClick selbst – und das nur, weil der Body diesen Namen in console.dir(handleClick) referenziert. Ändert man den Body in console.log("clicked"), verschwindet der Eintrag „Closure (update)“; [[Scopes]] zeigt dann nur Global.

Daher wird die NodeList nicht vom Handler gehalten. Sobald update() zurückgegeben wird, gibt es nichts mehr, das sie benennt, und der Sammler kann sie zurückfordern. Allein die Inspektorenansicht ist kein Beweis für den Speicherverbrauch – eine später durchgeführte gezwungene GC-Aktion bestätigt das zwar, aber sie widerlegt bereits die Behauptung, „der gesamte Scope werde erfasst.“

Dann wechselte der Kandidat zum anderen gängigen Modell: Schließungen speichern nur die Variablen, auf die sie verweisen, und handleClick nennt niemals allH1s. Der Interviewer bat um eine zusätzliche Zeile, ohne handleClick zu ändern.

Eine Zeile, die niemals ausgeführt wird

function update() {
  const elem = document.getElementById("root");
  const allH1s = document.querySelectorAll("h1");
  const printH1 = () => allH1s;   // never called

  function handleClick() {
    console.dir(handleClick);   // unchanged
  }

  elem.addEventListener("click", handleClick);
}

update();

printH1 ist eine ungenutzte Arrow-Funktion, die allH1s abruft. Nichts ruft sie auf, gibt sie zurück oder speichert sie. Führen Sie nach Hinzufügen dieselben Inspektionsschritte aus.

handleClick bleibt Zeichen für Zeichen unverändert, zeigt aber nun eine NodeList an, die er niemals abruft. Die Arrow-Funktion selbst fehlt in [[Scopes]] – nichts hat printH1 aufgefangen, wodurch die Bindung mit dem Aufruf verschwand – doch die von ihr erwähnte Variable überlebte im gemeinsamen Kontext.

„Whole scope“ versagt im ersten Fall. „Only what this function uses“ versagt im zweiten. V8s Regel bezieht sich auf den Aufruf, nicht auf jede Funktion für sich allein.

Die Regel, der V8 tatsächlich folgt

Wenn V8 update kompiliert, durchsucht es jede innere Funktion und vereint die Außenvariablen, auf die eine dieser Funktionen verweist. Diese Vereinigung wird zu einem einmal pro Aufruf erstellten Kontextobjekt, das im Heap zugewiesen wird. Variablen, die von keiner inneren Funktion erwähnt werden, bleiben auf dem Stack und verschwinden, wenn update zurückgegeben wird. Jede Schließung, die während dieses Aufrufs entsteht, verweist auf denselben Kontext. Es gibt keinen privaten „my captures“-Speicher pro Funktion.

Anwendung der Regel:

  • Fall 1: Unter den inneren Funktionen existiert nur handleClick; es verweist auf sich selbst. Kontext = { handleClick }. elem und allH1s bleiben stackbasiert und gehen verloren.
  • Fall 2: Die inneren Funktionen sind printH1 und handleClick. Die Vereinigung umfasst allH1s und handleClick. Kontext = { allH1s, handleClick }. Nichts verweist auf printH1, wodurch diese Bindung erlischt – doch der von ihr belegte Kontextplatz bleibt durch dasselbe Objekt handleClick am Leben.
  • Zwei Folgen dieser Regel führen zu tatsächlichen „Lecks“.

    Die Entscheidung wird beim Lesen des Codes getroffen, noch bevor etwas ausgeführt wird

    Die Struktur des Kontexts ist bereits zur Zeit der Parsing festgelegt. Der Interpreter wartet nicht ab, ob printH1 jemals konstruiert wird. Selbst wenn es in einer unmöglichen Abzweigung platziert wird, wird allH1s dennoch priorisiert:

    if (false) {
      const printH1 = () => allH1s;   // never even instantiated
    }
    // [[Scopes]] of handleClick: Closure (update) { allH1s: NodeList(3), handleClick }
    

    Die Ausführung dieser Variante unter Chrome 153 über das DevTools-Protokoll sowie die anschließende Erzwingung einer Garbage Collection lassen den NodeList weiterhin aktiv. Das Zuweisen von printH1 = null nach der Erstellung ändert nichts: Der Helper selbst hat niemals Speicher geblockt – vielmehr der gemeinsame Kontextspeicherplatz.

    Die Beibehaltung erfolgt gemäß dem am längsten lebenden sibling closure

    Deshalb bezog sich die Frage des Interviewers auf den Speicher. Verfolgen Sie die Blockierungen:

    [ <h1 id="root"> ]   (still in the document)
          ↓ event listener list holds
    [ handleClick ]   (the function object)
          ↓ [[Context]] points to the ONE context for this call
    [ { allH1s, handleClick } ]   (the shared context)
          ↓ allH1s slot holds
    [ NodeList(3) [h1#root, h1, h1] ]
          meanwhile
    [ printH1 ]   <- gone, it was on the stack
    

    printH1 existiert nicht mehr. Die dadurch verursachte Erhöhung – allH1s, die im Kontext vorhanden sind – überlebt es. handleClick blockiert den Kontext; die Listener-Liste des Elements blockiert handleClick; das Dokument blockiert das Element. Solange der Knoten nicht entfernt oder der Listener getrennt wird, bleiben der NodeList sowie die von ihm referenzierten h1-Knoten erreichbar.

    Beweisen Sie, dass es sich um Speicher handelt und nicht um ein Artefakt des Inspektors

    [[Scopes]] zeigt Referenzen an. Nur die Müllsammlung zeigt an, ob Objekte behalten werden. Chrome kann eine Sammlung erzwingen; WeakRef beobachtet ein Objekt, ohne es zu speichern. Einfügen:

    let ref;
    function update() {
      const elem = document.getElementById("root");
      const allH1s = document.querySelectorAll("h1");
      ref = new WeakRef(allH1s);            // observe, don't retain
      const printH1 = () => allH1s;         // the one line
      function handleClick() {
        console.dir(handleClick);
      }
      elem.addEventListener("click", handleClick);
    }
    update();
    

    Öffnen Sie das Speicherpanel, klicken Sie mehrmals auf „Müll sammeln“ und überprüfen Sie anschließend die Konsole:

    > ref.deref()
    ▶ NodeList(3) [h1#root, h1, h1]
    // still here after two forced GCs
    

    Nehmen Sie die Zeile printH1 entfernen, laden Sie neu und wiederholen Sie den Vorgang:

    > ref.deref()
    undefined
    // collected — nothing was holding it
    

    Diese beiden Ergebnisse unterscheiden sich durch einen nicht verwendeten Pfeil: Entweder wird der NodeList beim Zurückkehren von update zerstört, oder er bleibt so lange bestehen wie der Klick-Handler.

    DevTools können auch Objekte, die Sie protokolliert haben, festhalten. Wenn Sie vor dem Erzwingen des GC geklickt haben, wurde console.dir(handleClick) ausgeführt und die Konsole behielt die ausgeführte Funktion – sowie damit den Kontext – auch nachdem ein { once: true }-Listener entfernt wurde. Das Leeren der Konsole (oder das Vermeiden des Protokollierens des verdächtigen Elements) vor dem Vertrauen auf einen Heap-Snapshot ist wichtig, wenn man echte Speicherverluste aufspürt.

    Wo dies im echten Code Probleme verursacht

    Drei Überschriften sind nur ein Beispiel. Ersetzen Sie allH1s durch einen großen heruntergeladenen Datensatz, ein analysiertes Arbeitsblatt oder ein Array mit einer Million Zeilen. Ersetzen Sie printH1 durch einen Debug-Hilfsfunktion, einen ungenutzten Formatierer oder eine Callback-Funktion, die „für später aufbewahrt“ werden soll. Fügen Sie außerdem einen lang lebenden Listener hinzu – beispielsweise resize für window, einen Socket-message-Handler oder einen setInterval-Tick.

    function setup() {
      const rows = new Array(1e6).fill(0).map((_, i) => ({ i }));  // the big thing
      const debug = () => rows.length;        // mentions it, never called
    
      function onResize() {
        console.dir(onResize);              // never touches rows
      }
      window.addEventListener("resize", onResize);
    }
    setup();
    

    Nachdem setup() zurückgegeben hat und der GC ausgeführt wurde, zeigt onResize’s [[Scopes]] weiterhin das Array mit einer Million Elementen, und ein darauf angelegter WeakRef löst sich weiterhin auf. Der Resize-Handler belegt Speicher, den er nie benennt. Überprüfungen übersehen dieses Muster, weil die betreffende Funktion unschuldig erscheint und der dazu führende Code möglicherweise viel später hinzugefügt wurde.

    Die Lösung

    Jede der unten aufgeführten Optionen wurde auf dieselbe Weise getestet: [[Scopes]], erzwungener GC und anschließend WeakRef.deref().

    1. Vermeiden Sie verschachtelte Funktionen, die den großen äußeren Wert benennen

    Captures:

    const printH1 = () => allH1s;
    

    Nicht geeignet:

    const printH1 = (list) => list;
    

    Durch Übergeben des Wertes als Parameter wird eine äußere Referenz vermieden, sodass kein Kontextplatz entsteht. Noch besser ist es, ungenutzte Hilfsfunktionen zu löschen.

    2. Trennen Sie den Listener, wenn Sie fertig sind

    Der Kontext bleibt bestehen, solange handleClick aktiv ist. Unterbrechen Sie diese Verbindung:

    // any one of these frees the NodeList on the next GC
    elem.removeEventListener("click", handleClick);
    elem.addEventListener("click", handleClick, { once: true });
    elem.addEventListener("click", handleClick, { signal: controller.signal });
    controller.abort();
    

    Durch das Entfernen von #root aus dem Dokument wird dieser ebenfalls befreit. Diese Art von Leck neigt dazu, dauerhafte Ziele wie window, document, Sockets sowie lang lebende Wurzeln zu bevorzugen, da in der Regel nichts den Zuhörer entfernt.

    3. Wenn ein Geschwisterelement weiterhin Zugriff behalten muss, setzen Sie die äußere Bindung danach auf null

    let allH1s = document.querySelectorAll("h1");
    const printH1 = () => allH1s;
    // ...use it...
    allH1s = null;   // the context slot still exists, but now holds null
    

    Nach dem Setzen auf null listet [[Scopes]] weiterhin allH1s auf, doch der Wert ist null und die NodeList kann abgerufen werden. Es handelt sich um die weniger elegante Option, ist jedoch nützlich, wenn eine Umstrukturierung des Geschwisterelements derzeit nicht möglich ist.

    Ist das die Spezifikation oder V8?

    V8: Laut ECMAScript fängt eine Schließung ihr Umgebungsprotokoll ein und äußert sich nicht zur GC-Strategie. Ein konformes Engine kann alle im Gültigkeitsbereich liegenden Bindungen während der gesamten Laufzeit einer Schließung beibehalten; ein anderes könnte strenger sein als V8. „Nur die Variablen, auf die eine innere Funktion verweist“, ist V8s Optimierung. Deshalb entspricht „der gesamte Gültigkeitsbereich“ der Formulierung in den Spezifikationen und nicht dem, was Chrome, Edge, Node oder Deno in der Praxis beibehalten.

    Verlassen Sie sich weder auf das eine noch auf das andere Verhalten. Bewahren Sie große Werte in engen Gültigkeitsbereichen auf, schreiben Sie Hilfsfunktionen, die Argumente entgegennehmen anstelle von Referenzen auf äußere Funktionen, und geben Sie jedem langlebigen Zuhörer einen dokumentierten Punkt zur Trennung.

    Was der Interviewer getestet hat

    Nicht die Wörterbuchdefinition eines Closures. Entscheidend war es, zu erkennen, dass die „Capture“-Eigenschaft auf eine einzelne Aufrufinstanz beschränkt ist und nicht auf jede verschachtelte Funktion – Geschwisterfunktionen aus einem Aufruf teilen denselben Kontext, dessen Struktur bei der Parsing-Zeit festgelegt wird, die Aufrechterhaltung erfolgt gemäß dem am längsten lebenden Geschwisterelement, und ein erfahrener Entwickler öffnet DevTools anstelle eines Diagramms zu zitieren.

    Kernpunkte

    • Closures speichern weder ganze Kontexte noch nur die von ihnen selbst referenzierten Namen. Sie teilen pro Aufruf einen einzigen Kontext, der alle äußeren Variablen enthält, auf die jede innere Funktion verweist.
    • Diese Menge wird zur Parsing-Zeit ausgewählt. Sogar tote innere Funktionen sowie nutzlose Hilfsfunktionen tragen weiterhin zu den von ihnen referenzierten Namen bei.
    • Der Kontext besteht so lange wie das am längsten lebende Closure darin. Ein dauerhafter Zuhörer macht den gesamten Kontext dauerhaft.
  • Bestätigen Sie die Referenzen mit console.dir(fn) → [[Scopes]] sowie die Aufrechterhaltung der Referenzen mit WeakRef in Kombination mit „Memory“ → „Collect garbage“. Löschen Sie zunächst die Konsole, damit DevTools nicht als Referenzhalter fungiert.
  • Im Rahmen von Interview-Schleifen werden Personen belohnt, die das Verhalten des Engines mit der Identifizierung von Lecks in Verbindung bringen können. Die Fähigkeit, Umgebungsprotokolle zu beschreiben, ist weniger nützlich als das Wissen darüber, welches Panel geöffnet werden muss, welches Icon eine Sammlung von Daten auslöst und wie das Konsole-Logging selbst dazu beitragen kann, einen verdächtigen Graphen am Leben zu erhalten. Betrachten Sie [[Scopes]] als eine Karte der Referenzen und WeakRef in Kombination mit erzwungener GC als Beweis für die Aufrechterhaltung der Referenzen – gestalten Sie anschließend alles so um, dass die Antwort nicht mehr von Details der Optimierer abhängt.