Speicherlecks bei getrennten Fenstern

Schwierige Speicherlecks aufgrund von getrennten Fenstern finden und beheben

Bartek Nowierski
Bartek Nowierski

Was ist ein Speicherleck in JavaScript?

Ein Speicherleck ist eine unbeabsichtigte Erhöhung der von einer Anwendung verwendeten Arbeitsspeichermenge im Laufe der Zeit. In JavaScript treten Speicherlecks auf, wenn Objekte nicht mehr benötigt werden, aber weiterhin von Funktionen oder anderen Objekten referenziert werden. Diese Verweise verhindern, dass die nicht benötigten Objekte vom Garbage Collector wiederverwendet werden.

Die Aufgabe des Garbage Collectors besteht darin, Objekte zu identifizieren und wiederzuverwenden, die von der Anwendung nicht mehr erreichbar sind. Das funktioniert auch dann, wenn Objekte auf sich selbst verweisen oder sich zyklisch gegenseitig referenzieren. Sobald keine Verweise mehr vorhanden sind, über die eine Anwendung auf eine Gruppe von Objekten zugreifen könnte, kann die Garbage Collection ausgeführt werden.

let A = {};
console.log(A); // local variable reference

let B = {A}; // B.A is a second reference to A

A = null; // unset local variable reference

console.log(B.A); // A can still be referenced by B

B.A = null; // unset B's reference to A

// No references to A are left. It can be garbage collected.

Eine besonders heikle Art von Speicherleck tritt auf, wenn eine Anwendung auf Objekte verweist, die einen eigenen Lebenszyklus haben, z. B. DOM-Elemente oder Pop-up-Fenster. Es ist möglich, dass diese Arten von Objekten ohne Wissen der Anwendung nicht mehr verwendet werden. Das bedeutet, dass der Anwendungscode möglicherweise die einzigen verbleibenden Verweise auf ein Objekt enthält, das andernfalls durch die Garbage Collection gelöscht werden könnte.

Was ist ein separates Fenster?

Im folgenden Beispiel enthält eine Anwendung zum Ansehen von Präsentationen Schaltflächen zum Öffnen und Schließen eines Pop-ups für die Notizen des Vortragenden. Angenommen, ein Nutzer klickt auf Notizen anzeigen und schließt dann das Pop-up-Fenster direkt, anstatt auf die Schaltfläche Notizen ausblenden zu klicken. Die Variable notesWindow enthält dann weiterhin einen Verweis auf das Pop-up, auf das zugegriffen werden kann, obwohl es nicht mehr verwendet wird.

<button id="show">Show Notes</button>
<button id="hide">Hide Notes</button>
<script type="module">
  let notesWindow;
  document.getElementById('show').onclick = () => {
    notesWindow = window.open('/presenter-notes.html');
  };
  document.getElementById('hide').onclick = () => {
    if (notesWindow) notesWindow.close();
  };
</script>

Dies ist ein Beispiel für ein separates Fenster. Das Pop-up-Fenster wurde geschlossen, aber unser Code enthält eine Referenz darauf, die verhindert, dass der Browser es zerstören und den Arbeitsspeicher zurückfordern kann.

Wenn eine Seite window.open() aufruft, um ein neues Browserfenster oder einen neuen Tab zu erstellen, wird ein Window-Objekt zurückgegeben, das das Fenster oder den Tab darstellt. Auch nachdem ein solches Fenster geschlossen wurde oder der Nutzer es verlassen hat, kann das von window.open() zurückgegebene Window-Objekt weiterhin verwendet werden, um auf Informationen zuzugreifen. Dies ist eine Art von getrenntem Fenster: Da JavaScript-Code weiterhin potenziell auf Eigenschaften des geschlossenen Window-Objekts zugreifen kann, muss es im Arbeitsspeicher gehalten werden. Wenn das Fenster viele JavaScript-Objekte oder Iframes enthielt, kann dieser Speicher erst wieder freigegeben werden, wenn keine JavaScript-Referenzen mehr zu den Eigenschaften des Fensters vorhanden sind.

Mit den Chrome-Entwicklertools wird gezeigt, wie ein Dokument nach dem Schließen eines Fensters beibehalten werden kann.

