Fugas de memoria de la ventana separada

Encuentra y corrige fugas de memoria difíciles causadas por ventanas separadas.

Bartek Nowierski
Bartek Nowierski

¿Qué es una fuga de memoria en JavaScript?

Una fuga de memoria es un aumento no intencional de la cantidad de memoria que usa una aplicación con el tiempo. En JavaScript, las fugas de memoria ocurren cuando los objetos ya no son necesarios, pero las funciones o los demás objetos aún hacen referencia a ellos. Estas referencias evitan que el recolector de elementos no utilizados recupere los objetos que no se necesitan.

La tarea del recolector de elementos no utilizados es identificar y reclamar los objetos a los que ya no se puede acceder desde la aplicación. Esto funciona incluso cuando los objetos se hacen referencia a sí mismos o se refieren de forma cyclical a cada uno de ellos. Una vez que no quedan referencias a través de las cuales una aplicación pueda acceder a un grupo de objetos, se puede realizar la recolección de elementos no utilizados.

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.

Una clase particularmente difícil de fuga de memoria ocurre cuando una aplicación hace referencia a objetos que tienen su propio ciclo de vida, como elementos DOM o ventanas emergentes. Es posible que estos tipos de objetos dejen de usarse sin que la aplicación lo sepa, lo que significa que el código de la aplicación puede tener las únicas referencias restantes a un objeto que, de otro modo, podría eliminarse.

¿Qué es una ventana independiente?

En el siguiente ejemplo, una aplicación de visor de diapositivas incluye botones para abrir y cerrar una ventana emergente de notas del presentador. Imagina que un usuario hace clic en Show Notes y, luego, cierra la ventana emergente directamente en lugar de hacer clic en el botón Hide Notes. La variable notesWindow aún contiene una referencia a la ventana emergente a la que se podría acceder, aunque ya no esté en uso.

<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>

Este es un ejemplo de una ventana independiente. Se cerró la ventana emergente, pero nuestro código tiene una referencia a ella que evita que el navegador pueda destruirla y recuperar esa memoria.

Cuando una página llama a window.open() para crear una nueva ventana o pestaña del navegador, se muestra un objeto Window que representa la ventana o pestaña. Incluso después de que se cierre una ventana de este tipo o el usuario la cierre, el objeto Window que se muestra desde window.open() se puede usar para acceder a información sobre ella. Este es un tipo de ventana independiente: como el código JavaScript aún puede acceder a las propiedades del objeto Window cerrado, se debe mantener en la memoria. Si la ventana incluía muchos objetos o iframes de JavaScript, esa memoria no se puede recuperar hasta que no queden referencias de JavaScript a las propiedades de la ventana.

Se usan las Herramientas para desarrolladores de Chrome para demostrar cómo es posible retener un documento después de cerrar una ventana.

El mismo problema también puede ocurrir cuando se usan elementos <iframe>. Los iframes se comportan como ventanas anidadas que contienen documentos, y su propiedad contentWindow proporciona acceso al objeto Window contenido, al igual que el valor que muestra window.open(). El código JavaScript puede mantener una referencia a contentWindow o contentDocument de un iframe, incluso si se quita del DOM o cambia su URL, lo que evita que se recopile el documento como elemento no utilizado, ya que se puede acceder a sus propiedades.

Demostración de cómo un controlador de eventos puede retener el documento de un iframe, incluso después de navegar en el iframe a una URL diferente.

En los casos en que se retenga una referencia a document dentro de una ventana o un iframe desde JavaScript, ese documento se mantendrá en la memoria, incluso si la ventana o el iframe que lo contiene navegan a una URL nueva. Esto puede ser particularmente problemático cuando el código JavaScript que contiene esa referencia no detecta que la ventana o el marco navegaron a una URL nueva, ya que no sabe cuándo se convierte en la última referencia que mantiene un documento en la memoria.

Cómo las ventanas separadas causan fugas de memoria

Cuando se trabaja con ventanas y marcos de iframes en el mismo dominio que la página principal, es habitual detectar eventos o acceder a propiedades en los límites de los documentos. Por ejemplo, revisemos una variación del ejemplo del visor de presentaciones del comienzo de esta guía. El visor abre una segunda ventana para mostrar las notas del orador. La ventana de notas del orador escucha eventos click como su señal para pasar a la siguiente diapositiva. Si el usuario cierra esta ventana de notas, el código JavaScript que se ejecuta en la ventana superior original aún tiene acceso completo al documento de notas del orador:

