Fuites de mémoire de fenêtres dissociées

Identifiez et corrigez les fuites de mémoire délicates causées par des fenêtres dissociées.

Bartek Nowierski
Bartek Nowierski

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, les fuites de mémoire se produisent lorsque les objets ne sont plus nécessaires, mais 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.

Son rôle 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 font référence à eux-mêmes ou se font référence de manière cyclique. Une fois qu'il n'y a plus de références via lesquelles une application pourrait accéder à un groupe d'objets, il se peut que la mémoire soit récupéré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 ayant leur propre cycle de vie, tels que des éléments DOM ou des fenêtres pop-up. Il est possible que ces types d'objets deviennent inutilisés sans que l'application le sache, ce qui signifie que le code d'application peut contenir les seules références restantes à un objet qui, autrement, pourraient faire l'objet d'une récupération de mémoire.

Qu'est-ce qu'une fenêtre dissociée ?

Dans l'exemple suivant, une application de visualisation de diaporamas comprend des boutons permettant d'ouvrir et de fermer une fenêtre pop-up contenant des notes de présentateur. Imaginons qu'un utilisateur clique sur Show Notes (Afficher les notes), puis qu'il ferme la fenêtre pop-up directement au lieu de cliquer sur le bouton Hide Notes (Masquer les notes). La variable notesWindow contient toujours une référence à la fenêtre pop-up qui est accessible, même si la fenêtre pop-up n'est plus utilisée.

<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 dissocié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 après que l'utilisateur l'a quittée, l'objet Window renvoyé par window.open() peut toujours être utilisé pour accéder aux informations la concernant. Il s'agit d'un type de fenêtre dissociée: étant donné que le code JavaScript peut toujours accéder aux propriétés de l'objet Window fermé, celui-ci doit être conservé en mémoire. Si la fenêtre comportait de nombreux objets JavaScript ou iFrames, cette mémoire ne peut être récupérée que lorsqu'il ne reste plus aucune référence JavaScript aux propriétés de la fenêtre.

Utilisation des outils pour les développeurs Chrome pour montrer comment conserver un document après la fermeture d'une 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. 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 au contentWindow ou à la contentDocument d'un iFrame, même si l'iFrame est supprimé du DOM ou ses modifications d'URL, ce qui empêche la récupération de mémoire du document puisque ses propriétés sont toujours accessibles.

Démonstration de la manière dont un gestionnaire d'événements peut conserver le document d'un iFrame, même après avoir accédé à une autre URL

Si une référence à la propriété document dans une fenêtre ou un iFrame est conservée à partir de JavaScript, ce document est conservé en mémoire même si la fenêtre ou l'iFrame qui le contiennent accèdent à une nouvelle URL. Cela peut s'avérer particulièrement problématique lorsque le code JavaScript contenant cette référence ne détecte pas que la fenêtre ou le cadre a accédé à une nouvelle URL, car il ne sait pas quand il devient la dernière référence à conserver un document en mémoire.

Comment les fenêtres dissocié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 les événements ou d'accéder aux propriétés au-delà des limites des documents. Par exemple, revoyons une variante de l'exemple du lecteur de la présentation du début de ce guide. L'utilisateur 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 commentaires, le code JavaScript exécuté dans la fenêtre parente d'origine dispose toujours d'un accès complet au document de commentaires du présentateur:

<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 de navigateur créée par showNotes() ci-dessus. Aucun gestionnaire d'événements n'écoute de détecter que la fenêtre a été fermée. Par conséquent, rien n'informe notre code qu'il doit nettoyer les références au document. La fonction nextSlide() est toujours "active", car elle est liée en tant que gestionnaire de clics sur la page principale. Par ailleurs, comme nextSlide contient une référence à notesWindow, la fenêtre est toujours référencée et ne peut pas être récupérée.

Illustration de la façon dont les références à une fenêtre empêchent la récupération de mémoire une fois fermée.

Il existe d'autres scénarios dans lesquels les références sont conservées accidentellement, ce qui empêche les fenêtres dissociées d'être éligibles à la récupération de mémoire:

  • Les gestionnaires d'événements peuvent être enregistrés sur le document initial d'un iFrame avant que le frame ne accède à l'URL prévue, ce qui entraîne des références accidentelles au document et la persistance de l'iFrame après le nettoyage des autres références.

  • Un document gourmand en mémoire chargé dans une fenêtre ou un iFrame peut être accidentellement conservé 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.

  • Lors de la transmission d'un objet JavaScript à une autre fenêtre ou à un autre iFrame, la chaîne de prototype de l'objet inclut des références à l'environnement dans lequel il a été créé, y compris la fenêtre dans laquelle il a été créé. Cela signifie qu'il est tout aussi important d'éviter de conserver des références aux objets d'autres fenêtres que de conserver des 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 des fenêtres dissociées

