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

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

Материалы