Dasselbe Problem kann auch bei der Verwendung von <iframe>-Elementen auftreten. Iframes verhalten sich wie verschachtelte Fenster, die Dokumente enthalten. Die contentWindow-Property bietet Zugriff auf das enthaltene Window-Objekt, ähnlich wie der von window.open() zurückgegebene Wert. JavaScript-Code kann eine Referenz auf die contentWindow oder contentDocument eines Iframes beibehalten, auch wenn der Iframe aus dem DOM entfernt wird oder seine URL geändert wird. Dadurch wird verhindert, dass das Dokument durch die Garbage Collection gelöscht wird, da weiterhin auf seine Eigenschaften zugegriffen werden kann.

Demonstration, wie ein Ereignishandler das Dokument eines iFrames beibehalten kann, auch wenn der iFrame zu einer anderen URL weitergeleitet wird.

Wenn eine Referenz auf die document in einem Fenster oder iFrame aus JavaScript beibehalten wird, wird dieses Dokument im Arbeitsspeicher gehalten, auch wenn das enthaltene Fenster oder der iFrame zu einer neuen URL weitergeleitet wird. Das kann besonders problematisch sein, wenn das JavaScript, das diese Referenz enthält, nicht erkennt, dass das Fenster/der Frame zu einer neuen URL weitergeleitet wurde, da es nicht weiß, wann es die letzte Referenz ist, die ein Dokument im Arbeitsspeicher hält.

Wie getrennte Fenster zu Speicherlecks führen

Wenn Sie mit Fenstern und Iframes in derselben Domain wie die Hauptseite arbeiten, ist es üblich, auf Ereignisse zu warten oder über Dokumentgrenzen hinweg auf Properties zuzugreifen. Sehen wir uns beispielsweise noch einmal eine Variante des Beispiels für die Präsentationsanzeige vom Anfang dieses Leitfadens an. Der Betrachter öffnet ein zweites Fenster, um die Vortragsnotizen anzuzeigen. Im Fenster für die Vortragsnotizen wird auf click-Ereignisse gewartet, um zur nächsten Folie zu wechseln. Wenn der Nutzer dieses Notizenfenster schließt, hat das im ursprünglichen übergeordneten Fenster ausgeführte JavaScript weiterhin vollen Zugriff auf das Dokument mit den Sprechernotizen:

<button id="notes">Show Presenter Notes</button>
<script type="module">
  let notesWindow;
  function showNotes() {
    notesWindow = window.open('/presenter-notes.html');
    notesWindow.document.addEventListener('click', nextSlide);
  }
  document.getElementById('notes').onclick = showNotes;

  let slide = 1;
  function nextSlide() {
    slide += 1;
    notesWindow.document.title = `Slide  ${slide}`;
  }
  document.body.onclick = nextSlide;
</script>

Angenommen, wir schließen das Browserfenster, das von showNotes() oben erstellt wurde. Es gibt keinen Ereignishandler, der erkennt, dass das Fenster geschlossen wurde. Daher wird unser Code nicht darüber informiert, dass er alle Verweise auf das Dokument bereinigen soll. Die Funktion nextSlide() ist weiterhin „aktiv“, da sie auf der Hauptseite als Klick-Handler gebunden ist. Da nextSlide einen Verweis auf notesWindow enthält, wird weiterhin auf das Fenster verwiesen und es kann nicht durch die Garbage Collection gelöscht werden.

Abbildung, wie Verweise auf ein Fenster verhindern, dass es nach dem Schließen vom Garbage Collector erfasst wird.

