Aguarde...

7 de julho de 2021

Causas de vazamentos de memória em JavaScript e como evitá-los

Causas de vazamentos de memória em JavaScript e como evitá-los

O cuidado insuficiente com o gerenciamento de memória geralmente não produz consequências dramáticas quando se trata de páginas da Web “antiquadas”. Enquanto o usuário navega pelos links e carrega novas páginas, as informações da página são removidas da memória a cada carregamento.

O surgimento do SPA (Single Page Application) nos motiva a prestar mais atenção às práticas de codificação JavaScript relacionadas à memória. Se o aplicativo começar a usar progressivamente mais e mais memória, isso pode afetar seriamente o desempenho e até mesmo fazer com que a guia do navegador trave.

Neste artigo, exploraremos os padrões de programação que causam vazamentos de memória em JavaScript e explicaremos como melhorar o gerenciamento de memória.

O que é um vazamento de memória e como identificá-lo?

O navegador mantém os objetos na memória heap enquanto eles podem ser alcançados da raiz através da cadeia de referência. Coletor de lixo é um processo em segundo plano no mecanismo JavaScript que identifica objetos inacessíveis, remove-os e recupera a memória subjacente.

Causas de vazamentos de memória em JavaScript e como evitá-los
Um exemplo de uma cadeia de referência de uma raiz de coletor de lixo para os objetos

Um vazamento de memória ocorre quando um objeto na memória que deveria ser limpo em um ciclo de coleta de lixo permanece acessível a partir da raiz por meio de uma referência não intencional por outro objeto. Manter objetos redundantes na memória resulta em uso excessivo de memória dentro do aplicativo e pode levar à degradação e desempenho insatisfatório.

Causas de vazamentos de memória em JavaScript e como evitá-los
O objeto 4 não está acessível e será removido da memória. O objeto 3 ainda pode ser acessado por meio da referência esquecida do objeto 2 e não será coletado como lixo.

Como descobrir que nosso código está perdendo memória? Bem, os vazamentos de memória são sorrateiros e muitas vezes difíceis de perceber e localizar. O código JavaScript que está vazando não é considerado inválido de forma alguma, e o navegador não gerará nenhum erro ao executá-lo. Se notarmos que o desempenho de nossa página está piorando progressivamente, as ferramentas integradas do navegador podem nos ajudar a determinar se existe um vazamento de memória e quais objetos o causam.

A maneira mais rápida de verificar o uso da memória é dar uma olhada nos gerenciadores de tarefas do navegador (não deve ser confundida com o gerenciador de tarefas do sistema operacional). Eles nos fornecem uma visão geral de todas as guias e processos atualmente em execução no navegador. O gerenciador de tarefas do Chrome pode ser acessado pressionando Shift + Esc no Linux e Windows, enquanto o integrado no Firefox digitando about: performance na barra de endereço. Entre outras coisas, eles nos permitem ver a pegada de memória JavaScript de cada guia. Se nosso site está parado sem fazer nada, mas ainda assim, o uso de memória JavaScript está aumentando gradualmente, há uma boa chance de que haja um vazamento de memória.

Ferramentas de desenvolvedor estão fornecendo métodos de gerenciamento de memória mais avançados Ao gravar na ferramenta de desempenho do Chrome, podemos analisar visualmente o desempenho de uma página durante sua execução. Alguns padrões são típicos de vazamentos de memória, como o padrão de aumento do uso de memória heap mostrado abaixo.

Causas de vazamentos de memória em JavaScript e como evitá-los
Registro de desempenho no Chrome – o consumo de memória heap está crescendo continuamente (linha azul)

Fora isso, as Ferramentas de desenvolvedor do Chrome e do Firefox têm excelentes possibilidades para explorar ainda mais o uso da memória com a ajuda da ferramenta Memória. A comparação de instantâneos de heap consecutivos nos mostra onde e quanta memória foi alocada entre os dois instantâneos, junto com detalhes adicionais nos ajudando a identificar os objetos problemáticos no código.

Fontes comuns de vazamentos de memória em código JavaScript

Uma pesquisa pelas causas dos vazamentos de memória é, na verdade, uma pesquisa por padrões de programação que podem nos ‘enganar’ para manter as referências aos objetos que, de outra forma, seriam qualificados para a coleta de lixo. A seguir está uma lista útil de locais no código que são mais suscetíveis a vazamentos de memória e merecem consideração especial ao gerenciar a memória.

1. Variáveis ​​globais acidentais

As variáveis ​​globais estão sempre disponíveis na raiz e nunca serão coletadas como lixo. Alguns erros causam vazamento de variáveis ​​do escopo local para o escopo global quando em modo não estrito:

  • atribuindo valor à variável não declarada,
  • usando ‘this’ que aponta para o objeto global.
