Memory leaks и их предотвращение

Утечка памяти (memory leak) возникает в ситуации, когда объекты продолжают оставаться доступными для сборщика мусора JavaScript даже после того, как необходимость в них исчезла. Со временем такие объекты накапливаются, увеличивая потребление оперативной памяти, замедляя работу интерфейса и приводя к деградации производительности.

В проектах на базе Kepler.gl проблема особенно актуальна по нескольким причинам:

  • работа с большими геоданными;
  • активное использование React-компонентов;
  • хранение крупных наборов данных в Redux Store;
  • взаимодействие с WebGL;
  • большое количество подписок, обработчиков событий и асинхронных операций.

При длительной работе приложения даже небольшие утечки могут привести к потреблению сотен мегабайт дополнительной памяти.


Особенности управления памятью в Kepler.gl

Архитектурно Kepler.gl строится вокруг нескольких крупных подсистем:

  • React;
  • Redux;
  • deck.gl;
  • MapLibre или Mapbox GL;
  • WebGL-контекстов.

Каждая из них имеет собственные механизмы выделения ресурсов.

Основные категории объектов, занимающих память:

  • наборы геоданных;
  • слои визуализации;
  • текстуры WebGL;
  • буферы вершин;
  • кэшированные данные;
  • объекты состояния Redux;
  • временные результаты фильтрации;
  • обработчики событий.

Удаление React-компонента само по себе не гарантирует освобождение всех связанных ресурсов. Если остаются ссылки на данные, сборщик мусора не сможет освободить память.


Утечки через Redux Store

Одним из наиболее распространённых источников утечек является глобальное хранилище состояния.

Неправильное накопление данных

Проблемный пример:

dispatch(addDataToMap({
  datasets: {
    info: {
      id: 'dataset'
    },
    data: largeDataset
  }
}));

Если пользователь многократно загружает новые наборы данных, старые объекты могут оставаться в состоянии приложения.

Например:

state.datasets.push(newDataset);

При отсутствии механизма очистки память будет постоянно расти.

Правильнее использовать замену данных:

state.dataset = newDataset;

или удаление старых наборов:

dispatch(removeDataset(datasetId));

Избыточная история состояния

Некоторые проекты реализуют собственные механизмы Undo/Redo.

Опасный вариант:

history.push(currentState);

Если состояние содержит крупные GeoJSON-объекты, каждый снимок состояния может занимать десятки мегабайт.

Лучше сохранять:

  • только изменения;
  • минимальный набор данных;
  • ссылки на идентификаторы.

Например:

history.push({
  filterId,
  previousValue,
  nextValue
});

Утечки при работе с GeoJSON

Хранение дубликатов данных

Частая ошибка:

const geojsonCopy = JSON.parse(
  JSON.stringify(geojson)
);

Каждое такое копирование удваивает объём занимаемой памяти.

Особенно опасны операции внутри циклов:

datasets.forEach(dataset => {
  cache.push(
    JSON.parse(JSON.stringify(dataset))
  );
});

Для крупных файлов объём памяти может вырасти в несколько раз.

Предпочтительно использовать:

const datasetReference = geojson;

либо создавать производные структуры только при необходимости.


Накопление временных массивов

Пример:

let filteredResults = [];

function updateFilters(data) {
  filteredResults.push(
    data.features.filter(...)
  );
}

Каждый вызов сохраняет новый массив.

Корректный подход:

function updateFilters(data) {
  filteredResults =
    data.features.filter(...);
}

Утечки через React-компоненты

Неочищенные подписки

Компонент может создавать подписку на внешние события:

useEffect(() => {
  map.on('move', handleMove);
}, []);

После размонтирования компонента обработчик остаётся зарегистрированным.

Правильная реализация:

useEffect(() => {
  map.on('move', handleMove);

  return () => {
    map.off('move', handleMove);
  };
}, []);

Таймеры

Проблемный код:

useEffect(() => {
  setInterval(() => {
    updateStatistics();
  }, 1000);
}, []);

После удаления компонента интервал продолжает работать.

Необходимо очищать таймер:

useEffect(() => {
  const timer = setInterval(() => {
    updateStatistics();
  }, 1000);

  return () => {
    clearInterval(timer);
  };
}, []);

Незавершённые асинхронные запросы

Пример:

useEffect(() => {
  fetch(url)
    .then(response => response.json())
    .then(setData);
}, []);

Если компонент удалён до завершения запроса, результаты могут удерживать память.

Более безопасный вариант:

useEffect(() => {
  const controller =
    new AbortController();

  fetch(url, {
    signal: controller.signal
  });

  return () => {
    controller.abort();
  };
}, []);

Утечки при использовании deck.gl

Kepler.gl активно использует deck.gl для визуализации слоёв.

Каждый слой создаёт:

  • GPU-буферы;
  • текстуры;
  • шейдеры;
  • внутренние структуры данных.

Если слои создаются динамически, но не уничтожаются корректно, память видеокарты начинает расти.


Постоянное создание новых слоёв

Проблемный код:

const layers = [
  new ScatterplotLayer({
    id: Date.now().toString(),
    data
  })
];

Каждый рендер создаёт новый слой.

Лучше использовать стабильные идентификаторы:

const layers = [
  new ScatterplotLayer({
    id: 'points-layer',
    data
  })
];

Отсутствие удаления слоёв

При ручной работе с deck.gl необходимо вызывать очистку:

deck.finalize();

Метод освобождает:

  • WebGL-буферы;
  • текстуры;
  • шейдеры;
  • внутренние объекты deck.gl.

