Утечки памяти при использовании timeago.js — практическая проблема в
долгоживущих SPA-приложениях. Основная причина: таймеры, созданные
render, не освобождаются автоматически при удалении
элементов.
import { render } from 'timeago.js';
// Компонент монтируется
render(element, 'ru');
// Компонент размонтируется (DOM-элемент удалён)
// НО таймер всё ещё существует!
// Каждые N секунд он пытается обновить несуществующий элемент.
Таймер хранит ссылку на element. Пока таймер жив —
элемент не попадёт в GC. Цикл повторяется.
В Chrome DevTools:
HTMLTimeElement или
HTMLElement с datetime.Также: Performance → Record → проверить количество Timer Fired.
import { render, cancel } from 'timeago.js';
function mountComponent(el) {
render(el, 'ru');
}
function destroyComponent(el) {
cancel(el); // обязательно
}
useEffect(() => {
render(ref.current, 'ru');
return () => cancel(ref.current); // вызывается при размонтировании
}, []);
Внутри 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();
}
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 });
В критических сценариях (смена страницы, разлогин) — сброс всех таймеров:
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 на скрытых элементах без
необходимости.