Suivre les fuites de mémoire peut s'avérer délicat. Il est souvent difficile de créer des reproductions isolées de ces problèmes, en particulier lorsque plusieurs documents ou fenêtres sont impliqués. Pour compliquer les choses, l'inspection des références divulguées potentielles peut entraîner la création de références supplémentaires qui empêchent les objets inspectés d'être récupérés. À cette fin, il est utile de commencer par des outils qui évitent spécifiquement d'introduire cette possibilité.

Pour déboguer les problèmes de mémoire, vous pouvez prendre un instantané de segment de mémoire. Vous obtenez ainsi une vue à un moment précis de la mémoire actuellement utilisée par une application, c'est-à-dire de tous les objets qui ont été créés, mais qui n'ont pas encore été récupérés. Les instantanés de tas de mémoire contiennent des informations utiles sur les objets, y compris leur taille, ainsi qu'une liste des variables et des fermetures qui y font référence.

Capture d&#39;écran d&#39;un instantané du segment de mémoire dans les outils pour les développeurs Chrome montrant les références qui conservent un objet volumineux.
Instantané du tas de mémoire affichant les références qui conservent un objet volumineux.

Pour enregistrer un instantané de segment de mémoire, accédez à l'onglet Mémoire dans les outils pour les développeurs Chrome, puis sélectionnez Instantané de tas de mémoire dans la liste des types de profilage disponibles. Une fois l'enregistrement terminé, la vue Summary (Résumé) affiche les objets actuels en mémoire, regroupés par constructeur.

Démonstration de la prise d'un instantané de segment de mémoire dans les outils pour les développeurs Chrome.

L'analyse des empreintes de mémoire peut être une tâche intimidante, et il peut être assez difficile de trouver les bonnes informations dans le cadre du débogage. Pour vous aider, les ingénieurs de Chromium yossik@ et peledni@ ont développé un outil Heap Cleaner autonome qui permet de mettre en évidence un nœud spécifique, comme une fenêtre détachée. L'exécution de l'outil de nettoyage de tas de mémoire sur une trace supprime d'autres informations inutiles du graphique de conservation, ce qui rend le nettoyage des traces beaucoup plus facile à lire.

Mesurer la mémoire par programmation

Les instantanés de tas de mémoire offrent un niveau de détail élevé et sont parfaits pour déterminer où se produisent les fuites. Toutefois, la création d'un instantané de tas de mémoire est un processus manuel. Une autre façon de vérifier les fuites de mémoire consiste à obtenir la taille du tas de mémoire JavaScript actuellement utilisé à partir de l'API performance.memory:

Capture d&#39;écran d&#39;une section de l&#39;interface utilisateur des outils pour les développeurs Chrome.
Vérifier la taille du tas de mémoire JS utilisé dans les outils de développement lorsqu'un pop-up est créé, fermé et non référencé.

L'API performance.memory ne fournit des informations que sur la taille du tas de mémoire 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 devrions utiliser la nouvelle API performance.measureUserAgentSpecificMemory() en cours d'essai 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 sont les cas où le document parent conserve des références à un pop-up fermé ou à un iFrame supprimé, et lorsque la navigation inattendue d'une fenêtre ou d'un iFrame entraîne la suppression de l'enregistrement des gestionnaires d'événements.

Exemple: Fermer une fenêtre pop-up

Dans l'exemple suivant, deux boutons permettent d'ouvrir et de 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 des pièges courants: aucune référence au document du pop-up n'est conservée et aucun gestionnaire d'événements n'est enregistré dans la fenêtre pop-up. Toutefois, une fois que vous avez cliqué sur le bouton Open Popup (Ouvrir un pop-up), la variable popup fait désormais référence à la fenêtre ouverte. Cette variable est accessible à partir du champ d'application du gestionnaire de clics sur le bouton Close Popup (Fermer la fenêtre pop-up). À moins que popup ne soit réattribué ou que le gestionnaire de clics soit supprimé, la référence incluse à popup par ce gestionnaire signifie qu'elle ne peut pas être récupérée.

Solution: désactiver les références

Les variables qui font référence à une autre fenêtre ou à son document la conservent en mémoire. Étant donné que les objets en JavaScript sont toujours des références, l'attribution d'une nouvelle valeur aux variables supprime leur référence à l'objet d'origine. Pour "annuler la définition" des références à un objet, nous pouvons réattribuer ces variables à la valeur null.

En appliquant cela à l'exemple de pop-up précédent, nous pouvons modifier le gestionnaire de bouton de fermeture pour qu'il "ne définisse plus" 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 à naviguer sur d'autres sites Web par la fenêtre que nous avons ouverte ? Bien qu'au départ, il semblait suffisant de désactiver la référence popup lors d'un clic sur le bouton de fermeture, une fuite de mémoire subsiste lorsque les utilisateurs n'utilisent pas ce bouton pour fermer la fenêtre. Pour résoudre ce problème, vous devez détecter ces cas afin d'annuler la définition des références superflues lorsqu'elles se produisent.