function createGlobalVariables() {
  leaking1 = 'I leak into the global scope'; // assigning value to the undeclared variable
  this.leaking2 = 'I also leak into the global scope'; // 'this' points to the global object
};
createGlobalVariables();
window.leaking1; // 'I leak into the global scope'
window.leaking2; // 'I also leak into the global scope'

Como evitá-lo: o modo estrito (“usar estrito”) evitará vazamentos acidentais, pois o código do exemplo gerará erros.

2. Fechamentos

As variáveis ​​com escopo de função serão limpas depois que a função tiver saído da pilha de chamadas e se não houver nenhuma referência fora da função apontando para elas. O encerramento manterá as variáveis ​​referenciadas e vivas, embora a função tenha concluído a execução e seu contexto de execução e ambiente de variável tenham desaparecido há muito tempo.

function outer() {
  const potentiallyHugeArray = [];

  return function inner() {
    potentiallyHugeArray.push('Hello'); // function inner is closed over the potentiallyHugeArray variable
    console.log('Hello');
  };
};
const sayHello = outer(); // contains definition of the function inner

function repeat(fn, num) {
  for (let i = 0; i < num; i++){
    fn();
  }
}
repeat(sayHello, 10); // each sayHello call pushes another 'Hello' to the potentiallyHugeArray 
 
// now imagine repeat(sayHello, 100000)

Neste exemplo, potentiallyHugeArraynunca é retornado de nenhuma das funções e não pode ser alcançado, mas seu tamanho pode crescer infinitamente dependendo de quantas vezes chamarmos de function inner().

Como evitá-lo: os fechamentos são inevitáveis ​​e uma parte integrante do JavaScript, por isso é importante:

  • entender quando o fechamento foi criado e quais objetos são retidos por ele,
  • entender a vida útil esperada e o uso do fechamento (especialmente se usado como um retorno de chamada).

3. Temporizadores

Ter um setTimeoutou um setIntervalreferenciando algum objeto no retorno de chamada é a maneira mais comum de evitar que o objeto seja coletado como lixo. Se definirmos o cronômetro recorrente em nosso código (podemos fazer com que setTimeoutse comporte como setInterval, por exemplo, tornando-o recursivo), a referência ao objeto do retorno de chamada do cronômetro permanecerá ativa enquanto o retorno de chamada for invocável.

No exemplo abaixo, o dataobjeto pode ser coletado como lixo somente depois que o cronômetro for zerado. Como não temos nenhuma referência a setInterval, ele nunca pode ser apagado e data.hugeStringé mantido na memória até que o aplicativo pare, embora nunca seja usado.

function setCallback() {
  const data = {
    counter: 0,
    hugeString: new Array(100000).join('x')
  };

  return function cb() {
    data.counter++; // data object is now part of the callback's scope
    console.log(data.counter);
  }
}

setInterval(setCallback(), 1000); // how do we stop it?

Como evitá-lo: especialmente se a vida útil do retorno de chamada for indefinida ou indefinida:

  • estar ciente dos objetos referenciados no retorno de chamada do temporizador,
  • usando o identificador retornado do cronômetro para cancelá-lo quando necessário.
function setCallback() {
  // 'unpacking' the data object
  let counter = 0;
  const hugeString = new Array(100000).join('x'); // gets removed when the setCallback returns
  
  return function cb() {
    counter++; // only counter is part of the callback's scope
    console.log(counter);
  }
}

const timerId = setInterval(setCallback(), 1000); // saving the interval ID

// doing something ...

clearInterval(timerId); // stopping the timer i.e. if button pressed

4. Ouvintes de eventos

O ouvinte de evento ativo impedirá que todas as variáveis ​​capturadas em seu escopo sejam coletadas como lixo. Depois de adicionado, o ouvinte de evento permanecerá em vigor até:

  • removido explicitamente com removeEventListener()
  • o elemento DOM associado é removido.

Para alguns tipos de eventos, espera-se que seja mantido até que o usuário saia da página – como botões que deveriam ser clicados várias vezes. No entanto, às vezes queremos que um ouvinte de evento execute um determinado número de vezes.

const hugeString = new Array(100000).join('x');

document.addEventListener('keyup', function() { // anonymous inline function - can't remove it
  doSomething(hugeString); // hugeString is now forever kept in the callback's scope
});

No exemplo acima, uma função embutida anônima é usada como ouvinte de evento, o que significa que não pode ser excluída com removeEventListener(). Da mesma forma, o documento não pode ser removido, portanto, ficamos presos à função de ouvinte e tudo o que ela mantém em seu escopo, mesmo que só precisemos disparar uma vez.

