Расстаёмся с 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.