Бесконечная анимация, конечный жизненный цикл

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 всё ещё требует внимания. Исправление утечки легко создаёт новый крайний случай, если думать только об удалении и забыть о повторном подключении.

Материалы