<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>

Imagina que cerramos la ventana del navegador que creó showNotes() anteriormente. No hay un controlador de eventos que detecte que se cerró la ventana, por lo que nada le informa a nuestro código que debe limpiar las referencias al documento. La función nextSlide() sigue siendo "activa" porque está ligada como controlador de clics en nuestra página principal, y el hecho de que nextSlide contenga una referencia a notesWindow significa que aún se hace referencia a la ventana y no se puede realizar la recolección de elementos no utilizados.

Ilustración de cómo las referencias a una ventana evitan que se recopile basura una vez que se cierra.

Existen otras situaciones en las que se retienen referencias accidentalmente que impiden que las ventanas separadas sean aptas para la recolección de elementos no utilizados:

  • Los controladores de eventos se pueden registrar en el documento inicial de un iframe antes de que el marco navegue a la URL deseada, lo que genera referencias accidentales al documento y al iframe que persisten después de que se hayan borrado otras referencias.

  • Un documento con mucho uso de memoria cargado en una ventana o un iframe se puede mantener accidentalmente en la memoria mucho después de navegar a una URL nueva. Esto suele deberse a que la página superior retiene referencias al documento para permitir la eliminación del objeto de escucha.

  • Cuando se pasa un objeto JavaScript a otra ventana o iframe, la cadena de prototipos del objeto incluye referencias al entorno en el que se creó, incluida la ventana que lo creó. Esto significa que es tan importante evitar mantener referencias a objetos de otras ventanas como evitar mantener referencias a las ventanas en sí.

    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>
    

Cómo detectar fugas de memoria causadas por ventanas separadas

Puede ser complicado hacer un seguimiento de las fugas de memoria. A menudo, es difícil crear reproducciones aisladas de estos problemas, en especial, cuando hay varios documentos o ventanas involucrados. Para complicar aún más las cosas, la inspección de posibles referencias filtradas puede terminar creando referencias adicionales que impiden que se recopilen los objetos inspeccionados. Para ello, es útil comenzar con herramientas que eviten específicamente esta posibilidad.

Un buen punto de partida para comenzar a depurar problemas de memoria es tomar una instantánea del montón. Esto proporciona una vista de un momento determinado de la memoria que usa actualmente una aplicación: todos los objetos que se crearon, pero que aún no se eliminaron. Las instantáneas del montón contienen información útil sobre los objetos, incluido su tamaño y una lista de las variables y los cierres que hacen referencia a ellos.

Captura de pantalla de una instantánea de montón en Chrome DevTools que muestra las referencias que retienen un objeto grande.
Una instantánea del montón que muestra las referencias que retienen un objeto grande.

Para registrar una instantánea de montón, ve a la pestaña Memoria en Chrome DevTools y selecciona Instantánea de montón en la lista de tipos de generación de perfiles disponibles. Una vez que finaliza la grabación, la vista Resumen muestra los objetos actuales en la memoria, agrupados por constructor.

Demostración de cómo tomar una instantánea de montón en Chrome DevTools.

El análisis de volcados de montón puede ser una tarea abrumadora, y puede ser bastante difícil encontrar la información correcta como parte de la depuración. Para ayudar con esto, los ingenieros de Chromium yossik@ y peledni@ desarrollaron una herramienta independiente de Heap Cleaner que puede ayudar a destacar un nodo específico, como una ventana separada. Ejecutar Heap Cleaner en un seguimiento quita otra información innecesaria del gráfico de retención, lo que hace que el seguimiento sea más limpio y mucho más fácil de leer.

Mide la memoria de manera programática

Las instantáneas de montón proporcionan un alto nivel de detalle y son excelentes para determinar dónde se producen las fugas, pero tomar una instantánea de montón es un proceso manual. Otra forma de verificar si hay fugas de memoria es obtener el tamaño del montón de JavaScript que se usa actualmente desde la API de performance.memory:

Captura de pantalla de una sección de la interfaz de usuario de las Herramientas para desarrolladores de Chrome.
Se verifica el tamaño del montón de JS utilizado en DevTools a medida que se crea, cierra y no se hace referencia a una ventana emergente.

