Бесконечная анимация, конечный жизненный цикл
31 Окт 2023
Бесконечная анимация — нормальное поведение для бегущей строки. Бесконечные обработчики и наблюдатели — нет.
При ручных проверках marquee-content стал создавать заметную нагрузку. Смотрел поведение в DevTools, проверял компонент на реальных устройствах, несколько раз перечитывал код. Одного красивого места с подписью “вот утечка” не оказалось. Нагрузка складывалась из мелочей: повторной инициализации, новых обработчиков прокрутки, неполной очистки при удалении элемента.
marquee-content@3.0.0 опубликован. Публичный API почти не изменился, зато жизненный цикл компонента пришлось пересобрать.
API оставался стабильным
Между версиями 1.9.1 и 3.0.0 компонент по-прежнему подключается как Custom Element:
<marquee-content
data-mc-duration="20"
data-mc-direction="auto"
>
<ul>
<li>Primary</li>
<li>Secondary</li>
<li>Tertiary</li>
</ul>
</marquee-content>
Сохранились основные атрибуты:
data-mc-duration;data-mc-direction;data-mc-skew;data-mc-min;data-mc-max.
GSAP всё так же можно передать явно через MarqueeContent.registerGSAP(gsap).
Как накапливается лишняя работа
После подключения элемента вызывается connectedCallback(), а затем init(). Компонент создаёт обёртки, считает клоны, применяет skew и запускает GSAP-анимацию.
Ширину отслеживает ResizeObserver. При каждом обновлении размеров компонент заново рассчитывает клоны и пересоздаёт анимацию. Само по себе это ожидаемо: после изменения ширины старая геометрия уже не гарантирует непрерывную ленту.
Проблема была в деталях.
В версии 2.4.1 обновление после изменения размера проходило через setTimeout с задержкой 150ms, а затем через requestAnimationFrame:
debounce(fn, delay) {
this.timer = null;
return (...args) => {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => fn(...args), delay);
};
}
После таймера update() планировал ещё один кадр, в котором выполнялись клонирование и пересоздание анимации. Код работал, но обработка была привязана и к произвольной задержке, и к циклу отрисовки браузера.
Для направления auto каждая новая анимация добавляла собственный обработчик:
window.addEventListener('scroll', handleScroll, {
capture: true,
passive: true,
});
При resize метод animation() запускался снова. Предыдущая анимация уже умела завершаться, а вот созданный внутри autoDirection() обработчик scroll не удалялся. Несколько пересборок компонента могли оставить несколько обработчиков одного события.
Наконец, disconnectedCallback() останавливал запланированный кадр и завершал анимацию, но не отключал ResizeObserver. DOM-элемент уже удалён, а связанная с ним инфраструктура ещё не получила эту новость.
Отдельно каждый пункт выглядел терпимо. Вместе они объясняли, почему компонент с довольно простой анимацией начинал вести себя тяжелее, чем должен.
Вместо таймера — один кадр
В версии 3.0 debounce больше не использует setTimeout:
debounce = () => {
let timer;
return () => {
cancelAnimationFrame(timer);
timer = requestAnimationFrame(this.update);
};
};
Каждый новый вызов отменяет ранее запланированную функцию и оставляет только один update. Серия событий изменения размера больше не создаёт очередь таймеров. Обновление синхронизируется с циклом отрисовки браузера.
Сам update() по-прежнему планирует фактическую пересборку через requestAnimationFrame:
update() {
cancelAnimationFrame(this.af);
if (this.firstElementChild) {
this.af = requestAnimationFrame(() => {
this.cloning();
this.animation();
});
}
}
Получилась двухступенчатая схема через requestAnimationFrame. Первый кадр объединяет частые сигналы ResizeObserver, второй выполняет работу с DOM и анимацией. Она не делает пересборку бесплатной, но и не позволяет одному всплеску событий запланировать несколько одинаковых обновлений.
ScrollTrigger уже знает направление
Собственный window.addEventListener('scroll', ...) оказался лишним. ScrollTrigger и так обновляется при прокрутке и передаёт в onUpdate текущий экземпляр со свойством direction.
В версии 3.0 направление меняется внутри уже существующего ScrollTrigger:
onUpdate: (self) => {
if (this.dataset.mcDirection === 'ltr') {
this.timeline.timeScale(-1);
} else if (this.dataset.mcDirection === 'auto') {
this.timeline.timeScale(self.direction);
}
},
self.direction возвращает 1 при движении вперёд и -1 при движении назад. Отдельно хранить предыдущий scrollY, сравнивать значения и обслуживать глобальный обработчик больше не нужно.
Это тот случай, когда оптимизация состоит не в ускорении кода, а в его удалении. Событие уже обработано библиотекой, которой пользуется компонент. Второй параллельный механизм только создавал ещё одно состояние, которое нужно синхронизировать и очищать.
Очистка в одном месте
Работа с анимацией переехала в отдельный метод:
clearTimeline() {
if (this.timeline) {
this.timeline.kill();
this.timeline = null;
}
this.gsap.set(this.children, { clearProps: true });
}
Теперь одна и та же очистка используется перед пересозданием анимации, при выходе из медиазапроса и при отключении компонента.
disconnectedCallback() тоже стал полнее:
disconnectedCallback() {
cancelAnimationFrame(this.af);
this.clearTimeline();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
}
ResizeObserver.disconnect() прекращает наблюдение за всеми связанными элементами. Для Custom Element это не дополнительная перестраховка, а нормальная половина жизненного цикла: всё, что создаётся при инициализации, должно иметь понятный путь остановки.
Клоны создаются пакетом
Формула количества клонов не изменилась:
const requiredQuantity = Math.ceil(
this.scrollWidth / this.firstElementChild.clientWidth + 2,
);
Изменился способ добавления. Раньше каждый клон сразу вставлялся в DOM внутри цикла. Теперь сначала создаётся массив, а затем все элементы передаются в один append():
const clones = Array.from(
{ length: requiredQuantity - 1 },
() => this.firstElementChild.cloneNode(true),
);
this.append(...clones);
Это не повод обещать магический прирост производительности. Клоны всё равно нужно создать, а браузеры умеют оптимизировать последовательные операции. Но код выполняет одну операцию добавления и проще отделяет подготовку узлов от изменения DOM.
Жизненный цикл ещё не закрыт
Версия 3.0 закрыла заметную часть долга.
ResizeObserver создаётся и начинает наблюдение в конструкторе. После disconnect() повторное подключение того же DOM-элемента не запускает observe() заново. Экземпляр gsap.matchMedia() тоже не получает общего revert() в disconnectedCallback().
То есть очистка стала лучше, но сценарий remove → append всё ещё требует внимания. Исправление утечки легко создаёт новый крайний случай, если думать только об удалении и забыть о повторном подключении.