Vazamentos de memória da janela removida

Encontre e corrija vazamentos de memória complicados causados por janelas separadas.

Bartek Nowierski
Bartek Nowierski

O que é um vazamento de memória em JavaScript?

Um vazamento de memória é um aumento não intencional na quantidade de memória usada por um aplicativo ao longo do tempo. No JavaScript, os vazamentos de memória acontecem quando os objetos não são mais necessários, mas ainda são referenciados por funções ou outros objetos. Essas referências impedem que os objetos desnecessários sejam recuperados pelo coletor de lixo.

O trabalho do coletor de lixo é identificar e recuperar objetos que não podem mais ser acessados pelo aplicativo. Isso funciona mesmo quando os objetos se referenciam ou se referenciam ciclicamente. Quando não há referências restantes por meio das quais um aplicativo possa acessar um grupo de objetos, ele pode ser coletado.

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.

Uma classe particularmente complicada de vazamento de memória ocorre quando um aplicativo faz referência a objetos que têm o próprio ciclo de vida, como elementos DOM ou janelas pop-up. É possível que esses tipos de objetos fiquem sem uso sem que o aplicativo saiba, o que significa que o código do aplicativo pode ter as únicas referências restantes a um objeto que poderia ser coletado.

O que é uma janela separada?

No exemplo a seguir, um aplicativo de visualização de apresentação de slides inclui botões para abrir e fechar um pop-up de anotações do apresentador. Imagine que um usuário clica em Mostrar anotações e fecha a janela pop-up diretamente, em vez de clicar no botão Ocultar anotações. A variável notesWindow ainda mantém uma referência ao pop-up que pode ser acessado, mesmo que ele não esteja mais em 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 é um exemplo de janela separada. A janela pop-up foi fechada, mas nosso código tem uma referência a ela que impede que o navegador a destrua e recupere a memória.

Quando uma página chama window.open() para criar uma nova janela ou guia do navegador, um objeto Window é retornado, representando a janela ou guia. Mesmo depois que uma janela é fechada ou o usuário a fecha, o objeto Window retornado de window.open() ainda pode ser usado para acessar informações sobre ela. Esse é um tipo de janela separada: como o código JavaScript ainda pode acessar propriedades no objeto Window fechado, ele precisa ser mantido na memória. Se a janela incluir muitos objetos JavaScript ou iframes, essa memória não poderá ser recuperada até que não haja mais referências JavaScript às propriedades da janela.

Usando o Chrome DevTools para demonstrar como é possível reter um documento depois que uma janela foi fechada.

O mesmo problema também pode ocorrer ao usar elementos <iframe>. Os iFrames se comportam como janelas aninhadas que contêm documentos, e a propriedade contentWindow deles fornece acesso ao objeto Window contido, semelhante ao valor retornado por window.open(). O código JavaScript pode manter uma referência a um contentWindow ou contentDocument do iframe, mesmo que o iframe seja removido do DOM ou que o URL seja alterado, o que impede que o documento seja coletado como lixo, já que as propriedades ainda podem ser acessadas.

Demonstração de como um manipulador de eventos pode reter o documento de um iframe, mesmo depois de navegar pelo iframe para um URL diferente.

Nos casos em que uma referência ao document em uma janela ou iframe é mantida pelo JavaScript, esse documento é mantido na memória, mesmo que a janela ou o iframe naveguem para um novo URL. Isso pode ser particularmente problemático quando o JavaScript que contém essa referência não detecta que a janela/frame navegou para um novo URL, já que ele não sabe quando se torna a última referência que mantém um documento na memória.

Como janelas desconectadas causam vazamentos de memória

Ao trabalhar com janelas e iframes no mesmo domínio da página principal, é comum detectar eventos ou acessar propriedades em limites de documentos. Por exemplo, vamos analisar uma variação do exemplo do visualizador de apresentação do início deste guia. O espectador abre uma segunda janela para mostrar as anotações do apresentador. A janela de anotações do orador detecta eventos click como uma sugestão para passar para o próximo slide. Se o usuário fechar essa janela, o JavaScript executado na janela mãe original ainda terá acesso total ao documento de anotações do 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>