Es gibt eine Reihe weiterer Szenarien, in denen Verweise versehentlich beibehalten werden, sodass getrennte Fenster nicht für die Garbage Collection infrage kommen:

  • Ereignishandler können im ursprünglichen Dokument eines Iframes registriert werden, bevor der Frame zur gewünschten URL weitergeleitet wird. Dies führt zu versehentlichen Verweis auf das Dokument und den Iframe, die nach dem Bereinigen anderer Verweise bestehen bleiben.

  • Ein speicherintensives Dokument, das in einem Fenster oder IFrame geladen wird, kann versehentlich lange im Arbeitsspeicher gehalten werden, nachdem eine neue URL aufgerufen wurde. Das liegt oft daran, dass die übergeordnete Seite Verweise auf das Dokument beibehält, um das Entfernen von Listenern zu ermöglichen.

  • Wenn ein JavaScript-Objekt an ein anderes Fenster oder einen anderen IFrame übergeben wird, enthält die Prototypkette des Objekts Verweise auf die Umgebung, in der es erstellt wurde, einschließlich des Fensters, in dem es erstellt wurde. Es ist also genauso wichtig, Verweise auf Objekte aus anderen Fenstern wie auf die Fenster selbst zu vermeiden.

    index.html:

    <script>
      let currentFiles;
      function load(files) {
        // this retains the popup:
        currentFiles = files;
      }
      window.open('upload.html');
    </script>
    

    upload.html:

    <input type="file" id="file" />
    <script>
      file.onchange = () => {
        parent.load(file.files);
      };
    </script>
    

Speicherlecks durch getrennte Fenster erkennen

Das kann schwierig sein. Es ist oft schwierig, diese Probleme isoliert zu reproduzieren, insbesondere wenn mehrere Dokumente oder Fenster beteiligt sind. Was die Sache noch komplizierter macht: Die Prüfung potenzieller geleakter Referenzen kann zusätzliche Referenzen erstellen, die verhindern, dass die geprüften Objekte vom Garbage Collector erfasst werden. Zu diesem Zweck ist es sinnvoll, mit Tools zu beginnen, die diese Möglichkeit ausdrücklich nicht bieten.

Ein guter Ausgangspunkt für die Behebung von Speicherproblemen ist ein Heap-Snapshot. Dies bietet einen aktuellen Überblick über den Arbeitsspeicher, der derzeit von einer Anwendung verwendet wird – alle Objekte, die erstellt, aber noch nicht durch die Garbage Collection gelöscht wurden. Heap-Snapshots enthalten nützliche Informationen zu Objekten, einschließlich ihrer Größe und einer Liste der Variablen und Schließungen, die auf sie verweisen.

Screenshot eines Heap-Snapshots in den Chrome-Entwicklertools mit den Referenzen, die ein großes Objekt beibehalten
Ein Heap-Snapshot mit den Referenzen, die ein großes Objekt beibehalten.

Wenn Sie einen Heap-Snapshot aufzeichnen möchten, rufen Sie in den Chrome-Entwicklertools den Tab Arbeitsspeicher auf und wählen Sie in der Liste der verfügbaren Profilertypen Heap-Snapshot aus. Nach Abschluss der Aufzeichnung werden in der Ansicht Zusammenfassung die aktuellen Objekte im Arbeitsspeicher nach Konstruktor gruppiert angezeigt.

Demonstration der Erstellung eines Heap-Snapshots in den Chrome-Entwicklertools.

Die Analyse von Heap-Dumps kann eine entmutigende Aufgabe sein und es kann ziemlich schwierig sein, im Rahmen des Debuggings die richtigen Informationen zu finden. Die Chromium-Entwickler yossik@ und peledni@ haben dazu ein eigenständiges Heap Cleaner-Tool entwickelt, mit dem sich ein bestimmter Knoten wie ein getrenntes Fenster hervorheben lässt. Wenn Sie den Heap-Bereiniger auf einen Trace anwenden, werden andere unnötige Informationen aus dem Zeitachsendiagramm entfernt. Dadurch wird der Trace übersichtlicher und leichter zu lesen.

Arbeitsspeicher programmatisch messen

Heap-Snapshots bieten viele Details und eignen sich hervorragend, um herauszufinden, wo Lecks auftreten. Die Erstellung eines Heap-Snapshots ist jedoch ein manueller Vorgang. Eine weitere Möglichkeit, nach Speicherlecks zu suchen, besteht darin, die aktuell verwendete JavaScript-Heap-Größe über die performance.memory API abzurufen:

Screenshot eines Bereichs der Benutzeroberfläche der Chrome DevTools
Die verwendete JS-Heap-Größe in DevTools prüfen, während ein Pop-up erstellt, geschlossen und ohne Verweis gelöscht wird.

Die performance.memory API liefert nur Informationen zur Größe des JavaScript-Heaps. Das bedeutet, dass der Speicher, der vom Dokument und den Ressourcen des Pop-ups verwendet wird, nicht berücksichtigt wird. Um ein vollständiges Bild zu erhalten, müssten wir die neue performance.measureUserAgentSpecificMemory() API verwenden, die derzeit in Chrome getestet wird.