Solution: surveiller et supprimer

Dans de nombreux cas, le code JavaScript responsable de l'ouverture des fenêtres ou de la création des frames n'a pas de contrôle exclusif sur leur cycle de vie. Les fenêtres pop-up peuvent être fermées 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 cadre. 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 permet de détecter les fenêtres fermées et de quitter le document actuel. Cependant, il convient de noter une mise en garde importante: toutes les fenêtres et les iFrames nouvellement créés contiennent un document vide, puis accédez de manière asynchrone à l'URL indiqué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 cadre, juste avant le chargement du document cible. Étant donné que notre code de nettoyage de référence doit s'exécuter lorsque le document target est déchargé, nous devons ignorer ce premier événement pagehide. Il existe un certain nombre de techniques pour cela, la plus simple consiste à ignorer les événements pagehide 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;
  });
};

Il est important de noter que cette technique ne fonctionne que pour les fenêtres et les cadres ayant la même origine effective que la page parente sur laquelle notre code s'exécute. Lorsque vous chargez du contenu depuis une origine différente, 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, vous pouvez surveiller les propriétés window.closed ou frame.isConnected dans les rares cas où cela est nécessaire. Lorsque ces propriétés changent pour indiquer une fenêtre fermée ou un iFrame supprimé, nous vous recommandons de supprimer les références à cette fenêtre.

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

Solution: utiliser WeakRef

JavaScript est récemment compatible avec un nouveau moyen de référencer des objets, appelé WeakRef, qui permet la récupération de mémoire. Un WeakRef créé pour un objet n'est pas une référence directe, mais 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 fait l'objet d'une récupération de mémoire. Avec WeakRef, il est possible d'accéder à la valeur actuelle d'une fenêtre ou d'un document tout en autorisant la récupération de mémoire. Au lieu de conserver une référence à la fenêtre qui doit être désactivée 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, il peut s'agir de la récupération de mémoire, ce qui entraîne le renvoi 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>

Lorsque vous utilisez WeakRef pour accéder à des fenêtres ou à des documents, il est intéressant de tenir compte du fait 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 l'objet associé ait été récupéré, ce qui se produit de manière asynchrone dans JavaScript et généralement pendant les temps d'inactivité. Heureusement, lorsque vous recherchez des fenêtres dissociées dans le panneau Memory (Mémoire) des outils pour les développeurs Chrome, la prise d'un instantané de segment de mémoire déclenche en fait la récupération de mémoire et supprime la fenêtre faiblement référencée. Il est également possible de vérifier qu'un objet référencé via WeakRef a été supprimé de JavaScript, soit en détectant quand deref() renvoie undefined, soit en utilisant 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: communiquez via postMessage

La détection du moment où les fenêtres sont fermées ou que la navigation décharge un document nous permet de supprimer les gestionnaires et les références non définies afin de récupérer de la mémoire sur les fenêtres dissociées. Cependant, ces modifications apportent des corrections spécifiques à ce qui peut parfois constituer une préoccupation majeure: le couplage direct entre les pages.

Il existe une approche alternative plus globale qui permet d'éviter les références obsolètes entre les fenêtres et les documents: établir une séparation en limitant la communication entre documents à postMessage(). Pour en revenir à l'exemple d'origine des notes du présentateur, des fonctions telles que nextSlide() modifiaient directement la fenêtre de notes en la référençant et en manipulant son contenu. Au lieu de cela, la page principale pourrait transmettre les informations nécessaires à la fenêtre de notes de manière asynchrone et indirectement 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 les autres, aucune de ces deux fenêtres ne conserve une référence au document actuel d'une autre fenêtre. Une approche de transmission de message encourage également les conceptions dans lesquelles les références de fenêtre sont conservées à un seul endroit, ce qui signifie qu'une seule référence doit être désactivée lorsque les fenêtres sont fermées ou qu'elles quittent la page. Dans l'exemple ci-dessus, seul showNotes() conserve une référence à la fenêtre de notes. Il utilise l'événement pagehide pour s'assurer que cette référence est nettoyée.

Solution: évitez les références à l'aide de noopener

Dans les cas où une fenêtre pop-up est ouverte et que votre page n'a pas besoin de communiquer avec elle ni de la contrôler, vous pouvez éviter d'obtenir une référence à cette fenêtre. Ceci est particulièrement utile lorsque vous créez des fenêtres ou des iFrames qui chargent du contenu depuis un autre site. Dans ce 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" force window.open() à renvoyer null, ce qui empêche de stocker accidentellement une référence dans le pop-up. Cela 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 à détecter et à résoudre les fuites de mémoire. Si vous disposez d'une autre technique pour déboguer les fenêtres dissociées ou si cet article vous a aidé à identifier des fuites dans votre application, je me ferai un plaisir d'en savoir plus. Vous pouvez me retrouver sur Twitter : @_developit.