Расстаёмся с 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. Пользователю по-прежнему нужно установить и зарегистрировать зависимость.
- MDN: Using custom elements
- TypeScript: Declaration Files
- Vite: Library Mode
- Node.js: Package entry points
- GSAP: Install