Lösungen zur Vermeidung von Lecks durch abgelöste Fenster

Die beiden häufigsten Fälle, in denen getrennte Fenster zu Speicherlecks führen, sind, wenn das übergeordnete Dokument Verweise auf ein geschlossenes Pop-up oder einen entfernten iFrame beibehält und wenn eine unerwartete Navigation in einem Fenster oder iFrame dazu führt, dass Ereignishandler nie deregistriert werden.

Beispiel: Pop-up schließen

Im folgenden Beispiel werden zwei Schaltflächen verwendet, um ein Pop-up-Fenster zu öffnen und zu schließen. Damit die Schaltfläche Pop-up schließen funktioniert, wird ein Verweis auf das geöffnete Pop-up-Fenster in einer Variablen gespeichert:

<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = window.open('/login.html');
  };
  close.onclick = () => {
    popup.close();
  };
</script>

Auf den ersten Blick scheint der Code oben gängige Fallstricke zu vermeiden: Es werden keine Verweise auf das Pop-up-Dokument beibehalten und es werden keine Ereignis-Handler für das Pop-up-Fenster registriert. Sobald jedoch auf die Schaltfläche Pop-up öffnen geklickt wurde, verweist die Variable popup auf das geöffnete Fenster. Auf diese Variable kann über den Klickhandler der Schaltfläche Pop-up schließen zugegriffen werden. Sofern popup nicht neu zugewiesen oder der Klick-Handler entfernt wird, kann er aufgrund der enthaltenen Referenz auf popup nicht durch die Garbage Collection beseitigt werden.

Lösung: Verweise aufheben

Variablen, die auf ein anderes Fenster oder dessen Dokument verweisen, werden im Arbeitsspeicher beibehalten. Da Objekte in JavaScript immer Verweise sind, wird die Referenz auf das ursprüngliche Objekt entfernt, wenn Variablen einen neuen Wert zugewiesen wird. Wenn wir Verweise auf ein Objekt aufheben möchten, können wir diesen Variablen den Wert null neu zuweisen.

Wenn wir das auf das vorherige Pop-up-Beispiel anwenden, können wir den Handler für die Schaltfläche „Schließen“ so ändern, dass der Verweis auf das Pop-up-Fenster aufgehoben wird:

let popup;
open.onclick = () => {
  popup = window.open('/login.html');
};
close.onclick = () => {
  popup.close();
  popup = null;
};

Das hilft, wirft aber ein weiteres Problem auf, das speziell für mit open() erstellte Fenster gilt: Was passiert, wenn der Nutzer das Fenster schließt, anstatt auf unsere benutzerdefinierte Schaltfläche zum Schließen zu klicken? Was passiert, wenn der Nutzer im geöffneten Fenster andere Websites aufruft? Ursprünglich schien es ausreichend, die popup-Referenz zurückzusetzen, wenn auf die Schaltfläche „Schließen“ geklickt wurde. Es gibt jedoch weiterhin einen Speicherleck, wenn Nutzer das Fenster nicht über diese Schaltfläche schließen. Um dieses Problem zu beheben, müssen diese Fälle erkannt werden, damit in der Folge die Verweisdaten gelöscht werden.

Lösung: Überwachen und entsorgen

In vielen Fällen hat das JavaScript, das für das Öffnen von Fenstern oder das Erstellen von Frames verantwortlich ist, keine ausschließliche Kontrolle über ihren Lebenszyklus. Pop-ups können vom Nutzer geschlossen werden. Wenn der Nutzer zu einem neuen Dokument wechselt, kann das zuvor in einem Fenster oder Frame enthaltene Dokument getrennt werden. In beiden Fällen löst der Browser ein pagehide-Ereignis aus, um anzuzeigen, dass das Dokument entladen wird.