Imagine que fechamos a janela do navegador criada por showNotes() acima. Não há um manipulador de eventos detectando que a janela foi fechada. Portanto, nada informa ao código que ele precisa limpar todas as referências ao documento. A função nextSlide() ainda está "ativa" porque está vinculada como um gerenciador de cliques na nossa página principal. O fato de nextSlide conter uma referência a notesWindow significa que a janela ainda está referenciada e não pode ser coletada.

Ilustração de como as referências a uma janela impedem que ela seja coletada pelo garbage collector depois de fechada.

Há vários outros cenários em que as referências são retidas acidentalmente, o que impede que janelas separadas sejam qualificadas para a coleta de lixo:

  • Os manipuladores de eventos podem ser registrados no documento inicial de um iframe antes que o frame navegue para o URL pretendido, resultando em referências acidentais ao documento e ao iframe que persistem depois que outras referências foram limpas.

  • Um documento com muita memória carregado em uma janela ou iframe pode ser mantido acidentalmente na memória por muito tempo após a navegação para um novo URL. Isso geralmente é causado pela página mãe que retém referências ao documento para permitir a remoção do listener.

  • Ao transmitir um objeto JavaScript para outra janela ou iframe, a cadeia de protótipos do objeto inclui referências ao ambiente em que ele foi criado, incluindo a janela que o criou. Isso significa que é tão importante evitar manter referências a objetos de outras janelas quanto evitar manter referências às próprias janelas.

    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>
    

Como detectar vazamentos de memória causados por janelas separadas

Rastrear vazamentos de memória pode ser complicado. Muitas vezes, é difícil reproduzir esses problemas de forma isolada, principalmente quando há vários documentos ou janelas envolvidos. Para complicar as coisas, a inspeção de possíveis referências com vazamento pode acabar criando outras referências que impedem que os objetos inspecionados sejam coletados. Para isso, é útil começar com ferramentas que evitam especificamente essa possibilidade.

Um ótimo lugar para começar a depurar problemas de memória é fazer um snapshot de heap. Isso fornece uma visualização pontual da memória usada atualmente por um aplicativo, ou seja, todos os objetos que foram criados, mas ainda não foram coletados. Os snapshots de pilha contêm informações úteis sobre objetos, incluindo o tamanho deles e uma lista das variáveis e dos fechamentos que os referenciam.

Uma captura de tela de um instantâneo de heap no Chrome DevTools mostrando as referências que retêm um objeto grande.
Snapshot de heap mostrando as referências que retêm um objeto grande.

Para registrar um resumo de pilha, acesse a guia Memória no Chrome DevTools e selecione Resumo de pilha na lista de tipos de criação de perfil disponíveis. Quando a gravação for concluída, a visualização Resumo vai mostrar os objetos atuais na memória, agrupados por construtor.

Demonstração de como fazer um resumo de pilha no Chrome DevTools.

Analisar despejos de heap pode ser uma tarefa assustadora, e pode ser bastante difícil encontrar as informações corretas como parte da depuração. Para ajudar nisso, os engenheiros do Chromium yossik@ e peledni@ desenvolveram uma ferramenta independente Heap Cleaner que pode ajudar a destacar um nó específico, como uma janela separada. A execução do Limpador de heap em um rastro remove outras informações desnecessárias do gráfico de retenção, o que torna o rastro mais limpo e muito mais fácil de ler.

Medir a memória de forma programática

Os snapshots de heap fornecem um alto nível de detalhes e são excelentes para descobrir onde ocorrem vazamentos, mas fazer um snapshot de heap é um processo manual. Outra maneira de verificar vazamentos de memória é conferir o tamanho do heap do JavaScript usado atualmente na API performance.memory:

