Расстаёмся с Custom Element

09 Мар 2025

Custom Element давал marquee-content готовые методы жизненного цикла. Проблема появилась при интеграции с фреймворком: одним DOM-узлом одновременно управляли браузер и приложение.

К версии 4 я хотел управлять инициализацией явно. Заодно накопились задачи по типам, форматам сборки и устройству npm-пакета. В результате один архитектурный переход растянулся на несколько релизов, а TypeScript пришлось внедрять дважды. С первого раза он формально появился, но ещё не помогал.

Последний Custom Element

В версии 3.1.1 библиотека всё ещё экспортировала класс, унаследованный от HTMLElement:

connectedCallback() {
  this.init();
}

disconnectedCallback() {
  this.destroy();
}

Пользователь добавляет собственный тег, а браузер запускает connectedCallback(). При удалении элемента disconnectedCallback() вызывал destroy(): текущий Tween останавливался, this.af отменялся, а ResizeObserver отключался. Очистка оставалась неполной. Отложенный кадр внутри debounce и контексты matchMedia не отменялись, а после повторного подключения observer уже не восстанавливался.

Для изолированного компонента такая схема удобна. Во фреймворке появляется второй жизненный цикл: приложение управляет компонентом страницы, браузер — Custom Element внутри него. Инициализацию и очистку приходится согласовывать между ними.

Мне требовались три вещи:

  • Принимать уже существующий DOM-элемент, а не требовать специальный тег;
  • Явно запускать и останавливать анимацию вместе с компонентом приложения;
  • Сократить количество неявного поведения внутри библиотеки.

Поэтому в 4.0.0 MarqueeContent перестал наследоваться от HTMLElement, а управление инициализацией и очисткой перешло к вызывающему коду.

Обычный класс и явные init() / destroy()

Разметка стала обычной:

<div
  class="marquee"
  data-mc-duration="20"
  data-mc-direction="auto"
>
  <span>Primary</span>
  <span>Secondary</span>
  <span>Tertiary</span>
</div>

Вместо регистрации HTML-тега пользователь создавал экземпляр класса и вызывал init():

const marquee = new MarqueeContent({
  element: '.marquee',
});

marquee.init();

При размонтировании нужно было симметрично вызвать:

marquee.destroy();

Первый вариант API в 4.0.0 принимал элемент или селектор напрямую. Уже в 4.1.0 конструктор получил объект настроек { element }. Такой вызов немного длиннее, зато его проще расширять, не добавляя позиционные аргументы.

До 4.5.0 отсутствующий элемент приводил к раннему выходу из конструктора и оставлял частично созданный экземпляр. В 4.6.0 форма { element } сохранилась, но ошибка снова стала явной: конструктор бросал Target element not found.

Явные init() и destroy() можно привязать к монтированию и размонтированию компонента во фреймворке. Очистка при этом становится обязанностью пользователя: без destroy() ResizeObserver и ScrollTrigger продолжат жить после удаления DOM-узла.

Зависимости нужно передавать полностью

В ранней реализации четвёртой версии, GSAP регистрировался через статический метод, но ScrollTrigger.refresh() всё ещё вызывался через глобальный ScrollTrigger. Модульный API получился не до конца модульным.

В 4.2.0 регистрация стала явной для обеих зависимостей:

gsap.registerPlugin(ScrollTrigger);
MarqueeContent.registerGSAP(gsap, ScrollTrigger);

Метод registerGSAP() сохранял обе ссылки и убирал свободное глобальное имя из пути refresh(). Регистрация плагина в самом GSAP оставалась отдельной операцией. В 4.6.0 импортированные GSAP и ScrollTrigger уже служили значениями по умолчанию, а статический метод позволял их переопределить.

В пакетах такие детали важнее, чем в коде одной страницы. Приложение может рассчитывать на глобальный объект, потому что само контролирует порядок скриптов. Библиотека не должна молча предполагать, что нужное имя уже существует в window.