Mit dem Ereignis pagehide können geschlossene Fenster und das Verlassen des aktuellen Dokuments erkannt werden. Es gibt jedoch einen wichtigen Vorbehalt: Alle neu erstellten Fenster und Iframes enthalten ein leeres Dokument, das dann asynchron zur angegebenen URL weitergeleitet wird, sofern diese angegeben ist. Daher wird kurz nach dem Erstellen des Fensters oder Frames, kurz bevor das Zieldokument geladen wird, ein erstes pagehide-Ereignis ausgelöst. Da unser Code zum Bereinigen der Referenz ausgeführt werden muss, wenn das Ziel-Dokument entladen wird, müssen wir dieses erste pagehide-Ereignis ignorieren. Es gibt verschiedene Möglichkeiten, dies zu tun. Die einfachste besteht darin, Ereignisse vom Typ „Seite ausgeblendet“ zu ignorieren, die von der about:blank-URL des ursprünglichen Dokuments stammen. In unserem Pop-up-Beispiel würde das so aussehen:

let popup;
open.onclick = () => {
  popup = window.open('/login.html');

  // listen for the popup being closed/exited:
  popup.addEventListener('pagehide', () => {
    // ignore initial event fired on "about:blank":
    if (!popup.location.host) return;

    // remove our reference to the popup window:
    popup = null;
  });
};

Diese Methode funktioniert nur für Fenster und Frames, die denselben effektiven Ursprung wie die übergeordnete Seite haben, auf der der Code ausgeführt wird. Beim Laden von Inhalten aus einer anderen Quelle sind sowohl location.host als auch das pagehide-Ereignis aus Sicherheitsgründen nicht verfügbar. In der Regel sollten Sie Verweise auf andere Ursprünge vermeiden. In den seltenen Fällen, in denen dies erforderlich ist, können Sie die Properties window.closed oder frame.isConnected überwachen. Wenn sich diese Eigenschaften ändern, um ein geschlossenes Fenster oder einen entfernten Iframe anzugeben, sollten Sie alle Verweise darauf aufheben.

let popup = window.open('https://example.com');
let timer = setInterval(() => {
  if (popup.closed) {
    popup = null;
    clearInterval(timer);
  }
}, 1000);

Lösung: WeakRef verwenden

In JavaScript wird seit Kurzem die WeakRef unterstützt, eine neue Möglichkeit, auf Objekte zu verweisen, die die Garbage Collection ermöglicht. Ein WeakRef, das für ein Objekt erstellt wurde, ist keine direkte Referenz, sondern ein separates Objekt, das eine spezielle .deref()-Methode bereitstellt, die eine Referenz auf das Objekt zurückgibt, solange es nicht durch die Garbage Collection gelöscht wurde. Mit WeakRef können Sie auf den aktuellen Wert eines Fensters oder Dokuments zugreifen und es gleichzeitig für das Garbage Collection-System freigeben. Anstatt einen Verweis auf das Fenster beizubehalten, der bei Ereignissen wie pagehide oder Properties wie window.closed manuell zurückgesetzt werden muss, wird der Zugriff auf das Fenster bei Bedarf abgerufen. Wenn das Fenster geschlossen ist, kann es vom Garbage Collector erfasst werden, wodurch die .deref()-Methode undefined zurückgibt.

<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = new WeakRef(window.open('/login.html'));
  };
  close.onclick = () => {
    const win = popup.deref();
    if (win) win.close();
  };
</script>

Wenn Sie WeakRef zum Zugriff auf Fenster oder Dokumente verwenden, ist die Referenz in der Regel noch für kurze Zeit verfügbar, nachdem das Fenster geschlossen oder der IFrame entfernt wurde. Das liegt daran, dass WeakRef so lange einen Wert zurückgibt, bis das zugehörige Objekt durch die Garbage Collection entfernt wurde. Dieser Vorgang erfolgt in JavaScript asynchron und in der Regel während der Inaktivität. Glücklicherweise wird beim Prüfen auf getrennte Fenster im Bereich Arbeitsspeicher der Chrome-Entwicklertools durch das Erstellen eines Heap-Snapshots die Garbage Collection ausgelöst und das Fenster mit schwacher Referenz wird freigegeben. Es ist auch möglich zu prüfen, ob ein über WeakRef referenziertes Objekt aus JavaScript freigegeben wurde. Dazu kannst du entweder prüfen, ob deref() undefined zurückgibt, oder die neue FinalizationRegistry API verwenden:

let popup = new WeakRef(window.open('/login.html'));