Captura de tela de uma seção da interface do usuário do Chrome DevTools.
Verificação do tamanho do heap do JS usado no DevTools quando um pop-up é criado, fechado e sem referência.

A API performance.memory só fornece informações sobre o tamanho do heap do JavaScript, o que significa que ela não inclui a memória usada pelo documento e pelos recursos do pop-up. Para ter uma visão completa, precisamos usar a nova API performance.measureUserAgentSpecificMemory(), que está sendo testada no Chrome.

Soluções para evitar vazamentos de janela

Os dois casos mais comuns em que janelas separadas causam vazamentos de memória são quando o documento pai mantém referências a um pop-up fechado ou um iframe removido e quando a navegação inesperada de uma janela ou iframe resulta em manipuladores de eventos que nunca são registrados.

Exemplo: fechar um pop-up

No exemplo abaixo, dois botões são usados para abrir e fechar uma janela pop-up. Para que o botão Close Popup funcione, uma referência à janela pop-up aberta é armazenada em uma variável:

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

À primeira vista, parece que o código acima evita armadilhas comuns: nenhuma referência ao documento do pop-up é retida, e nenhum manipulador de eventos é registrado na janela do pop-up. No entanto, quando o botão Open Popup é clicado, a variável popup agora faz referência à janela aberta, e essa variável pode ser acessada no escopo do gerenciador de cliques do botão Close Popup. A menos que popup seja reatribuída ou o gerenciador de cliques seja removido, a referência do gerenciador a popup significa que ele não pode ser coletado.

Solução: referências não definidas

Variáveis que fazem referência a outra janela ou ao documento dela fazem com que ela seja retida na memória. Como os objetos no JavaScript são sempre referências, atribuir um novo valor a variáveis remove a referência ao objeto original. Para "redefinir" as referências a um objeto, podemos reatribuir essas variáveis ao valor null.

Aplicando isso ao exemplo de pop-up anterior, podemos modificar o gerenciador do botão de fechar para que ele "redefinir" a referência à janela pop-up:

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

Isso ajuda, mas revela outro problema específico das janelas criadas usando open(): e se o usuário fechar a janela em vez de clicar no botão de fechamento personalizado? Além disso, e se o usuário começar a navegar para outros sites na janela que abrimos? Embora originalmente parecesse suficiente redefinir a referência popup ao clicar no botão de fechamento, ainda há um vazamento de memória quando os usuários não usam esse botão específico para fechar a janela. Para resolver isso, é necessário detectar esses casos para redefinir as referências persistentes quando elas ocorrerem.

Solução: monitorar e descartar

Em muitas situações, o JavaScript responsável por abrir janelas ou criar frames não tem controle exclusivo sobre o ciclo de vida deles. Os pop-ups podem ser fechados pelo usuário, ou a navegação para um novo documento pode fazer com que o documento anteriormente contido por uma janela ou um frame seja desconectado. Em ambos os casos, o navegador dispara um evento pagehide para sinalizar que o documento está sendo removido.

O evento pagehide pode ser usado para detectar janelas fechadas e navegação para longe do documento atual. No entanto, há uma ressalva importante: todas as janelas e iframes recém-criados contêm um documento vazio e, em seguida, navegam de forma assíncrona para o URL fornecido, se fornecido. Como resultado, um evento pagehide inicial é acionado logo após a criação da janela ou do frame, pouco antes do documento alvo ser carregado. Como nosso código de limpeza de referência precisa ser executado quando o documento alvo é descarregado, precisamos ignorar esse primeiro evento pagehide. Há várias técnicas para fazer isso, e a mais simples é ignorar eventos de ocultação de página originados do URL about:blank do documento inicial. Confira como ficaria no nosso exemplo 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;
  });
};

É importante observar que essa técnica só funciona para janelas e frames que têm a mesma origem eficaz da página pai em que o código está sendo executado. Ao carregar conteúdo de uma origem diferente, o location.host e o evento pagehide ficam indisponíveis por motivos de segurança. Em geral, é melhor evitar manter referências a outras origens, mas, nos raros casos em que isso é necessário, é possível monitorar as propriedades window.closed ou frame.isConnected. Quando essas propriedades mudarem para indicar uma janela fechada ou um iframe removido, é recomendável redefinir todas as referências a ele.

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

