Прощай Custom Element, привет TypeScript: marquee-content 4
09 Мар 2025
Custom Element давал marquee-content готовые lifecycle callbacks. Проблема появилась при интеграции с фреймворком: одним DOM-узлом одновременно управляли браузер и приложение.
К версии 4 я хотел управлять инициализацией явно. Заодно накопились задачи по типам, форматам сборки и устройству npm-пакета. В результате один архитектурный переход растянулся на несколько релизов, а TypeScript пришлось внедрять дважды.
Последний Custom Element
В версии 3.1.1 либа всё ещё экспортировала класс, унаследованный от HTMLElement:
connectedCallback() {
this.init();
}
disconnectedCallback() {
this.destroy();
}
Пользователь добавлял собственный тег, а браузер запускал connectedCallback(). При удалении элемента disconnectedCallback() вызывал destroy(): текущий Tween останавливался, this.af отменялся, а ResizeObserver отключался. Cleanup оставался неполным. Отложенный кадр внутри 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.1.0 по 4.5.0 отсутствующий target приводил к раннему выходу из конструктора и оставлял частично созданный экземпляр. В 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, а пакет начал публиковать dist/index.d.ts.
На уровне списка файлов задача выглядела выполненной. На уровне публичного API — ещё нет.
Параметры регистрации GSAP и часть полей получили тип never:
static registerGSAP(gsap: never, ScrollTrigger: never): void;
Такой тип утверждает, что допустимого значения не существует. Реальный JavaScript ожидал GSAP и ScrollTrigger, но типизированный код не мог передать их в этот метод.
В декларации нашлась и вторая проблема — два default export:
export default class MarqueeContent {
// ...
}
export default MarqueeContent;
При проверке .d.ts это приводило к ошибке TS2528. Первая миграция создала TypeScript-файлы, но публичный контракт остался некорректным.
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 это было breaking-изменение: старый data-mc-duration библиотека больше не читала.
Шаг назад без маскировки
В 4.5.0 я убрал TypeScript из исходников. Декларации исчезли из npm-пакета, а закрытые поля снова использовали нативный синтаксис JavaScript:
class MarqueeContent {
#element;
#timeline;
#resizeObserver;
}
Сохранять декларации с never только ради непрерывной TypeScript-истории не имело смысла: они неверно описывали публичный контракт.
Возврат к JavaScript был промежуточным состоянием, а не итоговым решением. Публичный runtime API не изменился: { element }, init(), destroy() и registerGSAP() продолжили работать как раньше.
TypeScript со второй попытки
К версии 4.6.0 я вернул TypeScript, но начал с границы пакета. В декларации появились реальные типы GSAP и ScrollTrigger, а параметры элемента были описаны как строковый селектор или HTMLElement.
interface MarqueeOptions {
element?: string | HTMLElement;
}
Содержимое декларации стало соответствовать способу использования библиотеки. Публикация типов при этом осталась незавершённой: export map не содержал условия types, поэтому современные режимы module resolution могли не увидеть dist/index.d.ts.
Одновременно сборка переехала с Parcel на Vite в library mode. Пакет начал публиковать три формата:
{
"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"
},
"./dist/*": "./dist/*"
}
}
Vite создал отдельные CJS-, ESM- и UMD-файлы, а export map направлял require и import к разным сборкам. Конфигурация была рассчитана прежде всего на bundlers. Без "type": "module" файл index.es.js не образовывал полностью корректный native Node dual package.
GSAP не входил в bundle библиотеки: Vite оставлял gsap и gsap/ScrollTrigger внешними модулями. Это уменьшало риск дублирования, но не гарантировало единственную копию зависимости.
Незакрытый долг
this.matchMedia.add() по-прежнему вызывается при повторной настройке для клонирования и анимации, а общий revert() при destroy() отсутствует. Кроме того, GSAP в 4.6.0 используется во время выполнения, но указан только в devDependencies, а не в peerDependencies.
Четвёртая версия стала удобнее для фреймворков не потому, что обычный класс всегда лучше Custom Element. В этой библиотеке явное управление оказалось полезнее автоматического lifecycle. В другом компоненте с изолированной разметкой и минимальной интеграцией выбор мог быть обратным.
Итоги версии 4.6
- Отказ от Custom Element убрал второй автоматический lifecycle и позволил работать с существующей разметкой.
- Ручные
init()иdestroy()дали приложению контроль, но передали ему ответственность за очистку. - Передача GSAP и ScrollTrigger через один API убрала скрытую глобальную зависимость.
- Первая миграция на TypeScript создала декларации, которые не проходили собственную проверку.
- Вторая миграция исправила содержимое типов и добавила сборки CJS, ESM и UMD, но метаданные пакета ещё требуют доработки.
Возврат к JavaScript в 4.5.0 — промежуточный этап: в 4.6.0 типы вернулись уже от границы публичного API.