// Polling deref():
let timer = setInterval(() => {
  if (popup.deref() === undefined) {
    console.log('popup was garbage-collected');
    clearInterval(timer);
  }
}, 20);

// FinalizationRegistry API:
let finalizers = new FinalizationRegistry(() => {
  console.log('popup was garbage-collected');
});
finalizers.register(popup.deref());

Lösung: Über postMessage kommunizieren

Wenn wir erkennen, dass Fenster geschlossen werden oder ein Dokument durch die Navigation entfernt wird, können wir Handler entfernen und Verweise zurücksetzen, damit getrennte Fenster vom Garbage Collector gelöscht werden können. Diese Änderungen sind jedoch spezifische Korrekturen für ein Problem, das manchmal grundlegender ist: die direkte Verknüpfung zwischen Seiten.

Es gibt einen ganzheitlicheren alternativen Ansatz, der veraltete Verweise zwischen Fenstern und Dokumenten vermeidet: Durch Begrenzung der dokumentübergreifenden Kommunikation auf postMessage() wird eine Trennung hergestellt. Denken Sie an unser Beispiel für die Foliennotizen zurück: Funktionen wie nextSlide() haben das Notizenfenster direkt aktualisiert, indem sie darauf verwiesen und den Inhalt manipuliert haben. Stattdessen könnte die Hauptseite die erforderlichen Informationen asynchron und indirekt über postMessage() an das Notizenfenster weitergeben.

let updateNotes;
function showNotes() {
  // keep the popup reference in a closure to prevent outside references:
  let win = window.open('/presenter-view.html');
  win.addEventListener('pagehide', () => {
    if (!win || !win.location.host) return; // ignore initial "about:blank"
    win = null;
  });
  // other functions must interact with the popup through this API:
  updateNotes = (data) => {
    if (!win) return;
    win.postMessage(data, location.origin);
  };
  // listen for messages from the notes window:
  addEventListener('message', (event) => {
    if (event.source !== win) return;
    if (event.data[0] === 'nextSlide') nextSlide();
  });
}
let slide = 1;
function nextSlide() {
  slide += 1;
  // if the popup is open, tell it to update without referencing it:
  if (updateNotes) {
    updateNotes(['setSlide', slide]);
  }
}
document.body.onclick = nextSlide;

Auch wenn die Fenster sich weiterhin gegenseitig referenzieren müssen, behält keines der Fenster eine Referenz auf das aktuelle Dokument aus einem anderen Fenster. Ein Ansatz mit Nachrichtenübertragung fördert auch Designs, bei denen Fensterverweise an einem einzigen Ort gespeichert werden. Das bedeutet, dass nur eine einzige Referenz aufgehoben werden muss, wenn Fenster geschlossen oder verlassen werden. Im obigen Beispiel behält nur showNotes() eine Referenz auf das Notizenfenster bei und verwendet das Ereignis pagehide, um dafür zu sorgen, dass die Referenz bereinigt wird.

Lösung: Verweise mit noopener vermeiden

Wenn ein Pop-up-Fenster geöffnet wird, mit dem Ihre Seite nicht kommunizieren oder das sie nicht steuern muss, können Sie möglicherweise verhindern, dass Sie eine Referenz auf das Fenster erhalten. Das ist besonders nützlich, wenn Sie Fenster oder Iframes erstellen, in denen Inhalte von einer anderen Website geladen werden. In diesen Fällen wird für window.open() die Option "noopener" akzeptiert, die genau wie das Attribut rel="noopener" für HTML-Links funktioniert:

window.open('https://example.com/share', null, 'noopener');

Wenn die Option "noopener" verwendet wird, gibt window.open() null zurück. So kann kein Verweis auf das Pop-up versehentlich gespeichert werden. Außerdem wird verhindert, dass das Pop-up-Fenster einen Verweis auf sein übergeordnetes Fenster erhält, da die Eigenschaft window.opener null ist.

Feedback

Wir hoffen, dass Ihnen einige der Vorschläge in diesem Artikel dabei helfen, Speicherlecks zu finden und zu beheben. Wenn Sie eine andere Methode zum Debuggen von getrennten Fenstern haben oder dieser Artikel Ihnen geholfen hat, Lecks in Ihrer App zu finden, lassen Sie es mich wissen. Sie finden mich auf Twitter unter @_developit.