В экосистеме Popmotion ключевым источником утечек памяти становятся
не сами анимационные алгоритмы, а неправильное управление их жизненным
циклом. Любая сущность — animate, value,
spring, timeline, pointer,
listen, everyFrame — создает либо подписки,
либо внутренние циклы, которые продолжают существовать даже после того,
как визуально анимация «закончилась».
Основная проблема заключается в том, что Popmotion не работает в
вакууме: он взаимодействует с DOM, событиями мыши/тача,
requestAnimationFrame, замыканиями и внешними состояниями.
Если не разорвать цепочки зависимостей вручную, сборщик мусора не сможет
освободить память.
animateКаждый вызов animate создает внутренний контроллер,
который подписывается на requestAnimationFrame. Пока
контроллер активен, он удерживает ссылки на:
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() не вызван:
Особенно критично в SPA, где компоненты часто монтируются и размонтируются.
value и подписки
onChangevalue создает реактивный контейнер с подписчиками.
Каждая подписка — это удерживаемая ссылка.
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 и
неотписанные события DOMlisten привязывает обработчики событий напрямую к
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 остаются активными.
Это приводит к:
everyFrame и
бесконечные циклыeveryFrame запускает цикл, синхронизированный с
requestAnimationFrame.
import { everyFrame } from "popmotion";
const loop = everyFrame().start(t => {
console.log(t);
});
Если не остановить цикл, он будет выполняться бесконечно, даже если визуально ничего не происходит.
loop.stop();
Особенно опасно при:
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 в памяти.
Особенно опасно:
onUpdateТипичный сценарий в SPA:
function mount() {
animate({
from: 0,
to: 100,
onUpdate: v => element.style.left = v + "px"
});
}
При каждом вызове mount создается новая анимация, но
старая не останавливается. Это приводит к накоплению:
Результат — линейный рост потребления памяти и падение производительности.
Popmotion часто используется в связке:
listen → animatepointer → springvalue → timelineКаждая цепочка добавляет уровень вложенности подписок.
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()цепочка остается в памяти целиком.
Даже если 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());
Каждый визуальный компонент должен явно управлять Popmotion-объектами:
Перед созданием новой анимации необходимо завершить старую:
if (controls) controls.stop();
controls = animate({...});
Лучше передавать только необходимые значения:
const id = element.id;
animate({
onUpdate: v => updatePosition(id, v)
});
JavaScript GC освобождает память только если:
Popmotion активно использует RAF и подписки, поэтому любая забытая анимация автоматически становится «корнем» графа памяти.
Даже один незавершенный контроллер может удерживать целое поддерево объектов.
Типичные признаки утечек:
Инструменты:
Popmotion не управляет жизненным циклом за пользователя. Он предоставляет примитивы управления движением, но ответственность за остановку, отписку и разрушение цепочек полностью лежит на коде приложения.
Любая неявная «вечная» анимация — это не особенность библиотеки, а следствие незавершенного контроллера или неразорванной подписки.