La API de performance.memory solo proporciona información sobre el tamaño del montón de JavaScript, lo que significa que no incluye la memoria que usan el documento y los recursos de la ventana emergente. Para obtener el panorama completo, deberíamos usar la nueva API de performance.measureUserAgentSpecificMemory() que se está probando actualmente en Chrome.

Soluciones para evitar filtraciones de ventanas sueltas

Los dos casos más comunes en los que las ventanas separadas causan fugas de memoria son cuando el documento superior retiene referencias a una ventana emergente cerrada o a un iframe quitado, y cuando la navegación inesperada de una ventana o un iframe hace que los controladores de eventos nunca se den de baja.

Ejemplo: Cómo cerrar una ventana emergente

En el siguiente ejemplo, se usan dos botones para abrir y cerrar una ventana emergente. Para que funcione el botón Close Popup, se almacena una referencia a la ventana emergente abierta en una 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>

A primera vista, parece que el código anterior evita errores comunes: no se retienen referencias al documento de la ventana emergente ni se registran controladores de eventos en la ventana emergente. Sin embargo, una vez que se hace clic en el botón Open Popup, la variable popup ahora hace referencia a la ventana abierta, y se puede acceder a esa variable desde el alcance del controlador de clics del botón Close Popup. A menos que se reasigne popup o se quite el controlador de clics, la referencia adjunta de ese controlador a popup significa que no se puede realizar la recolección de elementos no utilizados.

Solución: No establecer referencias

Las variables que hacen referencia a otra ventana o a su documento hacen que se retenga en la memoria. Dado que los objetos en JavaScript siempre son referencias, asignar un valor nuevo a las variables quita su referencia al objeto original. Para "desconfigurar" las referencias a un objeto, podemos reasignar esas variables al valor null.

Si aplicamos esto al ejemplo de ventana emergente anterior, podemos modificar el controlador del botón de cierre para que “desconfigure” su referencia a la ventana emergente:

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

Esto ayuda, pero revela otro problema específico de las ventanas creadas con open(): ¿qué sucede si el usuario cierra la ventana en lugar de hacer clic en nuestro botón de cierre personalizado? Además, ¿qué sucede si el usuario comienza a explorar otros sitios web en la ventana que abrimos? Si bien, en un principio, parecía suficiente restablecer la referencia popup cuando se hacía clic en el botón de cierre, aún se produce una fuga de memoria cuando los usuarios no usan ese botón en particular para cerrar la ventana. Para resolver este problema, es necesario detectar estos casos para anular las referencias persistentes cuando ocurran.

Solución: Supervisar y eliminar

En muchas situaciones, el código JavaScript responsable de abrir ventanas o crear marcos no tiene control exclusivo sobre su ciclo de vida. El usuario puede cerrar las ventanas emergentes, o bien la navegación a un documento nuevo puede hacer que el documento que antes contenía una ventana o un marco se desconecte. En ambos casos, el navegador activa un evento pagehide para indicar que se está descargando el documento.

El evento pagehide se puede usar para detectar ventanas cerradas y navegar fuera del documento actual. Sin embargo, hay una advertencia importante: todas las ventanas y los iframes creados recientemente contienen un documento vacío y, luego, navegan de forma asíncrona a la URL proporcionada si se proporciona. Como resultado, se activa un evento pagehide inicial poco después de crear la ventana o el marco, justo antes de que se cargue el documento de destino. Dado que nuestro código de limpieza de referencias debe ejecutarse cuando se descarga el documento objetivo, debemos ignorar este primer evento pagehide. Existen varias técnicas para hacerlo, la más simple de las cuales es ignorar los eventos de ocultación de página que provienen de la URL about:blank del documento inicial. Así es como se vería en nuestro ejemplo de ventana emergente:

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;
  });
};

Es importante tener en cuenta que esta técnica solo funciona para ventanas y marcos que tienen el mismo origen efectivo que la página superior en la que se ejecuta nuestro código. Cuando se carga contenido de un origen diferente, location.host y el evento pagehide no están disponibles por motivos de seguridad. Si bien, por lo general, es mejor evitar mantener referencias a otros orígenes, en los casos excepcionales en los que esto es necesario, es posible supervisar las propiedades window.closed o frame.isConnected. Cuando estas propiedades cambian para indicar una ventana cerrada o un iframe quitado, es conveniente anular cualquier referencia a él.

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

