Бесконечная анимация, конечный жизненный цикл
31 Окт 2023
Бесконечная анимация — нормальное поведение для бегущей строки. Бесконечные обработчики и наблюдатели — нет.
При ручных проверках заметил, что marquee-content создаёт ощутимую нагрузку. Смотрел поведение в DevTools, проверял компонент на реальных устройствах, несколько раз перечитывал код. Не нашёл одного места, на которое можно указать: “вот утечка”. Нагрузка складывалась из мелочей: повторной инициализации, новых обработчиков прокрутки, неполной очистки при удалении элемента.
marquee-content@3.0.0 опубликован. Публичный API почти не изменился, зато жизненный цикл компонента пришлось пересобрать.
API оставался стабильным
В 3.0.0, как и в 1.9.1, компонент подключается как 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(). Компонент создаёт обёртки, рассчитывает количество клонов, задаёт наклон и запускает 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, а наблюдатель продолжал за ним следить.
Отдельно каждый пункт выглядел терпимо. Вместе они объясняли, почему компонент с довольно простой анимацией начинал вести себя тяжелее, чем должен.
Вместо таймера — requestAnimationFrame
В версии 3.0 debounce больше не использует setTimeout:
debounce = () => {
let timer;
return () => {
cancelAnimationFrame(timer);
timer = requestAnimationFrame(this.update);
};
};
Каждый новый вызов отменяет ещё не выполненный update() и планирует его заново. Прежний debounce тоже отменял предыдущий таймер. Теперь вместо задержки в 150ms обновление привязано к циклу отрисовки браузера.
Сам update() по-прежнему планирует фактическую пересборку через requestAnimationFrame:
update() {
cancelAnimationFrame(this.af);
if (this.firstElementChild) {
this.af = requestAnimationFrame(() => {
this.cloning();
this.animation();
});
}
}
Получилась двухступенчатая схема через requestAnimationFrame. Первый кадр объединяет сигналы ResizeObserver, пришедшие до вызова update(), второй выполняет работу с 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().
Отложенный кадр внутри debounce тоже не отменяется: disconnectedCallback() знает только о второй ступени, this.af. Если удалить элемент до выполнения первой, она всё ещё может запланировать пересборку.
То есть очистка стала лучше, но сценарий remove → append всё ещё требует внимания. Исправление утечки легко создаёт новый крайний случай, если думать только об удалении и забыть о повторном подключении.