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

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

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

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

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

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

Для бегущей строки больше не нужен специальный тег:

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

Вместо регистрации HTML-тега нужно создать экземпляр класса и вызвать init(). В примере используется объект настроек, появившийся в 4.1.0:

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

marquee.init();

При размонтировании компонента нужно вызвать:

marquee.destroy();

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

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

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

Очистка в 4.6.0 тоже остаётся неполной: отложенный кадр debounce и контексты matchMedia не отменяются, а повторный init() после destroy() не возобновляет наблюдение за размерами.

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

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

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

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

Метод registerGSAP() сохранял обе ссылки, поэтому при вызове refresh() больше не требовался глобальный ScrollTrigger. Регистрация плагина в самом 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;

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

Vite оставляет gsap и gsap/ScrollTrigger внешними модулями, поэтому в сборку marquee-content не встраивается собственная копия GSAP. Пользователю по-прежнему нужно установить и зарегистрировать зависимость.

Материалы