Solución: Usa WeakRef

Recientemente, JavaScript obtuvo compatibilidad con una nueva forma de hacer referencia a objetos que permite que se realice la recolección de elementos no utilizados, llamada WeakRef. Un WeakRef creado para un objeto no es una referencia directa, sino un objeto independiente que proporciona un método .deref() especial que muestra una referencia al objeto, siempre y cuando no se haya recolectado como elemento no utilizado. Con WeakRef, es posible acceder al valor actual de una ventana o un documento y, al mismo tiempo, permitir que se recopile la basura. En lugar de retener una referencia a la ventana que se debe anular de forma manual en respuesta a eventos como pagehide o propiedades como window.closed, se obtiene acceso a la ventana según sea necesario. Cuando se cierra la ventana, se puede realizar una recolección de elementos no utilizados, lo que hace que el método .deref() comience a mostrar undefined.

<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 detalle interesante que debes tener en cuenta cuando usas WeakRef para acceder a ventanas o documentos es que, por lo general, la referencia permanece disponible durante un período breve después de que se cierra la ventana o se quita el iframe. Esto se debe a que WeakRef sigue mostrando un valor hasta que se realiza la recolección de elementos no utilizados en su objeto asociado, lo que ocurre de forma asíncrona en JavaScript y, por lo general, durante el tiempo inactivo. Por suerte, cuando se verifican las ventanas separadas en el panel Memory de las Herramientas para desarrolladores de Chrome, tomar una captura de montón en realidad activa la recolección de basura y descarta la ventana con referencia débil. También es posible verificar que un objeto al que se hace referencia a través de WeakRef se haya eliminado de JavaScript, ya sea detectando cuándo deref() muestra undefined o usando la nueva API de 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());

Solución: Comunícate a través de postMessage

Detectar cuándo se cierran las ventanas o la navegación descarga un documento nos brinda una forma de quitar los controladores y establecer referencias para que las ventanas separadas se puedan recolectar como basura. Sin embargo, estos cambios son correcciones específicas para lo que, a veces, puede ser una preocupación más fundamental: la vinculación directa entre páginas.

Hay disponible un enfoque alternativo más integral que evita las referencias inactivas entre ventanas y documentos: establece la separación limitando la comunicación entre documentos a postMessage(). Si recordamos nuestro ejemplo original de notas del presentador, funciones como nextSlide() actualizaban la ventana de notas directamente haciendo referencia a ella y manipulando su contenido. En su lugar, la página principal podría pasar la información necesaria a la ventana de notas de forma asíncrona e indirecta a través de 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;

Si bien esto aún requiere que las ventanas se hagan referencia entre sí, ninguna retiene una referencia al documento actual desde otra ventana. Un enfoque de transmisión de mensajes también fomenta los diseños en los que las referencias de ventanas se mantienen en un solo lugar, lo que significa que solo se debe anular una sola referencia cuando se cierran las ventanas o se navega hacia otro lugar. En el ejemplo anterior, solo showNotes() retiene una referencia a la ventana de notas y usa el evento pagehide para garantizar que se borre esa referencia.

Solución: Evita las referencias con noopener

En los casos en que se abre una ventana emergente con la que tu página no necesita comunicarse ni controlar, es posible que puedas evitar obtener una referencia a la ventana. Esto es particularmente útil cuando se crean ventanas o iframes que cargarán contenido de otro sitio. En estos casos, window.open() acepta una opción "noopener" que funciona igual que el atributo rel="noopener" para los vínculos HTML:

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

La opción "noopener" hace que window.open() devuelva null, lo que hace imposible almacenar accidentalmente una referencia a la ventana emergente. También evita que la ventana emergente obtenga una referencia a su ventana superior, ya que la propiedad window.opener será null.

Comentarios

Esperamos que algunas de las sugerencias de este artículo te ayuden a encontrar y corregir las fugas de memoria. Si tienes otra técnica para depurar ventanas separadas o si este artículo te ayudó a descubrir fugas en tu app, nos encantaría saberlo. Puedes encontrarme en Twitter @_developit.