Memory leaks

В экосистеме Popmotion ключевым источником утечек памяти становятся не сами анимационные алгоритмы, а неправильное управление их жизненным циклом. Любая сущность — animate, value, spring, timeline, pointer, listen, everyFrame — создает либо подписки, либо внутренние циклы, которые продолжают существовать даже после того, как визуально анимация «закончилась».

Основная проблема заключается в том, что Popmotion не работает в вакууме: он взаимодействует с DOM, событиями мыши/тача, requestAnimationFrame, замыканиями и внешними состояниями. Если не разорвать цепочки зависимостей вручную, сборщик мусора не сможет освободить память.


Источники утечек памяти в Popmotion

Незавершенные анимации animate

Каждый вызов animate создает внутренний контроллер, который подписывается на requestAnimationFrame. Пока контроллер активен, он удерживает ссылки на:

  • функцию обновления
  • target-объект
  • промежуточные значения
  • easing-логику
import { animate } from "popmotion";

const controls = animate({
  from: 0,
  to: 100,
  onUpdate: (v) => {
    element.style.transform = `translateX(${v}px)`;
  }
});

Если такой контроллер не завершен или не остановлен, он продолжает существовать в event loop даже после удаления DOM-элемента.


Отсутствие вызова stop()

Любой контроллер Popmotion имеет метод остановки. Игнорирование этого метода — одна из самых частых причин утечек.

const controls = animate({
  from: 0,
  to: 1,
  duration: 1000,
  onUpdate: v => console.log(v)
});

// критически важно
controls.stop();

Если stop() не вызван:

  • остается активный RAF-цикл
  • замыкание удерживает ссылки на DOM
  • GC не может освободить память

Особенно критично в SPA, где компоненты часто монтируются и размонтируются.


value и подписки onChange

value создает реактивный контейнер с подписчиками. Каждая подписка — это удерживаемая ссылка.

import { value } from "popmotion";

const x = value(0, v => {
  element.style.left = `${v}px`;
});

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

Правильное завершение:

x.stop();

или при использовании подписок:

const unsubscribe = x.subscribe(v => {
  element.style.left = `${v}px`;
});

unsubscribe();

listen и неотписанные события DOM

listen привязывает обработчики событий напрямую к DOM-узлам или глобальным объектам.

import { listen } from "popmotion";

const pointerDown = listen(element, "mousedown").start(e => {
  console.log(e.pageX);
});

Каждый .start() создает подписку на события. Если не вызвать .stop(), обработчик продолжит жить даже после удаления элемента.

pointerDown.stop();

Типичная проблема — удаление DOM-узла без остановки слушателя. В этом случае DOM уже отсутствует, но замыкание продолжает удерживать ссылку на него.


pointer и глобальные подписки

pointer() особенно опасен с точки зрения утечек, так как может привязываться к window событиям:

import { pointer } from "popmotion";

const track = pointer().start(v => {
  element.style.transform = `translate(${v.x}px, ${v.y}px)`;
});

Пока не вызван track.stop(), обработчики mousemove, touchmove остаются активными.

Это приводит к:

  • накоплению подписок при повторных инициализациях
  • увеличению нагрузки на main thread
  • удержанию замыканий с DOM-элементами

everyFrame и бесконечные циклы

everyFrame запускает цикл, синхронизированный с requestAnimationFrame.

import { everyFrame } from "popmotion";

const loop = everyFrame().start(t => {
  console.log(t);
});

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

loop.stop();

Особенно опасно при:

  • смене страниц
  • уничтожении canvas/WebGL контекста
  • динамическом создании компонентов

spring, physics и неявные продолжительные анимации

Физические анимации не имеют фиксированной длительности. Они завершаются только при достижении состояния покоя.

import { spring } from "popmotion";

const springAnim = spring({
  from: 0,
  to: 100,
  stiffness: 100
}).start(v => {
  element.style.left = `${v}px`;
});

Если система не достигает равновесия (например, из-за постоянно обновляемых параметров), анимация может продолжаться значительно дольше ожидаемого.

При частом пересоздании таких анимаций без остановки предыдущих возникает наложение контроллеров.


Замыкания как скрытый источник удержания памяти

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

const data = new Array(100000).fill("x");

const controls = animate({
  from: 0,
  to: 1,
  onUpdate: () => {
    console.log(data.length);
  }
});

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

Особенно опасно:

  • использование внешних store
  • доступ к большим объектам внутри onUpdate
  • ссылки на DOM-дерево внутри callback

Повторная инициализация без cleanup

Типичный сценарий в SPA:

function mount() {
  animate({
    from: 0,
    to: 100,
    onUpdate: v => element.style.left = v + "px"
  });
}

При каждом вызове mount создается новая анимация, но старая не останавливается. Это приводит к накоплению:

  • RAF-циклов
  • подписчиков
  • замыканий

Результат — линейный рост потребления памяти и падение производительности.


Сложные цепочки: комбинированные утечки

Popmotion часто используется в связке:

  • listenanimate
  • pointerspring
  • valuetimeline

Каждая цепочка добавляет уровень вложенности подписок.

const x = value(0);

const drag = pointer().start(p => {
  x.set(p.x);
});

const anim = x.onChange(v => {
  element.style.transform = `translateX(${v}px)`;
});

Без корректного завершения:

  • drag.stop()
  • x.stop()
  • anim.stop()

цепочка остается в памяти целиком.


Утечки через DOM-ссылки

Даже если Popmotion корректно остановлен, DOM-узлы могут удерживаться через замыкания:

let element = document.querySelector(".box");

animate({
  from: 0,
  to: 100,
  onUpdate: v => {
    element.style.left = v + "px";
  }
});

Если элемент удален из DOM, но ссылка осталась в замыкании, GC не освободит его.

Правильный подход требует обнуления ссылок после остановки:

controls.stop();
element = null;

Таймлайны и каскадные подписки

timeline может содержать множество вложенных анимаций. Каждая из них имеет свой контроллер.

import { timeline } from "popmotion";

const sequence = timeline([
  [element1, { opacity: 1 }],
  [element2, { x: 100 }]
]);

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


Паттерны предотвращения утечек

Централизованный контроллер

Хранение всех контроллеров в одном месте позволяет гарантировать очистку:

const controllers = [];

controllers.push(
  animate({...}),
  pointer().start(...)
);

// cleanup
controllers.forEach(c => c.stop());

Привязка к жизненному циклу UI

Каждый визуальный компонент должен явно управлять Popmotion-объектами:

  • создание при mount
  • остановка при unmount

Защита от повторного старта

Перед созданием новой анимации необходимо завершить старую:

if (controls) controls.stop();
controls = animate({...});

Минимизация замыканий

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

const id = element.id;

animate({
  onUpdate: v => updatePosition(id, v)
});

Поведение сборщика мусора

JavaScript GC освобождает память только если:

  • нет активных ссылок
  • нет подписок
  • нет активных таймеров / RAF

Popmotion активно использует RAF и подписки, поэтому любая забытая анимация автоматически становится «корнем» графа памяти.

Даже один незавершенный контроллер может удерживать целое поддерево объектов.


Диагностика проблем

Типичные признаки утечек:

  • рост памяти после повторных навигаций
  • увеличение числа RAF-циклов
  • падение FPS при отсутствии визуальной активности
  • увеличение времени GC пауз

Инструменты:

  • Performance panel (Chrome DevTools)
  • Memory snapshots
  • Allocation instrumentation on timeline

Ключевая особенность архитектуры Popmotion

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

Любая неявная «вечная» анимация — это не особенность библиотеки, а следствие незавершенного контроллера или неразорванной подписки.