Encontre e corrija vazamentos de memória complicados causados por janelas separadas.
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.
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.
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.
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.
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.
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
:
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.