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