Fugas de memoria de la ventana separada

Buscar y corregir fugas de memoria complicadas 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 a lo largo del tiempo. En JavaScript, las fugas de memoria se producen cuando ya no se necesitan objetos, pero otros objetos o funciones hacen referencia a ellos. Estas referencias evitan que el recolector de elementos no utilizados reclame los objetos innecesarios.

El trabajo del recolector de elementos no utilizados es identificar y reclamar 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 de forma cíclica entre sí. Una vez que no queden referencias a través de las cuales una aplicación podría acceder a un grupo de objetos, se puede recolectar como elemento no utilizado.

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 de fuga de memoria en particular es complicada cuando una app hace referencia a objetos que tienen su propio ciclo de vida, como elementos del DOM o ventanas emergentes. Es posible que estos tipos de objetos no se usen 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 lo contrario, podría recolectarse como elementos no utilizados.

¿Qué es una ventana independiente?

En el siguiente ejemplo, una aplicación de visualización de diapositivas incluye botones para abrir y cerrar una ventana emergente de notas del presentador. Imagina que un usuario hace clic en Mostrar notas y, luego, cierra la ventana emergente directamente en lugar de hacer clic en el botón Ocultar notas. La variable notesWindow aún contiene una referencia a la ventana emergente a la que se podría acceder, aunque esta 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 reclamar 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 esa ventana o que el usuario la salga de ella, el objeto Window que muestra window.open() se puede usar para acceder a la información sobre ella. Este es un tipo de ventana separada: debido a que el código JavaScript aún puede acceder a las propiedades del objeto Window cerrado, debe mantenerse en la memoria. Si la ventana incluía muchos objetos JavaScript o iframes, no se puede reclamar esa memoria hasta que no queden referencias de JavaScript a las propiedades de la ventana.

Cómo usar las Herramientas para desarrolladores de Chrome para demostrar cómo es posible retener un documento después de que se cierra una ventana.

El mismo problema 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 conservar una referencia a contentWindow o contentDocument de un iframe, incluso si el iframe se quita del DOM o cambia su URL, lo que evita que el documento se recolecte como elemento no utilizado, ya que aún se puede acceder a sus propiedades.

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

En los casos en que se conserve una referencia a document dentro de una ventana o iframe de JavaScript, ese documento se conservará en la memoria incluso si la ventana contenedor o el iframe navega 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 navegó 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 causan fugas de memoria las ventanas separadas

Cuando se trabaja con iframes y ventanas en el mismo dominio que la página principal, es común escuchar eventos o acceder a las propiedades en el mismo dominio de los límites de los documentos. Por ejemplo, repasemos una variación del ejemplo de visualizador de presentaciones del principio de esta guía. El visualizador abre una segunda ventana para mostrar las notas del orador. La ventana de notas del orador detecta eventos click como 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 showNotes() creó más arriba. No hay ningún controlador de eventos que esté escuchando para detectar que se cerró la ventana, por lo que nada informa a nuestro código que debe limpiar todas las referencias al documento. La función nextSlide() todavía está "publicada" porque está vinculada como controlador de clics en nuestra página principal. El hecho de que nextSlide contenga una referencia a notesWindow significa que todavía se hace referencia a la ventana y no se puede recolectar elementos no utilizados.

Ilustración de cómo las referencias a una ventana evitan que se recolecte como elemento no utilizado una vez cerrada.

Hay otras situaciones en las que se retienen accidentalmente las referencias que evitan que las ventanas desconectadas 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 prevista, lo que provocaría referencias accidentales al documento y al iframe que se conserven después de que se hayan limpiado otras referencias.

  • Un documento con mucha memoria cargado en una ventana o un iframe puede quedar accidentalmente en la memoria mucho después de navegar a una nueva URL. A menudo, esto se debe a que la página principal 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 igual de importante evitar retener referencias a objetos de otras ventanas y evitar tener 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>
    

Detectar fugas de memoria causadas por ventanas separadas

Rastrear fugas de memoria puede ser complicado. A menudo es difícil construir reproducciones aisladas de estos problemas, en especial cuando se utilizan varios documentos o ventanas. Para complicar el proceso, inspeccionar las posibles referencias filtradas puede terminar creando referencias adicionales que evitan que los objetos inspeccionados se recolecten como elementos no utilizados. Con ese fin, es útil comenzar con herramientas que eviten específicamente introducir esta posibilidad.

Un excelente lugar 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 utilizada actualmente por una aplicación: todos los objetos que se crearon, pero que aún no se recolectaron como elementos no utilizados. Las instantáneas del montón contienen información útil sobre los objetos, incluidos 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 las Herramientas para desarrolladores de Chrome 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 del montón, ve a la pestaña Memory en las Herramientas para desarrolladores de Chrome y selecciona Heap snapshot en la lista de tipos de generación de perfiles disponibles. Una vez que finalizó el registro, la vista Summary muestra los objetos actuales en memoria, agrupados por constructor.

Demostración de cómo tomar una instantánea del montón en las Herramientas para desarrolladores de Chrome.

