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

Утечки памяти при использовании timeago.js — практическая проблема в долгоживущих SPA-приложениях. Основная причина: таймеры, созданные render, не освобождаются автоматически при удалении элементов.


Механизм утечки

import { render } from 'timeago.js';

// Компонент монтируется
render(element, 'ru');

// Компонент размонтируется (DOM-элемент удалён)
// НО таймер всё ещё существует!
// Каждые N секунд он пытается обновить несуществующий элемент.

Таймер хранит ссылку на element. Пока таймер жив — элемент не попадёт в GC. Цикл повторяется.


Симптомы утечки

  • Рост потребления памяти со временем (видно в DevTools → Memory).
  • Ошибки типа “Cannot set property of null” (если GC всё же собрал элемент).
  • Ухудшение производительности интерфейса.
  • Нагрузка на CPU от множества фоновых таймеров.

Диагностика

В Chrome DevTools:

  1. Memory → Heap snapshot.
  2. Поискать объекты типа HTMLTimeElement или HTMLElement с datetime.
  3. Если их количество растёт с каждым монтированием/размонтированием — утечка есть.

Также: Performance → Record → проверить количество Timer Fired.


Решение: cancel при размонтировании

import { render, cancel } from 'timeago.js';

function mountComponent(el) {
  render(el, 'ru');
}

function destroyComponent(el) {
  cancel(el); // обязательно
}

React: useEffect cleanup

useEffect(() => {
  render(ref.current, 'ru');
  return () => cancel(ref.current); // вызывается при размонтировании
}, []);

WeakMap для управления таймерами

Внутри timeago.js (начиная с версий 4.x) для хранения таймеров используется WeakMap. Это значит: если сам DOM-элемент стал недостижим (нет других ссылок), GC может собрать его вместе со связанными данными в WeakMap.

Однако это не означает автоматического освобождения clearTimeout. Таймеры остаются активными, просто теряют привязку к элементу. Явный cancel по-прежнему нужен.


Утечка при динамическом контенте

Классическая ошибка в SPA с infinite scroll:

// НЕПРАВИЛЬНО: render без cancel при удалении
function removePost(id) {
  const el = document.querySelector(`[data-id="${id}"]`);
  el.remove(); // таймер НЕ остановлен
}

// ПРАВИЛЬНО
function removePost(id) {
  const el  = document.querySelector(`[data-id="${id}"]`);
  const time = el.querySelector('time');

  cancel(time); // сначала остановить таймер
  el.remove();
}

MutationObserver для автоматической очистки

import { cancel } from 'timeago.js';

const mo = new MutationObserver((mutations) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(node => {
      if (node.nodeType !== Node.ELEMENT_NODE) return;

      // Остановить таймеры для удалённых элементов
      if (node.matches('[datetime]')) cancel(node);
      node.querySelectorAll?.('[datetime]').forEach(cancel);
    });
  });
});

mo.observe(document.body, { childList: true, subtree: true });

Глобальный reset

В критических сценариях (смена страницы, разлогин) — сброс всех таймеров:

import { cancel } from 'timeago.js';

function resetApp() {
  cancel(); // остановить все timeago-таймеры
  // ... остальная очистка
}

Предотвращение дублирования таймеров

Повторный вызов render без предварительного cancel создаёт дополнительные таймеры:

// НЕПРАВИЛЬНО
function refreshTime(el) {
  render(el, 'ru'); // второй таймер поверх первого
}

// ПРАВИЛЬНО
function refreshTime(el) {
  cancel(el);
  render(el, 'ru');
}

Ограничение количества обновляемых элементов

В ленте с тысячами записей:

import { render, cancel } from 'timeago.js';

const MAX_RENDERED = 50;

function renderFeed(items) {
  // Остановить все старые
  cancel(document.querySelectorAll('.time'));

  // Запустить только для первых N
  const visible = Array.from(items).slice(0, MAX_RENDERED);
  render(visible, 'ru');
}

Проверка после навигации

После каждого перехода между страницами в SPA проверять, нет ли утечек:

router.afterEach(() => {
  // Количество активных элементов с datetime должно соответствовать ожидаемому
  const count = document.querySelectorAll('[datetime]').length;
  if (count > 100) {
    console.warn(`[timeago] Подозрение на утечку: ${count} элементов с datetime`);
  }
});

Чеклист предотвращения утечек

  • cancel вызывается при каждом размонтировании компонента.
  • cancel вызывается перед render при переинициализации.
  • MutationObserver отслеживает удаление элементов.
  • Не вызывать render на скрытых элементах без необходимости.
  • Использовать IntersectionObserver для виртуализации обновлений.