Schwierige Speicherlecks aufgrund von getrennten Fenstern finden und beheben
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.
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.
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.
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.
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.
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:
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.