Recherchez et corrigez les fuites de mémoire difficiles à détecter causées par des fenêtres dissociées.
Qu'est-ce qu'une fuite de mémoire en JavaScript ?
Une fuite de mémoire est une augmentation involontaire de la quantité de mémoire utilisée par une application au fil du temps. En JavaScript, des fuites de mémoire se produisent lorsque des objets ne sont plus nécessaires, mais qu'ils sont toujours référencés par des fonctions ou d'autres objets. Ces références empêchent les objets inutiles d'être récupérés par le récupérateur de mémoire.
Le rôle du garbage collector est d'identifier et de récupérer les objets qui ne sont plus accessibles depuis l'application. Cela fonctionne même lorsque les objets se référent eux-mêmes ou se référent de manière cyclique. Une fois qu'il n'y a plus de références par lesquelles une application peut accéder à un groupe d'objets, elle peut être collectée.
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.
Une classe de fuite de mémoire particulièrement délicate se produit lorsqu'une application fait référence à des objets qui ont leur propre cycle de vie, comme des éléments DOM ou des fenêtres pop-up. Il est possible que ces types d'objets ne soient plus utilisés sans que l'application le sache, ce qui signifie que le code de l'application peut être la seule référence restante à un objet qui pourrait autrement être collecté.
Qu'est-ce qu'une fenêtre détachée ?
Dans l'exemple suivant, une application de visionnage de diapositives inclut des boutons permettant d'ouvrir et de fermer une fenêtre pop-up de notes du présentateur. Imaginons qu'un utilisateur clique sur Afficher les notes, puis ferme directement la fenêtre pop-up au lieu de cliquer sur le bouton Masquer les notes. La variable notesWindow
contient toujours une référence au pop-up auquel il est possible d'accéder, même si le pop-up n'est plus utilisé.
<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>
Voici un exemple de fenêtre détachée. La fenêtre pop-up a été fermée, mais notre code contient une référence qui empêche le navigateur de la détruire et de récupérer cette mémoire.
Lorsqu'une page appelle window.open()
pour créer une fenêtre ou un onglet de navigateur, un objet Window
représentant la fenêtre ou l'onglet est renvoyé. Même après la fermeture d'une telle fenêtre ou la navigation de l'utilisateur, l'objet Window
renvoyé par window.open()
peut toujours être utilisé pour accéder à des informations à son sujet. Il s'agit d'un type de fenêtre détachée: comme le code JavaScript peut toujours potentiellement accéder aux propriétés de l'objet Window
fermé, il doit être conservé en mémoire. Si la fenêtre incluait de nombreux objets JavaScript ou iFrames, cette mémoire ne peut pas être récupérée tant qu'il ne reste aucune référence JavaScript aux propriétés de la fenêtre.
Le même problème peut également se produire lorsque vous utilisez des éléments <iframe>
. Les iFrames se comportent comme des fenêtres imbriquées contenant des documents, et leur propriété contentWindow
permet d'accéder à l'objet Window
contenu, tout comme la valeur renvoyée par window.open()
. Le code JavaScript peut conserver une référence à l'contentWindow
ou à l'contentDocument
d'une iframe, même si l'iframe est supprimée du DOM ou si son URL change. Cela empêche la collecte des déchets du document, car ses propriétés sont toujours accessibles.
Dans les cas où une référence à l'document
dans une fenêtre ou une iFrame est conservée à partir de JavaScript, ce document est conservé en mémoire, même si la fenêtre ou l'iFrame contenante accède à une nouvelle URL. Cela peut être particulièrement gênant lorsque le code JavaScript qui détient cette référence ne détecte pas que la fenêtre/le frame a accédé à une nouvelle URL, car il ne sait pas quand il devient la dernière référence conservant un document en mémoire.
Comment les fenêtres détachées provoquent des fuites de mémoire
Lorsque vous travaillez avec des fenêtres et des iFrames sur le même domaine que la page principale, il est courant d'écouter des événements ou d'accéder à des propriétés au-delà des limites du document. Par exemple, reprenons une variante de l'exemple de lecteur de présentation du début de ce guide. Le lecteur ouvre une deuxième fenêtre pour afficher les commentaires du présentateur. La fenêtre des commentaires du présentateur écoute les événements click
comme signal pour passer à la diapositive suivante. Si l'utilisateur ferme cette fenêtre de notes, le code JavaScript exécuté dans la fenêtre parente d'origine conserve un accès complet au document de notes du conférencier:
<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>
Imaginons que nous fermions la fenêtre du navigateur créée par showNotes()
ci-dessus. Aucun gestionnaire d'événements n'est en écoute pour détecter que la fenêtre a été fermée. Par conséquent, rien n'informe notre code qu'il doit nettoyer toutes les références au document. La fonction nextSlide()
est toujours "active", car elle est liée en tant que gestionnaire de clics sur notre page principale. Le fait que nextSlide
contienne une référence à notesWindow
signifie que la fenêtre est toujours référencée et ne peut pas être collectée.
Il existe d'autres scénarios dans lesquels des références sont conservées accidentellement, ce qui empêche les fenêtres détachées d'être éligibles au garbage collection:
Les gestionnaires d'événements peuvent être enregistrés sur le document initial d'un iFrame avant que le frame ne navigue vers son URL prévue, ce qui entraîne des références accidentelles au document et à l'iFrame qui persistent après le nettoyage des autres références.
Un document volumineux chargé dans une fenêtre ou une iFrame peut être conservé accidentellement en mémoire longtemps après avoir accédé à une nouvelle URL. Cela est souvent dû au fait que la page parente conserve des références au document afin de permettre la suppression de l'écouteur.
Lorsque vous transmettez un objet JavaScript à une autre fenêtre ou à un autre iframe, la chaîne de prototypes de l'objet inclut des références à l'environnement dans lequel il a été créé, y compris la fenêtre qui l'a créé. Par conséquent, il est tout aussi important d'éviter de conserver des références à des objets d'autres fenêtres que de ne pas conserver de références aux fenêtres elles-mêmes.
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>
Détecter les fuites de mémoire causées par les fenêtres dissociées
Il peut être difficile de détecter les fuites de mémoire. Il est souvent difficile de créer des reproductions isolées de ces problèmes, en particulier lorsque plusieurs documents ou fenêtres sont concernés. Pour compliquer les choses, l'inspection des références potentielles en fuite peut finir par créer des références supplémentaires qui empêchent la collecte des déchets des objets inspectés. Pour ce faire, il est utile de commencer par des outils qui évitent spécifiquement d'introduire cette possibilité.
Pour commencer à déboguer les problèmes de mémoire, effectuez une capture d'écran de la mémoire tampon. Cela fournit une vue ponctuelle de la mémoire actuellement utilisée par une application, c'est-à-dire de tous les objets créés, mais pas encore collectés. Les instantanés de tas contiennent des informations utiles sur les objets, y compris leur taille et une liste des variables et des fermetures qui les référencent.
Pour enregistrer un instantané de tas, accédez à l'onglet Mémoire dans les outils pour les développeurs Chrome, puis sélectionnez Instantané de tas dans la liste des types de profilage disponibles. Une fois l'enregistrement terminé, la vue Récapitulatif affiche les objets actuels en mémoire, regroupés par constructeur.
L'analyse des vidages de tas peut s'avérer une tâche ardue, et il peut être très difficile de trouver les bonnes informations lors du débogage. Pour y parvenir, les ingénieurs Chromium yossik@ et peledni@ ont développé un outil nettoyeur de tas autonome qui peut aider à mettre en évidence un nœud spécifique, comme une fenêtre détachée. Exécuter le nettoyeur de tas sur une trace supprime d'autres informations inutiles du graphique de rétention, ce qui rend la trace plus claire et beaucoup plus facile à lire.
Mesurer la mémoire de façon programmatique
Les instantanés de tas fournissent un niveau de détail élevé et sont excellents pour déterminer où les fuites se produisent. Toutefois, prendre un instantané de tas est un processus manuel. Pour détecter les fuites de mémoire, vous pouvez également obtenir la taille de la mémoire tampon JavaScript actuellement utilisée à partir de l'API performance.memory
:
L'API performance.memory
ne fournit que des informations sur la taille du tas JavaScript, ce qui signifie qu'elle n'inclut pas la mémoire utilisée par le document et les ressources du pop-up. Pour obtenir une vue d'ensemble, nous aurions besoin d'utiliser la nouvelle API performance.measureUserAgentSpecificMemory()
actuellement en phase de test dans Chrome.
Solutions pour éviter les fuites de fenêtres détachées
Les deux cas les plus courants où les fenêtres dissociées provoquent des fuites de mémoire se produisent lorsque le document parent conserve des références à une fenêtre pop-up fermée ou à une iFrame supprimée, et lorsque la navigation inattendue d'une fenêtre ou d'une iFrame entraîne la non-désinscription des gestionnaires d'événements.
Exemple: Fermer un pop-up
Dans l'exemple suivant, deux boutons sont utilisés pour ouvrir et fermer une fenêtre pop-up. Pour que le bouton Close Popup (Fermer la fenêtre pop-up) fonctionne, une référence à la fenêtre pop-up ouverte est stockée dans une variable:
<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>
À première vue, le code ci-dessus semble éviter les écueils courants: aucune référence au document du pop-up n'est conservée, et aucun gestionnaire d'événements n'est enregistré sur la fenêtre pop-up. Toutefois, une fois que l'utilisateur a cliqué sur le bouton Ouvrir le pop-up, la variable popup
fait désormais référence à la fenêtre ouverte, et cette variable est accessible depuis le champ d'application du gestionnaire de clic sur le bouton Fermer le pop-up. Sauf si popup
est réaffecté ou si le gestionnaire de clics est supprimé, la référence fermée de ce gestionnaire à popup
signifie qu'il ne peut pas être collecté par le garbage collector.
Solution: Définir des références
Les variables qui font référence à une autre fenêtre ou à son document entraînent sa conservation en mémoire. Étant donné que les objets en JavaScript sont toujours des références, attribuer une nouvelle valeur aux variables supprime leur référence à l'objet d'origine. Pour "désinitialiser" les références à un objet, nous pouvons réaffecter ces variables à la valeur null
.
En appliquant cela à l'exemple de fenêtre pop-up précédent, nous pouvons modifier le gestionnaire du bouton de fermeture pour qu'il "définisse" sa référence à la fenêtre pop-up:
let popup;
open.onclick = () => {
popup = window.open('/login.html');
};
close.onclick = () => {
popup.close();
popup = null;
};
Cela aide, mais révèle un autre problème spécifique aux fenêtres créées à l'aide de open()
: que se passe-t-il si l'utilisateur ferme la fenêtre au lieu de cliquer sur notre bouton de fermeture personnalisé ? De plus, que se passe-t-il si l'utilisateur commence à parcourir d'autres sites Web dans la fenêtre que nous avons ouverte ? Bien qu'il semble initialement suffisant de ne pas définir la référence popup
lorsque vous cliquez sur notre bouton de fermeture, il existe toujours une fuite de mémoire lorsque les utilisateurs n'utilisent pas ce bouton particulier pour fermer la fenêtre. Pour résoudre ce problème, vous devez détecter ces cas afin de ne pas définir les références persistantes lorsqu'elles se produisent.
Solution: Surveiller et supprimer
Dans de nombreux cas, le code JavaScript chargé d'ouvrir des fenêtres ou de créer des cadres n'a pas le contrôle exclusif de leur cycle de vie. Les pop-ups peuvent être fermés par l'utilisateur, ou la navigation vers un nouveau document peut entraîner la dissociation du document précédemment contenu dans une fenêtre ou un frame. Dans les deux cas, le navigateur déclenche un événement pagehide
pour signaler que le document est en cours de déchargement.
L'événement pagehide
peut être utilisé pour détecter les fenêtres fermées et la navigation en dehors du document actuel. Toutefois, il existe une mise en garde importante: toutes les fenêtres et iFrames nouvellement créées contiennent un document vide, puis accèdent de manière asynchrone à l'URL donnée, le cas échéant. Par conséquent, un événement pagehide
initial est déclenché peu de temps après la création de la fenêtre ou du frame, juste avant le chargement du document cible. Étant donné que notre code de nettoyage des références doit s'exécuter lorsque le document cible est déchargé, nous devons ignorer ce premier événement pagehide
. Il existe plusieurs techniques pour ce faire, la plus simple étant d'ignorer les événements de masquage de page provenant de l'URL about:blank
du document initial. Voici à quoi cela ressemblerait dans notre exemple de pop-up:
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;
});
};
Notez que cette technique ne fonctionne que pour les fenêtres et les cadres qui ont la même origine effective que la page parente sur laquelle notre code s'exécute. Lorsque vous chargez du contenu à partir d'une autre origine, location.host
et l'événement pagehide
ne sont pas disponibles pour des raisons de sécurité. Bien qu'il soit généralement préférable d'éviter de conserver des références à d'autres origines, dans les rares cas où cela est nécessaire, il est possible de surveiller les propriétés window.closed
ou frame.isConnected
. Lorsque ces propriétés changent pour indiquer une fenêtre fermée ou une iFrame supprimée, il est conseillé de ne pas définir de références à celle-ci.
let popup = window.open('https://example.com');
let timer = setInterval(() => {
if (popup.closed) {
popup = null;
clearInterval(timer);
}
}, 1000);
Solution: Utiliser WeakRef
JavaScript a récemment accepté une nouvelle méthode de référencement des objets qui permet la récupération de mémoire, appelée WeakRef
. Un WeakRef
créé pour un objet n'est pas une référence directe, mais plutôt un objet distinct qui fournit une méthode .deref()
spéciale qui renvoie une référence à l'objet tant qu'il n'a pas été collecté par le garbage collector. Avec WeakRef
, il est possible d'accéder à la valeur actuelle d'une fenêtre ou d'un document tout en permettant la collecte des déchets. Au lieu de conserver une référence à la fenêtre qui doit être définie manuellement en réponse à des événements tels que pagehide
ou des propriétés telles que window.closed
, l'accès à la fenêtre est obtenu selon les besoins. Lorsque la fenêtre est fermée, elle peut être collectée, ce qui entraîne le retour de undefined
par la méthode .deref()
.
<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>
Un détail intéressant à prendre en compte lorsque vous utilisez WeakRef
pour accéder à des fenêtres ou à des documents est que la référence reste généralement disponible pendant une courte période après la fermeture de la fenêtre ou la suppression de l'iFrame. En effet, WeakRef
continue de renvoyer une valeur jusqu'à ce que son objet associé ait été collecté, ce qui se produit de manière asynchrone en JavaScript et généralement pendant les temps d'inactivité. Heureusement, lorsque vous recherchez des fenêtres détachées dans le panneau Mémoire des outils pour les développeurs Chrome, la prise d'un instantané de tas déclenche le ramassage des déchets et élimine la fenêtre faiblement référencée. Vous pouvez également vérifier qu'un objet référencé via WeakRef
a été supprimé de JavaScript, soit en détectant quand deref()
renvoie undefined
, soit à l'aide de la nouvelle API FinalizationRegistry
:
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());
Solution: Communiquer via postMessage
Détecter quand les fenêtres sont fermées ou que la navigation décharge un document nous permet de supprimer les gestionnaires et de définir des références afin que les fenêtres dissociées puissent être collectées. Toutefois, ces modifications sont des corrections spécifiques à ce qui peut parfois être un problème plus fondamental: le couplage direct entre les pages.
Une approche plus globale est disponible pour éviter les références obsolètes entre les fenêtres et les documents: établir une séparation en limitant la communication inter-documents à postMessage()
. Pour revenir à notre exemple de notes de présentateur d'origine, des fonctions telles que nextSlide()
mettaient à jour la fenêtre de notes directement en la référençant et en manipulant son contenu. À la place, la page principale peut transmettre les informations nécessaires à la fenêtre de notes de manière asynchrone et indirecte via postMessage()
.
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;
Bien que les fenêtres doivent toujours se référencer les unes aux autres, aucune d'elles ne conserve de référence au document actuel à partir d'une autre fenêtre. Une approche de transmission de messages encourage également les conceptions dans lesquelles les références de fenêtre sont conservées en un seul endroit, ce qui signifie qu'une seule référence doit être définie lorsque les fenêtres sont fermées ou que l'utilisateur quitte la page. Dans l'exemple ci-dessus, seul showNotes()
conserve une référence à la fenêtre de notes et utilise l'événement pagehide
pour s'assurer que cette référence est nettoyée.
Solution: Éviter les références à l'aide de noopener
Dans le cas où une fenêtre pop-up est ouverte avec laquelle votre page n'a pas besoin de communiquer ni de contrôler, vous pouvez éviter d'obtenir une référence à la fenêtre. Cela est particulièrement utile lorsque vous créez des fenêtres ou des iFrames qui chargent du contenu à partir d'un autre site. Dans ces cas, window.open()
accepte une option "noopener"
qui fonctionne exactement comme l'attribut rel="noopener"
pour les liens HTML:
window.open('https://example.com/share', null, 'noopener');
L'option "noopener"
entraîne le retour de null
par window.open()
, ce qui rend impossible le stockage accidentel d'une référence au pop-up. Il empêche également la fenêtre pop-up d'obtenir une référence à sa fenêtre parente, car la propriété window.opener
sera null
.
Commentaires
Nous espérons que certaines des suggestions de cet article vous aideront à trouver et à résoudre les fuites de mémoire. Si vous avez une autre technique pour déboguer les fenêtres détachées ou si cet article vous a aidé à détecter des fuites dans votre application, je serais ravi de le savoir. Vous pouvez me retrouver sur Twitter : @_developit.