El análisis de volcados de montón puede ser una tarea abrumadora y encontrar la información correcta como parte de la depuración puede resultar muy difícil. Para ayudar con esto, los ingenieros de Chromium yossik@ y peledni@ desarrollaron una herramienta independiente de limpiador de montón que puede ayudar a destacar un nodo específico, como una ventana separada. Cuando se ejecuta Heap Cleaner en un registro, se 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.

Cómo medir la memoria de manera programática

Las instantáneas del montón proporcionan un alto nivel de detalle y son excelentes para determinar dónde se producen las fugas, pero tomar una instantánea del 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 Herramientas para desarrolladores a medida que se crea, se 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, es decir, no incluye la memoria usada por el documento y los recursos de la ventana emergente. Para ver el panorama completo, debemos usar la nueva API de performance.measureUserAgentSpecificMemory() que se está probando en Chrome.

Soluciones para evitar filtraciones de ventanas separadas

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

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 conservan las referencias al documento de la ventana emergente y no se registran controladores de eventos en ella. 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 ella 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 de ese controlador a popup significa que no se puede recolectar elementos no utilizados.

Solución: Referencias no establecidas

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, la asignación de un valor nuevo a las variables quita su referencia al objeto original. Para “anular” 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 cerrar para que "sin configurar" 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 un problema adicional específico de las ventanas creadas con open(): ¿qué pasa si el usuario cierra la ventana en lugar de hacer clic en el botón de cierre personalizado? Además, ¿qué pasa si el usuario comienza a navegar a otros sitios web en la ventana que abrimos? Si bien originalmente parecía suficiente desactivar la referencia de popup cuando se hacía clic en el botón de cerrar, aún hay 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 a fin de desactivar las referencias persistentes cuando ocurren.

Solución: Monitorear y eliminar

En muchas situaciones, el 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 la navegación a un documento nuevo puede hacer que el documento que antes contenía una ventana o un marco se separe. En ambos casos, el navegador activa un evento pagehide para indicar que se está descargando el documento.

Se puede usar el evento pagehide para detectar ventanas cerradas y la navegación fuera del documento actual. Sin embargo, hay una salvedad importante: todos los iframes y las ventanas recién creados contienen un documento vacío y, luego, navega de forma asíncrona a la URL dada, 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 referencia debe ejecutarse cuando se descarga el documento target, debemos ignorar este primer evento pagehide. Existen varias técnicas para hacerlo. La más simple es ignorar los eventos de ocultamiento de páginas que se originan en 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 principal en la que se ejecuta nuestro código. Cuando se carga contenido de un origen diferente, los eventos location.host y pagehide no están disponibles por motivos de seguridad. Si bien generalmente es mejor evitar mantener referencias a otros orígenes, en los casos excepcionales en que esto sea necesario, es posible supervisar las propiedades window.closed o frame.isConnected. Cuando estas propiedades cambian para indicar que se cerró la ventana o se quitó un iframe, se recomienda desactivar todas las referencias a ella.

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 separado que proporciona un método .deref() especial que muestra una referencia al objeto, siempre que 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 la recolección de elementos no utilizados. En lugar de conservar una referencia a la ventana que debe quitarse manualmente 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 recolectar 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 la referencia suele permanecer 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 continúa mostrando un valor hasta que se recolecta su objeto asociado como elemento no utilizado, lo que ocurre de forma asíncrona en JavaScript y, por lo general, durante el tiempo de inactividad. Afortunadamente, cuando se comprueba si hay ventanas separadas en el panel Memory de las Herramientas para desarrolladores de Chrome, tomar una instantánea del montón en realidad activa la recolección de elementos no utilizados y elimina la ventana con referencias débiles. También es posible verificar que un objeto al que se hace referencia a través de WeakRef se haya eliminado de JavaScript. Para ello, se puede detectar cuando deref() muestra undefined o usar 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 mediante postMessage

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

Hay un enfoque alternativo más integral disponible que evita las referencias inactivas entre ventanas y documentos: establecer una separación limitando la comunicación entre documentos a postMessage(). Si nos volvemos al ejemplo original de las notas del presentador, funciones como nextSlide() actualizaron la ventana de notas directamente haciendo referencia a ella y manipulando su contenido. En cambio, la página principal podría pasar la información necesaria a la ventana de notas de manera indirecta y asíncrona 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 todavía requiere que las ventanas se hagan referencia entre sí, ninguna conserva una referencia al documento actual de otra ventana. Un enfoque para pasar mensajes también fomenta los diseños en los que las referencias de las ventanas se mantienen en un solo lugar, lo que significa que solo se debe desactivar una referencia cuando se cierran o salen de las ventanas. En el ejemplo anterior, solo showNotes() conserva una referencia a la ventana de notas y usa el evento pagehide para garantizar que se borre la referencia.

Solución: Evita referencias con noopener

En los casos en los que se abra una ventana emergente con la que tu página no necesite comunicarse ni controlarla, tal vez puedas evitar obtener una referencia a la ventana. Esto es muy útil cuando creas 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() muestre null, lo que hace que sea 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 solucionar 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, me encantaría saberlo. Puedes encontrarme en Twitter: @_developit.