Solução: use WeakRef

Recentemente, o JavaScript ganhou suporte a uma nova maneira de fazer referência a objetos que permite a coleta de lixo, chamada WeakRef. Uma WeakRef criada para um objeto não é uma referência direta, mas um objeto separado que fornece um método .deref() especial que retorna uma referência ao objeto, desde que ele não tenha sido coletado. Com WeakRef, é possível acessar o valor atual de uma janela ou documento, permitindo que ele seja coletado de forma automática. Em vez de reter uma referência à janela que precisa ser desfeita manualmente em resposta a eventos como pagehide ou propriedades como window.closed, o acesso à janela é recebido conforme necessário. Quando a janela é fechada, ela pode ser coletada, fazendo com que o método .deref() comece a retornar 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>

Um detalhe interessante a considerar ao usar WeakRef para acessar janelas ou documentos é que a referência geralmente permanece disponível por um curto período de tempo após a janela ser fechada ou o iFrame removido. Isso acontece porque WeakRef continua retornando um valor até que o objeto associado seja coletado, o que acontece de forma assíncrona no JavaScript e geralmente durante o tempo de inatividade. Felizmente, ao verificar janelas separadas no painel Memory do Chrome DevTools, a captura de um resumo de pilha aciona a coleta de lixo e descarta a janela com referência fraca. Também é possível verificar se um objeto referenciado por WeakRef foi descartado do JavaScript, detectando quando deref() retorna undefined ou usando a nova 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());

Solução: comunicar por postMessage

A detecção de quando as janelas são fechadas ou a navegação descarrega um documento nos dá uma maneira de remover gerenciadores e redefinir referências para que as janelas separadas possam ser coletadas. No entanto, essas mudanças são correções específicas para o que às vezes pode ser uma preocupação mais fundamental: o acoplamento direto entre páginas.

Uma abordagem alternativa mais holística está disponível para evitar referências desatualizadas entre janelas e documentos: estabelecer a separação limitando a comunicação entre documentos a postMessage(). Voltando ao exemplo original de anotações do apresentador, funções como nextSlide() atualizavam a janela de anotações diretamente, referenciando-a e manipulando o conteúdo. Em vez disso, a página principal poderia transmitir as informações necessárias para a janela de notas de forma assíncrona e indireta por 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;

Embora isso ainda exija que as janelas se refiram umas às outras, nenhuma delas mantém uma referência ao documento atual de outra janela. Uma abordagem de transmissão de mensagens também incentiva designs em que as referências de janela são mantidas em um único lugar, o que significa que apenas uma referência precisa ser desfeita quando as janelas são fechadas ou navegadas. No exemplo acima, apenas showNotes() mantém uma referência à janela de notas e usa o evento pagehide para garantir que a referência seja limpa.

Solução: evite referências usando noopener

Nos casos em que uma janela pop-up é aberta e sua página não precisa se comunicar ou controlar, é possível evitar a referência à janela. Isso é particularmente útil ao criar janelas ou iframes que vão carregar conteúdo de outro site. Nesses casos, window.open() aceita uma opção "noopener" que funciona exatamente como o atributo rel="noopener" para links HTML:

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

A opção "noopener" faz com que window.open() retorne null, o que torna impossível armazenar acidentalmente uma referência ao pop-up. Isso também impede que a janela pop-up receba uma referência à janela pai, já que a propriedade window.opener será null.

Feedback

Esperamos que algumas das sugestões deste artigo ajudem a encontrar e corrigir vazamentos de memória. Se você tiver outra técnica para depurar janelas separadas ou se este artigo ajudou a descobrir vazamentos no app, adoraríamos saber. Você pode me encontrar no Twitter @_developit.