Vazamentos de memória da janela removida

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

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. Em JavaScript, os vazamentos de memória ocorrem quando os objetos não são mais necessários, mas ainda são referenciados por funções ou outros objetos. Essas referências evitam que objetos desnecessários sejam recuperados pelo coletor de lixo.

A função do coletor de lixo é identificar e recuperar objetos que não podem mais ser acessados pelo aplicativo. Isso funciona mesmo quando os objetos fazem referência a si mesmos ou ciclicamente. Quando não há mais referências pelas quais um aplicativo pode acessar um grupo de objetos, ele pode ser coletado como lixo.

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 especialmente complicada de vazamento de memória ocorre quando um app faz referência a objetos com ciclo de vida próprio, como elementos DOM ou janelas pop-up. É possível que esses tipos de objetos não sejam usados 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 como lixo.

O que é uma janela independente?

No exemplo abaixo, um app de visualização de slides inclui botões para abrir e fechar um pop-up de notas do apresentador. Imagine que um usuário clica em Show Notes e fecha a janela pop-up diretamente em vez de clicar no botão Hide Notes. A variável notesWindow ainda conté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 uma janela não independente. A janela pop-up foi fechada, mas nosso código tem uma referência a ela que impede que o navegador possa destruí-la e recuperar a memória.

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

Uso do Chrome DevTools para demonstrar como é possível reter um documento depois que uma janela é 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, muito parecido com o valor retornado por window.open(). O código JavaScript pode manter uma referência a contentWindow ou contentDocument de um iframe mesmo se o iframe for removido do DOM ou se o URL dele mudar, o que impede que o documento seja coletado como lixo, porque as propriedades ainda podem ser acessadas.

Demonstração de como um manipulador de eventos pode reter um documento de 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 é retida do JavaScript, esse documento vai ser mantido na memória, mesmo que a janela ou o iframe que o contenha navegue para um novo URL. Isso pode ser particularmente incômodo quando o JavaScript que contém essa referência não detecta que a janela/frame levou a um novo URL, já que não sabe quando ele se torna a última referência a manter um documento na memória.

Como janelas removidas 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 além dos limites do documento. Por exemplo, vamos revisitar uma variação do exemplo do visualizador de apresentações do início deste guia. O visualizador abre uma segunda janela para exibir as anotações do apresentador. A janela de anotações do apresentador detecta eventos click para avançar para o próximo slide. Se o usuário fechar essa janela de anotações, o JavaScript em execução na janela mãe original ainda terá acesso total ao documento de anotações do apresentador:

<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 pelo showNotes() acima. Nenhum manipulador de eventos detecta que a janela foi fechada. Portanto, nada informa ao nosso código que ele precisa limpar 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, e o fato de nextSlide conter uma referência a notesWindow significa que a janela ainda é referenciada e não pode ser coletada como lixo.

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

Há vários outros cenários em que as referências são retidas por acidente, impedindo que janelas desconectadas sejam qualificadas para a coleta de lixo:

  • Os manipuladores de eventos podem ser registrados no documento inicial de um iframe antes do frame navegar até o URL pretendido, resultando em referências acidentais ao documento e ao iframe persistindo após a limpeza de outras referências.

  • Um documento com uso intenso de memória carregado em uma janela ou um iframe pode ser acidentalmente mantido na memória por muito tempo depois de acessar um novo URL. Geralmente, isso é causado porque a página pai retém as 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 referências a objetos de outras janelas quanto a elas.

    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 desconectadas

Rastrear vazamentos de memória pode ser complicado. Muitas vezes, é difícil criar reproduções isoladas desses problemas, especialmente quando há vários documentos ou janelas envolvidos. Para complicar o processo, a inspeção de possíveis referências vazadas pode acabar criando outras referências que evitem que os objetos inspecionados sejam coletados como lixo. Para isso, é útil começar com ferramentas que evitem especificamente essa possibilidade.

Um ótimo lugar para começar a depurar problemas de memória é tirar um snapshot do heap. Isso fornece uma visualização pontual da memória atualmente usada por um aplicativo, com todos os objetos criados, mas que ainda não foram coletados como lixo. Esses snapshots contêm informações úteis sobre objetos, incluindo o tamanho deles e uma lista de variáveis e fechamentos que fazem referência a eles.

Captura de tela de um instantâneo de heap no Chrome DevTools mostrando as referências que retêm um objeto grande.
Um snapshot de heap mostrando as referências que retêm um objeto grande.

Para gravar um snapshot de heap, acesse a guia Memória no Chrome DevTools e selecione Snapshot de heap na lista de tipos de criação de perfil disponíveis. Quando a gravação terminar, a visualização Resumo mostrará os objetos atuais na memória, agrupados por construtor.

Demonstração de como criar um snapshot de heap no Chrome DevTools.

A análise de heap dumps pode ser uma tarefa desafiadora, além de ser bastante difícil encontrar as informações corretas como parte da depuração. Para ajudar com isso, os engenheiros do Chromium yossik@ e peledni@ desenvolveram uma ferramenta autônoma Heap Cleaner que pode ajudar a destacar um nó específico, como uma janela separada. A execução do Heap Cleaner em um trace remove outras informações desnecessárias do gráfico de retenção, o que torna o trace mais limpo e muito mais fácil de ler.