Если экземпляр deck создаётся вручную:

const deck = new Deck({...});

то при завершении работы необходимо:

deck.finalize();

Утечки WebGL-памяти

Сборщик мусора JavaScript не управляет памятью GPU напрямую.

Это означает, что объект может исчезнуть из памяти JavaScript, но соответствующие ресурсы видеокарты останутся занятыми.

Типичные проблемы:

  • неосвобождённые текстуры;
  • неосвобождённые FrameBuffer;
  • старые WebGL-контексты;
  • лишние экземпляры карт.

Повторное создание карты

Опасный сценарий:

function recreateMap() {
  return new KeplerGl(...);
}

Если старый экземпляр не уничтожается, память GPU продолжает расти.

Перед созданием нового экземпляра необходимо удалить предыдущий.


Утечки через обработчики событий карты

Карта генерирует множество событий:

move
zoom
drag
resize
click
mousemove

Неправильный код:

map.on('move', updateLayer);

При повторном монтировании компонента количество обработчиков увеличивается:

map.on('move', updateLayer);
map.on('move', updateLayer);
map.on('move', updateLayer);

Каждый обработчик удерживает ссылки на:

  • состояние приложения;
  • данные слоя;
  • объекты карты.

В результате память постепенно растёт.

Необходимо удалять обработчики:

map.off('move', updateLayer);

Кэширование и утечки памяти

Кэширование ускоряет работу приложения, но может стать причиной серьёзных утечек.

Проблемный пример:

const cache = new Map();

Далее:

cache.set(datasetId, dataset);

Если элементы никогда не удаляются:

cache.size

будет постоянно увеличиваться.

Необходимо реализовывать политику очистки.

Например:

if (cache.size > 100) {
  cache.clear();
}

или механизм LRU-кэша.


Утечки через замыкания

JavaScript-замыкания способны удерживать крупные объекты даже после завершения работы функции.

Пример:

function createHandler(dataset) {
  return () => {
    console.log(dataset.id);
  };
}

Если обработчик сохраняется:

handlers.push(
  createHandler(largeDataset)
);

то весь набор данных остаётся в памяти.

Более безопасный вариант:

function createHandler(datasetId) {
  return () => {
    console.log(datasetId);
  };
}

Утечки при работе с пользовательскими слоями

При создании собственных расширений для Kepler.gl важно контролировать жизненный цикл объектов.

Проблемный пример:

class CustomLayer {
  constructor(data) {
    this.cache = data;
  }
}

Если экземпляры создаются многократно:

layers.push(
  new CustomLayer(bigDataset)
);

старые данные остаются в памяти.

Следует реализовывать методы очистки:

destroy() {
  this.cache = null;
}

и вызывать их перед удалением слоя.


Использование WeakMap и WeakSet

Для временного хранения связанных объектов полезно использовать слабые коллекции.

Обычный вариант:

const metadata = new Map();

Без удаления элементов память будет накапливаться.

Вариант со слабой ссылкой:

const metadata =
  new WeakMap();

После удаления объекта ключа данные автоматически становятся доступными для сборщика мусора.

Пример:

metadata.set(dataset, {
  color: 'red'
});

После исчезновения объекта dataset запись также может быть удалена движком JavaScript.


Профилирование памяти

Для поиска утечек используются инструменты браузера.

Chrome DevTools Memory

Позволяет:

  • делать снимки памяти;
  • анализировать удерживаемые объекты;
  • отслеживать рост потребления памяти;
  • находить цепочки ссылок.

Типичный алгоритм:

  1. Сделать первый снимок памяти.
  2. Выполнить серию действий в приложении.
  3. Очистить данные.
  4. Сделать второй снимок.
  5. Сравнить результаты.

Если количество объектов продолжает расти после очистки, существует утечка.


Performance Monitor

Инструмент показывает:

  • использование JS Heap;
  • загрузку CPU;
  • количество DOM-узлов;
  • число слушателей событий.

Постоянный рост JS Heap без возврата к исходному уровню является характерным признаком утечки памяти.


React DevTools Profiler

Полезен для выявления:

  • лишних рендеров;
  • неразмонтированных компонентов;
  • накопления состояний;
  • повторного создания объектов.

Особое внимание уделяется компонентам, связанным с:

  • загрузкой данных;
  • фильтрами;
  • слоями визуализации;
  • пользовательскими панелями Kepler.gl.

Практические рекомендации по предотвращению утечек

При работе с данными:

  • избегать дублирования GeoJSON;
  • удалять неиспользуемые наборы данных;
  • ограничивать размеры кэшей;
  • не сохранять лишние копии состояния.

При работе с React:

  • очищать подписки;
  • удалять таймеры;
  • отменять запросы;
  • контролировать жизненный цикл компонентов.

При работе с deck.gl и WebGL:

  • использовать стабильные идентификаторы слоёв;
  • уничтожать неиспользуемые экземпляры;
  • освобождать GPU-ресурсы;
  • избегать бесконтрольного создания карт.

При работе с обработчиками событий:

  • всегда вызывать off() после on();
  • избегать множественных регистраций;
  • не хранить большие объекты внутри замыканий.

При профилировании:

  • регулярно анализировать Heap Snapshot;
  • проверять рост памяти после удаления данных;
  • контролировать объём Redux Store;
  • отслеживать потребление GPU-памяти во время длительных сессий работы приложения.

Систематическое соблюдение этих правил позволяет поддерживать стабильное потребление памяти даже при обработке крупных геопространственных наборов данных и длительной работе приложений на базе Kepler.gl.