Como evitá-lo: devemos sempre cancelar o registro do ouvinte de evento quando não for mais necessário, criando uma referência apontando para ele e passando-o para removeEventListener().

function listener() {
  doSomething(hugeString);
}

document.addEventListener('keyup', listener); // named function can be referenced here...
document.removeEventListener('keyup', listener); // ...and here

Caso o ouvinte de eventos deva executar apenas uma vez, addEventListener()pode receber um terceiro parâmetro, que é um objeto que fornece opções adicionais. Dado que {once: true}é passado como um terceiro parâmetro para addEventListener(), a função de ouvinte será removida automaticamente após tratar o evento uma vez.

document.addEventListener('keyup', function listener() {
  doSomething(hugeString);
}, {once: true}); // listener will be removed after running once

5. Cache

Se continuarmos acrescentando memória ao cache sem nos livrar dos objetos não utilizados e sem alguma lógica que limite o tamanho, o cache pode crescer infinitamente.

let user_1 = { name: "Peter", id: 12345 };
let user_2 = { name: "Mark", id: 54321 };
const mapCache = new Map();

function cache(obj){
  if (!mapCache.has(obj)){
    const value = `${obj.name} has an id of ${obj.id}`;
    mapCache.set(obj, value);

    return [value, 'computed'];
  }

  return [mapCache.get(obj), 'cached'];
}

cache(user_1); // ['Peter has an id of 12345', 'computed']
cache(user_1); // ['Peter has an id of 12345', 'cached']
cache(user_2); // ['Mark has an id of 54321', 'computed']

console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321")
user_1 = null; // removing the inactive user

// Garbage Collector
console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321") // first entry is still in cache

No exemplo acima, o cache ainda está segurando o user_1objeto. Portanto, precisamos limpar o cache adicionalmente das entradas que nunca serão reutilizadas.

Solução possível: Para contornar esse problema, podemos usar WeakMap. É uma estrutura de dados com referências de chave fracamente mantidas que aceita apenas objetos como chaves. Se usarmos um objeto como a chave, e ele for a única referência a esse objeto – a entrada associada será removida do cache e o lixo coletado. No exemplo a seguir, após anular o user_1objeto, a entrada associada é excluída automaticamente do WeakMap após a próxima coleta de lixo.

let user_1 = { name: "Peter", id: 12345 };
let user_2 = { name: "Mark", id: 54321 };
const weakMapCache = new WeakMap();

function cache(obj){
  // ...same as above, but with weakMapCache

  return [weakMapCache.get(obj), 'cached'];
}

cache(user_1); // ['Peter has an id of 12345', 'computed']
cache(user_2); // ['Mark has an id of 54321', 'computed']
console.log(weakMapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321"}
user_1 = null; // removing the inactive user

// Garbage Collector

console.log(weakMapCache); // ((…) => "Mark has an id of 54321") - first entry gets garbage collected

6. Elementos DOM separados

Se um nó DOM tiver referências diretas de JavaScript, isso impedirá que seja coletado como lixo, mesmo depois que o nó for removido da árvore DOM.

No exemplo a seguir, criamos um divelemento e o anexamos ao document.body. O removeChild()não funciona conforme o esperado e o instantâneo de heap mostrará HTMLDivElement desanexado, pois ainda há uma variável apontando para o div.

function createElement() {
  const div = document.createElement('div');
  div.id = 'detached';
  return div;
}

// this will keep referencing the DOM element even after deleteElement() is called
const detachedDiv = createElement();

document.body.appendChild(detachedDiv);

function deleteElement() {
  document.body.removeChild(document.getElementById('detached'));
}

deleteElement(); // Heap snapshot will show detached div#detached

Como evitá-lo? Uma das soluções possíveis é mover as referências DOM para o escopo local. No exemplo abaixo, a variável que aponta para o elemento DOM é removida após o término da função appendElement().

function createElement() {...} // same as above

// DOM references are inside the function scope

function appendElement() {
  const detachedDiv = createElement();
  document.body.appendChild(detachedDiv);
}

appendElement();

function deleteElement() {
  document.body.removeChild(document.getElementById('detached'));
}

deleteElement(); // no detached div#detached elements in the Heap Snapshot

Conclusão de vazamentos de memória JS

Ao lidar com aplicativos não triviais, identificar e corrigir problemas e vazamentos de memória JavaScript pode se tornar uma tarefa altamente desafiadora. Por esse motivo, a parte integrante do processo de gerenciamento de memória é entender as fontes típicas de vazamento de memória para evitar que aconteçam. No final das contas, quando se trata de memória e desempenho, é a experiência do usuário que está em jogo, e isso é o que mais importa.

Postado em Blog
Escreva um comentário