marquee-content 3.0: анимация бесконечная, слушатели — тоже
31 Окт 2023
Бесконечная анимация — нормальное поведение для бегущей строки. Бесконечные обработчики и наблюдатели — нет.
При ручных проверках заметил лишнюю нагрузку. В коде нашлись три возможных источника: повторная инициализация, новые scroll-обработчики и неполная очистка. Точный сценарий воспроизведения и вклад каждого механизма не сохранились.
marquee-content@3.0.0 опубликован. Публичный 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). В 1.9.1 исходный класс уже содержал этот метод, но собранный ESM ещё не экспортировал класс. Экспорт исправил в 2.0.0.
При переходе с 2.4.1 на 3.0.0 пользователю не требовалось менять код: переделка находилась внутри компонента.
Как накапливалась лишняя работа
В версии 2.4.1 после подключения элемента connectedCallback() вызывал init(). Компонент создавал обёртки, считал клоны, применял skew и запускал GSAP-анимацию.
Ширину отслеживал ResizeObserver. При каждом обновлении размеров компонент заново рассчитывал клоны и пересоздавал Tween. Само по себе это ожидаемо: после изменения ширины старая геометрия уже не гарантирует непрерывную ленту.
В версии 2.4.1 resize проходил через setTimeout с задержкой 150ms, а затем через requestAnimationFrame:
debounce(fn, delay) {
this.timer = null;
return (...args) => {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => fn(...args), delay);
};
}
После таймера update() планировал ещё один кадр, в котором выполнялись клонирование и пересоздание анимации. Старый debounce отменял предыдущий timeout, поэтому ожидающий callback был только один. Обработка при этом оставалась привязана и к фиксированной задержке, и к циклу отрисовки браузера.
Для направления auto каждая новая анимация добавляла собственный обработчик:
window.addEventListener('scroll', handleScroll, {
capture: true,
passive: true,
});
При resize метод animation() убивал предыдущий Tween, но созданный внутри autoDirection() scroll-listener не удалялся. Несколько пересборок компонента могли оставить несколько обработчиков одного события.
disconnectedCallback() убивал текущий Tween и отменял второй этап rAF, если тот уже был запланирован. Ожидающий timeout при этом не отменялся, а ResizeObserver продолжал наблюдение после удаления элемента.
Вместо таймера — один кадр
В версии 3.0 debounce больше не использует setTimeout:
debounce = () => {
let timer;
return () => {
cancelAnimationFrame(timer);
timer = requestAnimationFrame(this.update);
};
};
Каждый новый вызов отменяет ранее запланированный callback. Частые сигналы объединяются в один update без фиксированной задержки 150ms.
Сам update() по-прежнему планирует фактическую пересборку через requestAnimationFrame:
update() {
cancelAnimationFrame(this.af);
if (this.firstElementChild) {
this.af = requestAnimationFrame(() => {
this.cloning();
this.animation();
});
}
}
Получилась двухступенчатая rAF-схема. Первый кадр объединяет сигналы 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, сравнивать значения и обслуживать глобальный listener больше не нужно.
Здесь оптимизация свелась к удалению кода: ScrollTrigger уже обрабатывал событие. Параллельный механизм добавлял состояние, которое приходилось синхронизировать и очищать.
Очистка в одном месте
Несмотря на имя свойства, this.timeline содержит результат gsap.to(), то есть Tween. Работа с ним переехала в отдельный метод:
clearTimeline() {
if (this.timeline) {
this.timeline.kill();
this.timeline = null;
}
this.gsap.set(this.children, { clearProps: true });
}
Теперь один и тот же cleanup используется перед пересозданием анимации, при выходе из media query и при отключении компонента.
При этом каждый update() снова вызывает cloning() и animation(). Оба метода регистрируют новый callback через MM.add(), но общий MM.revert() не вызывается. Нативные scroll-listeners больше не накапливаются, а matchMedia-контексты всё ещё могут.
disconnectedCallback() тоже стал полнее:
disconnectedCallback() {
cancelAnimationFrame(this.af);
this.clearTimeline();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
}
ResizeObserver.disconnect() прекращает наблюдение за всеми связанными элементами. Для Custom Element это обычная часть lifecycle: созданные при подключении наблюдатели нужно отключать при удалении.
Осталась ещё одна проблема. ID первого rAF хранится в локальной переменной внутри debounce(), поэтому disconnectedCallback() отменяет только this.af второго этапа. Уже запланированный первый callback всё ещё может вызвать update() после удаления элемента.
Клоны добавляются одним вызовом
Формула количества клонов не изменилась:
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. Практическая польза в другом — подготовка узлов отделена от их добавления.
Что действительно стало меньше
Исходный код стал короче, но npm-тарбол немного распух из-за добавленных src/demo.js и src/index.html.
Для пользователя важнее, что из runtime исчез собственный scroll-listener, а ResizeObserver получил явный disconnect().
Что осталось незакрытым
Версия 3.0 закрыла часть долга, но lifecycle остался неполным.
ResizeObserver создаётся и начинает наблюдение в конструкторе. После disconnect() повторное подключение того же DOM-элемента не запускает observe() заново. init() повторно оборачивает сохранившуюся структуру, а старые matchMedia-контексты и клоны не получают общего revert().
Сценарий remove → append всё ещё работает неправильно.
Итог версии 3.0
- Пересмотр кода выявил несколько возможных источников лишней работы: повторную инициализацию, scroll-обработчики и незавершённый
ResizeObserver. - Удаление собственного scroll-listener оказалось полезнее попытки сделать его быстрее. ScrollTrigger уже предоставлял нужное направление.
requestAnimationFrameзаменил произвольную задержку и объединил частые resize-сигналы, но сама работа с DOM никуда не исчезла.disconnectedCallback()должен останавливать Tween, наблюдатели, слушатели и запланированные кадры. Остановка только анимации решает не всю задачу.- В версии стало меньше обработчиков и короче runtime-код, но измерений прироста производительности у меня пока нет.