Medir a memória de maneira programática

Eles fornecem um alto nível de detalhes e são excelentes para descobrir onde ocorrem os vazamentos. No entanto, isso é um processo manual. Outra maneira de verificar se há vazamentos de memória é acessar o tamanho de heap do JavaScript usado pela API performance.memory:

Captura de tela de uma seção da interface do usuário do Chrome DevTools.
Verifique o tamanho de 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 de 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 o panorama completo, precisamos usar a nova API performance.measureUserAgentSpecificMemory() que está sendo testada no Chrome.

Soluções para evitar vazamentos de janelas desconectadas

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

Exemplo: fechar um pop-up

No exemplo a seguir, 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 é mantida e nenhum manipulador de eventos é registrado na janela pop-up. No entanto, depois que o botão Open Popup é clicado, a variável popup passa a referenciar a janela aberta, e essa variável fica acessível no escopo do gerenciador de cliques do botão Close Popup. A menos que popup seja reatribuído ou o gerenciador de cliques seja removido, a referência incluída nele a popup significa que ele não pode ser coletado da lixeira.

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

Variáveis que referenciam outra janela ou o documento dela fazem com que ele seja retido na memória. Como os objetos em JavaScript são sempre referências, atribuir um novo valor às variáveis remove a referência ao objeto original. Para "cancelar" 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 "Fechar" para "desdefinir" 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 para janelas criadas usando open(): e se o usuário fechar a janela em vez de clicar no nosso botão "Fechar" personalizado? Além disso, e se o usuário começar a navegar para outros sites na janela que abrimos? Embora originalmente pareça suficiente desativar a referência popup ao clicar no botão "Fechar", 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 cancelar a definição de 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 delas. 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 removido. Nos dois casos, o navegador dispara um evento pagehide para sinalizar que o documento está sendo descarregado.

O evento pagehide pode ser usado para detectar janelas fechadas e sair 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 maneira assíncrona até o URL determinado, se fornecido. Como resultado, um evento pagehide inicial é disparado logo após a criação da janela ou do frame, logo antes do carregamento do documento de destino. Como nosso código de limpeza de referência precisa ser executado quando o documento de target é descarregado, precisamos ignorar esse primeiro evento pagehide. Existem várias técnicas para fazer isso, a mais simples delas é ignorar eventos pagehide originários do URL about:blank do documento inicial. Confira como ele ficaria em nosso exemplo de janela 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;
  });
};

Essa técnica só funciona para janelas e frames que têm a mesma origem efetiva que a página principal em que o código é executado. Ao carregar conteúdo de uma origem diferente, location.host e o evento pagehide ficam indisponíveis por motivos de segurança. Embora geralmente seja melhor evitar referências a outras origens, nos raros casos em que isso é necessário, é possível monitorar as propriedades window.closed ou frame.isConnected. Quando essas propriedades mudam para indicar uma janela fechada ou um iframe removido, é recomendável desativar as referências a eles.

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

Solução: usar o WeakRef

Recentemente, o JavaScript ganhou suporte a uma nova maneira de referenciar objetos que permite a coleta de lixo, chamada WeakRef. Um WeakRef criado 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 da lixeira. Com WeakRef, é possível acessar o valor atual de uma janela ou documento e, ao mesmo tempo, permitir que ele seja coletado como lixo. Em vez de manter uma referência à janela que precisa ser desativada 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 como lixo, 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 se considerar ao usar WeakRef para acessar janelas ou documentos é que a referência geralmente permanece disponível por um curto período depois que a janela é fechada ou o iframe é removido. Isso ocorre porque WeakRef continua retornando um valor até que o objeto associado tenha sido coletado como lixo, o que acontece de forma assíncrona em JavaScript e geralmente durante o tempo de inatividade. Felizmente, ao verificar se há janelas removidas no painel Memória do Chrome DevTools, capturar um snapshot de heap 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: comunique-se por postMessage

Detectar quando janelas são fechadas ou quando a navegação descarrega um documento oferece uma maneira de remover gerenciadores e referências não definidas para que as janelas removidas possam ser coletadas como lixo. 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 as páginas.

Está disponível uma abordagem alternativa mais holística que evita referências desatualizadas entre janelas e documentos: estabelecendo a separação limitando a comunicação entre documentos a postMessage(). Pensando no exemplo original das notas de apresentador, funções como nextSlide() atualizaram a janela de anotações diretamente, referenciando e manipulando o conteúdo. Em vez disso, a página principal pode 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 façam referência 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 janelas são mantidas em um único local, o que significa que apenas uma referência precisa ser desmarcada quando as janelas são fechadas ou saem do site. No exemplo acima, apenas showNotes() reté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 nem controlar, talvez você possa evitar nunca receber uma referência à janela. Isso é particularmente útil ao criar janelas ou iframes que carregam conteúdo de outro site. Nesses casos, window.open() aceita uma opção "noopener" que funciona 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, tornando impossível armazenar acidentalmente uma referência ao pop-up. Ele também impede que a janela pop-up receba uma referência à janela mãe, 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 removidas ou se este artigo ajudou a descobrir vazamentos no seu app, adoraria saber! Entre em contato pelo Twitter @_developit.