Первая попытка TypeScript

По 4.2.0 включительно исходники оставались на JavaScript и собирались Parcel. В версии 4.3.0 основной файл стал TypeScript, появился tsconfig.json.

На уровне списка файлов задача выглядела выполненной. На уровне публичного API — ещё нет.

Параметры регистрации GSAP и часть полей получили тип never:

static registerGSAP(gsap: never, ScrollTrigger: never): void;

Такой тип утверждает, что допустимого значения не существует. Реальный JS ожидал экземпляры GSAP и ScrollTrigger, но декларация не могла выразить этот контракт. Типы были в npm-пакете, однако использовать их по назначению было трудно.

Это хороший пример разницы между “код переписан на TypeScript” и “у библиотеки появился типизированный API”. Компилятор проверяет только ту модель, которую ему дали. Если на границе стоят never или безразмерный any, наличие .ts ещё ничего не гарантирует пользователю.

Duration переименован в speed

В версии 4.4.0 заменил атрибут data-mc-duration на data-mc-speed:

<div class="marquee" data-mc-speed="20">
  <!-- элементы ленты -->
</div>

Математика анимации при этом не изменилась. Значение по-прежнему передавалось в GSAP как duration:

timeline.to(element.children, {
  duration: speed,
  x: '-100%',
});

Поэтому меньшее значение двигало ленту быстрее, а большее — медленнее. speed здесь означал пользовательскую настройку темпа, а не физическую скорость в пикселях в секунду. Имя стало короче, но семантика осталась обратной привычной скорости.

Для HTML это было несовместимое изменение: старый data-mc-duration библиотека больше не читала. Переименование публичного атрибута оказалось заметнее, чем переход исходников на TypeScript.

Шаг назад без маскировки

В 4.5.0 убрал TypeScript вместе с декларациями, а инкапсуляция переехала на нативные приватные поля JavaScript:

class MarqueeContent {
  #element;
  #timeline;
  #resizeObserver;
}

Можно было оставить первую миграцию, но пользы от такой последовательности немного. Плохие типы не становятся хорошими от того, что дольше лежат в пакете.

Возврат к JavaScript был промежуточным состоянием. Публичный API не изменился: { element }, init(), destroy() и registerGSAP() продолжили работать как раньше.

TypeScript со второй попытки

К версии 4.6.0 я вернул TypeScript, но начал с границы пакета. В декларации появились реальные типы GSAP и ScrollTrigger, а параметры элемента были описаны как строковый селектор или HTMLElement.

type MarqueeContentOptions = {
  element?: string | HTMLElement;
};

Теперь типы соответствовали способу использования библиотеки, а не просто факту существования TypeScript-файла.

Одновременно сборка переехала с Parcel на Vite в режиме сборки библиотек. Пакет начал публиковать три формата:

{
  "main": "dist/index.cjs.js",
  "module": "dist/index.es.js",
  "browser": "./dist/index.umd.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "require": "./dist/index.cjs.js",
      "import": "./dist/index.es.js",
      "default": "./dist/index.umd.js"
    }
  }
}

CommonJS оставался доступен через require, ESM — через import, UMD — для прямого браузерного подключения. types указывало на декларацию, а exports задавал явные точки входа вместо надежды на догадки сборщика.

GSAP при этом не попал внутрь сборки: Vite оставлял gsap и gsap/ScrollTrigger внешними модулями. Это предотвращало появление второй копии GSAP в приложении, но сохранило обязанность пользователя установить и зарегистрировать зависимость.

Что осталось

Смена архитектуры не закрыла весь техдолг.

gsap.matchMedia().add() по-прежнему вызывается при повторной настройке, а общий revert() при destroy() отсутствует. Ручное управление жизненным циклом требует дисциплины. Кроме того, GSAP в 4.6.0 используется во время выполнения, но указан только в devDependencies, а не в peerDependencies.

Материалы