[{"data":1,"prerenderedAt":35},["ShallowReactive",2],{"journal-all":3,"journal-post-marquee-content-4-proshhaj-custom-element-privet-type-script":32},[4,14,23],{"id":5,"documentId":6,"title":7,"content":8,"slug":9,"author":10,"displayDate":11,"publishedAt":12,"tags":13},19,"sjb50o4as9ejtiicpk9edw42","marquee-content 4: прощай Custom Element, привет TypeScript","Custom Element давал `marquee-content` готовые lifecycle callbacks. Проблема появилась при интеграции с фреймворком: одним DOM-узлом одновременно управляли браузер и приложение.\n\nК версии 4 я хотел управлять инициализацией явно. Заодно накопились задачи по типам, форматам сборки и устройству npm-пакета. В результате один архитектурный переход растянулся на несколько релизов, а TypeScript пришлось внедрять дважды.\n\n## Последний Custom Element\n\nВ версии `3.1.1` либа всё ещё экспортировала класс, унаследованный от `HTMLElement`:\n\n```js\nconnectedCallback() {\n  this.init();\n}\n\ndisconnectedCallback() {\n  this.destroy();\n}\n```\n\nПользователь добавлял собственный тег, а браузер запускал `connectedCallback()`. При удалении элемента `disconnectedCallback()` вызывал `destroy()`: текущий Tween останавливался, `this.af` отменялся, а `ResizeObserver` отключался. Cleanup оставался неполным. Отложенный кадр внутри debounce и контексты `matchMedia` не отменялись, а после повторного подключения observer уже не восстанавливался.\n\nДля изолированного компонента такая схема удобна. Во фреймворке появляется второй жизненный цикл: приложение управляет компонентом страницы, браузер — Custom Element внутри него. Инициализацию и очистку приходится согласовывать между ними.\n\nМне требовались три вещи:\n\n- Принимать уже существующий DOM-элемент, а не требовать специальный тег;\n- Явно запускать и останавливать анимацию вместе с компонентом приложения;\n- Сократить количество неявного поведения внутри библиотеки.\n\nПоэтому в `4.0.0` `MarqueeContent` перестал наследоваться от `HTMLElement`, а управление инициализацией и очисткой перешло к вызывающему коду.\n\n## Обычный класс и явные init() \u002F destroy()\n\nРазметка стала обычной:\n\n```html\n\u003Cdiv\n  class=\"marquee\"\n  data-mc-duration=\"20\"\n  data-mc-direction=\"auto\"\n>\n  \u003Cspan>Primary\u003C\u002Fspan>\n  \u003Cspan>Secondary\u003C\u002Fspan>\n  \u003Cspan>Tertiary\u003C\u002Fspan>\n\u003C\u002Fdiv>\n```\n\nВместо регистрации HTML-тега пользователь создавал экземпляр класса и вызывал `init()`:\n\n```js\nconst marquee = new MarqueeContent({\n  element: '.marquee',\n});\n\nmarquee.init();\n```\n\nПри размонтировании нужно было симметрично вызвать:\n\n```js\nmarquee.destroy();\n```\n\nПервый вариант API в `4.0.0` принимал элемент или селектор напрямую. Уже в `4.1.0` конструктор получил объект настроек `{ element }`. Такой вызов немного длиннее, зато его проще расширять, не добавляя позиционные аргументы.\n\nС `4.1.0` по `4.5.0` отсутствующий target приводил к раннему выходу из конструктора и оставлял частично созданный экземпляр. В `4.6.0` форма `{ element }` сохранилась, но ошибка снова стала явной: конструктор бросал `Target element not found`.\n\nЯвные `init()` и `destroy()` можно привязать к монтированию и размонтированию компонента во фреймворке. Очистка при этом становится обязанностью пользователя: без `destroy()` `ResizeObserver` и ScrollTrigger продолжат жить после удаления DOM-узла.\n\n## Зависимости нужно передавать полностью\n\nВ ранней реализации четвёртой версии GSAP регистрировался через статический метод, но `ScrollTrigger.refresh()` всё ещё вызывался через глобальный `ScrollTrigger`. Модульный API получился не до конца модульным.\n\nВ `4.2.0` регистрация стала явной для обеих зависимостей:\n\n```js\ngsap.registerPlugin(ScrollTrigger);\nMarqueeContent.registerGSAP(gsap, ScrollTrigger);\n```\n\nМетод `registerGSAP()` сохранял обе ссылки и убирал свободное глобальное имя из пути `refresh()`. Регистрация плагина в самом GSAP оставалась отдельной операцией. В `4.6.0` импортированные GSAP и ScrollTrigger уже служили значениями по умолчанию, а статический метод позволял их переопределить.\n\nВ пакетах такие детали важнее, чем в коде одной страницы. Приложение может рассчитывать на глобальный объект, потому что само контролирует порядок скриптов. Библиотека не должна молча предполагать, что нужное имя уже существует в `window`.\n\n## Первая попытка в TypeScript\n\nДо `4.2.0` включительно исходники оставались на JavaScript и собирались Parcel. В версии `4.3.0` основной файл стал TypeScript, появился `tsconfig.json`, а пакет начал публиковать `dist\u002Findex.d.ts`.\n\nНа уровне списка файлов задача выглядела выполненной. На уровне публичного API — ещё нет.\n\nПараметры регистрации GSAP и часть полей получили тип `never`:\n\n```ts\nstatic registerGSAP(gsap: never, ScrollTrigger: never): void;\n```\n\nТакой тип утверждает, что допустимого значения не существует. Реальный JavaScript ожидал GSAP и ScrollTrigger, но типизированный код не мог передать их в этот метод.\n\nВ декларации нашлась и вторая проблема — два default export:\n\n```ts\nexport default class MarqueeContent {\n  \u002F\u002F ...\n}\n\nexport default MarqueeContent;\n```\n\nПри проверке `.d.ts` это приводило к ошибке `TS2528`. Первая миграция создала TypeScript-файлы, но публичный контракт остался некорректным.\n\n## duration переименован в speed\n\nВ версии `4.4.0` изменился атрибут `data-mc-duration` на `data-mc-speed`:\n\n```html\n\u003Cdiv class=\"marquee\" data-mc-speed=\"20\">\n  \u003C!-- элементы ленты -->\n\u003C\u002Fdiv>\n```\n\nМатематика анимации при этом не изменилась. Значение по-прежнему передавалось в GSAP как `duration`:\n\n```js\ntimeline.to(element.children, {\n  duration: speed,\n  x: '-100%',\n});\n```\n\nПоэтому меньшее значение двигало ленту быстрее, а большее — медленнее. `speed` здесь означал пользовательскую настройку темпа, а не физическую скорость в пикселях в секунду. Имя стало короче, но семантика осталась обратной привычной скорости.\n\nДля HTML это было breaking-изменение: старый `data-mc-duration` библиотека больше не читала.\n\n## Шаг назад без маскировки\n\nВ `4.5.0` я убрал TypeScript из исходников. Декларации исчезли из npm-пакета, а закрытые поля снова использовали нативный синтаксис JavaScript:\n\n```js\nclass MarqueeContent {\n  #element;\n  #timeline;\n  #resizeObserver;\n}\n```\n\nСохранять декларации с `never` только ради непрерывной TypeScript-истории не имело смысла: они неверно описывали публичный контракт.\n\nВозврат к JavaScript был промежуточным состоянием, а не итоговым решением. Публичный runtime API не изменился: `{ element }`, `init()`, `destroy()` и `registerGSAP()` продолжили работать как раньше.\n\n## TypeScript со второй попытки\n\nК версии `4.6.0` я вернул TypeScript, но начал с границы пакета. В декларации появились реальные типы GSAP и ScrollTrigger, а параметры элемента были описаны как строковый селектор или `HTMLElement`.\n\n```ts\ninterface MarqueeOptions {\n  element?: string | HTMLElement;\n}\n```\n\nСодержимое декларации стало соответствовать способу использования библиотеки. Публикация типов при этом осталась незавершённой: export map не содержал условия `types`, поэтому современные режимы module resolution могли не увидеть `dist\u002Findex.d.ts`.\n\nОдновременно сборка переехала с Parcel на Vite в library mode. Пакет начал публиковать три формата:\n\n```json\n{\n  \"main\": \"dist\u002Findex.cjs.js\",\n  \"module\": \"dist\u002Findex.es.js\",\n  \"browser\": \".\u002Fdist\u002Findex.umd.js\",\n  \"types\": \"dist\u002Findex.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"require\": \".\u002Fdist\u002Findex.cjs.js\",\n      \"import\": \".\u002Fdist\u002Findex.es.js\",\n      \"default\": \".\u002Fdist\u002Findex.umd.js\"\n    },\n    \".\u002Fdist\u002F*\": \".\u002Fdist\u002F*\"\n  }\n}\n```\n\nVite создал отдельные CJS-, ESM- и UMD-файлы, а export map направлял `require` и `import` к разным сборкам. Конфигурация была рассчитана прежде всего на bundlers. Без `\"type\": \"module\"` файл `index.es.js` не образовывал полностью корректный native Node dual package.\n\nGSAP не входил в bundle библиотеки: Vite оставлял `gsap` и `gsap\u002FScrollTrigger` внешними модулями. Это уменьшало риск дублирования, но не гарантировало единственную копию зависимости.\n\n## Незакрытый долг\n\n`this.matchMedia.add()` по-прежнему вызывается при повторной настройке для клонирования и анимации, а общий `revert()` при `destroy()` отсутствует. Кроме того, GSAP в `4.6.0` используется во время выполнения, но указан только в `devDependencies`, а не в `peerDependencies`.\n\nЧетвёртая версия стала удобнее для фреймворков не потому, что обычный класс всегда лучше Custom Element. В этой библиотеке явное управление оказалось полезнее автоматического lifecycle. В другом компоненте с изолированной разметкой и минимальной интеграцией выбор мог быть обратным.\n\n## Итоги версии 4.6\n\n1. Отказ от Custom Element убрал второй автоматический lifecycle и позволил работать с существующей разметкой.\n2. Ручные `init()` и `destroy()` дали приложению контроль, но передали ему ответственность за очистку.\n3. Передача GSAP и ScrollTrigger через один API убрала скрытую глобальную зависимость.\n4. Первая миграция на TypeScript создала декларации, которые не проходили собственную проверку.\n5. Вторая миграция исправила содержимое типов и добавила сборки CJS, ESM и UMD, но метаданные пакета ещё требуют доработки.\n\nВозврат к JavaScript в `4.5.0` — промежуточный этап: в `4.6.0` типы вернулись уже от границы публичного API.\n\n## Интересное\n\n- [MDN: Using custom elements](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWeb_components\u002FUsing_custom_elements)\n- [TypeScript: Declaration Files](https:\u002F\u002Fwww.typescriptlang.org\u002Fdocs\u002Fhandbook\u002Fdeclaration-files\u002Fintroduction.html)\n- [Vite: Library Mode](https:\u002F\u002Fvite.dev\u002Fguide\u002Fbuild.html#library-mode)\n- [Node.js: Package entry points](https:\u002F\u002Fnodejs.org\u002Fapi\u002Fpackages.html#package-entry-points)\n- [GSAP: Install](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FInstallation\u002F)","marquee-content-4-proshhaj-custom-element-privet-type-script","Maxim","2025-03-09","2026-08-28T14:11:53.315Z",[],{"id":15,"documentId":16,"title":17,"content":18,"slug":19,"author":10,"displayDate":20,"publishedAt":21,"tags":22},16,"kei6wjtw7x6zzx0sd6481d9z","Анимация бесконечная, слушатели — тоже: marquee-content 3.0","Бесконечная анимация — нормальное поведение для бегущей строки. А вот бесконечные обработчики и наблюдатели — нет.\n\nПри ручных проверках заметил лишнюю нагрузку. В коде нашлись три возможных источника: повторная инициализация, новые scroll-обработчики и неполная очистка.\n\nПубличный API `marquee-content@3.0.0` почти не изменился, зато жизненный цикл компонента пришлось пересобрать.\n\n## Разметка без изменений\n\nМежду `1.9.1` и `3.0.0` компонент по-прежнему подключался как Custom Element:\n\n```html\n\u003Cmarquee-content\n  data-mc-duration=\"20\"\n  data-mc-direction=\"auto\"\n>\n  \u003Cul>\n    \u003Cli>Primary\u003C\u002Fli>\n    \u003Cli>Secondary\u003C\u002Fli>\n    \u003Cli>Tertiary\u003C\u002Fli>\n  \u003C\u002Ful>\n\u003C\u002Fmarquee-content>\n```\n\nСохранились те же основные атрибуты:\n\n- `data-mc-duration`;\n- `data-mc-direction`;\n- `data-mc-skew`;\n- `data-mc-min`;\n- `data-mc-max`.\n\nGSAP всё так же можно было передать через `MarqueeContent.registerGSAP(gsap)`. В `1.9.1` исходный класс уже содержал этот метод, но собранный ESM ещё не экспортировал класс. Экспорт я исправил в `2.0.0`.\n\nПри переходе с `2.4.1` на `3.0.0` пользователю не требовалось менять код: переделка находилась внутри компонента.\n\n## Как накапливалась лишняя работа\n\nВ версии `2.4.1` после подключения элемента `connectedCallback()` вызывал `init()`. Компонент создавал обёртки, считал клоны, применял skew и запускал GSAP-анимацию.\n\nШирину отслеживал `ResizeObserver`. При каждом обновлении размеров компонент заново рассчитывал клоны и пересоздавал Tween. Само по себе это ожидаемо: после изменения ширины старая геометрия уже не гарантирует непрерывную строку.\n\nВ версии `2.4.1` resize проходил через `setTimeout` с задержкой `150ms`, а затем через `requestAnimationFrame`:\n\n```js\ndebounce(fn, delay) {\n  this.timer = null;\n\n  return (...args) => {\n    if (this.timer) clearTimeout(this.timer);\n    this.timer = setTimeout(() => fn(...args), delay);\n  };\n}\n```\n\nПосле таймера `update()` планировал ещё один кадр, в котором выполнялись клонирование и пересоздание анимации. Старый debounce отменял предыдущий timeout, поэтому ожидающий callback был только один. Обработка при этом оставалась привязана и к фиксированной задержке, и к циклу отрисовки браузера.\n\nДля направления `auto` каждая новая анимация добавляла собственный обработчик:\n\n```js\nwindow.addEventListener('scroll', handleScroll, {\n  capture: true,\n  passive: true,\n});\n```\n\nПри resize метод `animation()` убивал предыдущий Tween, но созданный внутри `autoDirection()` scroll-listener не удалялся. Несколько пересборок компонента могли оставить несколько обработчиков одного события.\n\n`disconnectedCallback()` убивал текущий Tween и отменял второй этап rAF, если тот уже был запланирован. Ожидающий timeout при этом не отменялся, а `ResizeObserver` продолжал наблюдение после удаления элемента.\n\n## Вместо таймера — один кадр\n\nВ версии 3.0 debounce больше не использует `setTimeout`:\n\n```js\ndebounce = () => {\n  let timer;\n\n  return () => {\n    cancelAnimationFrame(timer);\n    timer = requestAnimationFrame(this.update);\n  };\n};\n```\n\nКаждый новый вызов отменяет ранее запланированный callback. Частые сигналы объединяются в один `update` без фиксированной задержки `150ms`.\n\nСам `update()` по-прежнему планирует фактическую пересборку через `requestAnimationFrame`:\n\n```js\nupdate() {\n  cancelAnimationFrame(this.af);\n\n  if (this.firstElementChild) {\n    this.af = requestAnimationFrame(() => {\n      this.cloning();\n      this.animation();\n    });\n  }\n}\n```\n\nПолучилась двухступенчатая rAF-схема. Первый кадр объединяет сигналы `ResizeObserver`, поступившие до ближайшей отрисовки, второй выполняет работу с DOM и анимацией. Длительная серия уведомлений всё ещё может запускать обновление в нескольких кадрах.\n\n## ScrollTrigger уже знает направление\n\nСобственный `window.addEventListener('scroll', ...)` оказался лишним. ScrollTrigger и так обновляется при прокрутке и передаёт в `onUpdate` текущий экземпляр со свойством `direction`.\n\nВ версии 3.0 направление меняется внутри уже существующего ScrollTrigger:\n\n```js\nonUpdate: (self) => {\n  if (this.dataset.mcDirection === 'ltr') {\n    this.timeline.timeScale(-1);\n  } else if (this.dataset.mcDirection === 'auto') {\n    this.timeline.timeScale(self.direction);\n  }\n},\n```\n\n`self.direction` возвращает `1` при движении вперёд и `-1` при движении назад. Отдельно хранить предыдущий `scrollY`, сравнивать значения и обслуживать глобальный listener больше не нужно.\n\nЗдесь оптимизация свелась к удалению кода: ScrollTrigger уже обрабатывал событие. Параллельный механизм добавлял состояние, которое приходилось синхронизировать и очищать.\n\n## Очистка в одном месте\n\nНесмотря на имя свойства, `this.timeline` содержит результат `gsap.to()`, то есть Tween. Работа с ним переехала в отдельный метод:\n\n```js\nclearTimeline() {\n  if (this.timeline) {\n    this.timeline.kill();\n    this.timeline = null;\n  }\n\n  this.gsap.set(this.children, { clearProps: true });\n}\n```\n\nТеперь один и тот же cleanup используется перед пересозданием анимации, при выходе из media query и при отключении компонента.\n\nПри этом каждый `update()` снова вызывает `cloning()` и `animation()`. Оба метода регистрируют новый callback через `MM.add()`, но общий `MM.revert()` не вызывается. Нативные scroll-listeners больше не накапливаются, а matchMedia-контексты всё ещё могут.\n\n`disconnectedCallback()` тоже стал полнее:\n\n```js\ndisconnectedCallback() {\n  cancelAnimationFrame(this.af);\n  this.clearTimeline();\n\n  if (this.resizeObserver) {\n    this.resizeObserver.disconnect();\n  }\n}\n```\n\n`ResizeObserver.disconnect()` прекращает наблюдение за всеми связанными элементами. Для Custom Element это обычная часть lifecycle: созданные при подключении наблюдатели нужно отключать при удалении.\n\nОсталась ещё одна проблема. ID первого rAF хранится в локальной переменной внутри `debounce()`, поэтому `disconnectedCallback()` отменяет только `this.af` второго этапа. Уже запланированный первый callback всё ещё может вызвать `update()` после удаления элемента.\n\n## Клоны добавляются одним вызовом\n\nФормула количества клонов не изменилась:\n\n```js\nconst requiredQuantity = Math.ceil(\n  this.scrollWidth \u002F this.firstElementChild.clientWidth + 2,\n);\n```\n\nИзменился способ добавления. Раньше каждый клон сразу вставлялся в DOM внутри цикла. Теперь сначала создаётся массив, а затем все элементы передаются в один `append()`:\n\n```js\nconst clones = Array.from(\n  { length: requiredQuantity - 1 },\n  () => this.firstElementChild.cloneNode(true),\n);\n\nthis.append(...clones);\n```\n\nЗаметного прироста это не гарантирует: клоны всё равно нужно создать, а браузер может объединить последовательные изменения DOM. Практическая польза в другом — подготовка узлов отделена от их добавления.\n\n## Что действительно стало меньше\n\nИсходный код стал короче (npm-тарбол немного распух из-за добавленных `src\u002Fdemo.js` и `src\u002Findex.html`).\n\nДля пользователя важнее, что из runtime исчез собственный scroll-listener, а `ResizeObserver` получил явный `disconnect()`.\n\n## Что осталось незакрытым\n\nВерсия 3.0 закрыла часть долга, но lifecycle остался неполным.\n\n`ResizeObserver` создаётся и начинает наблюдение в конструкторе. После `disconnect()` повторное подключение того же DOM-элемента не запускает `observe()` заново. `init()` повторно оборачивает сохранившуюся структуру, а старые matchMedia-контексты и клоны не получают общего `revert()`.\n\nСценарий `remove → append` всё ещё работает неправильно.\n\n## Итоги версии 3.0\n\n1. Пересмотр кода выявил несколько возможных источников лишней работы: повторную инициализацию, scroll-обработчики и незавершённый `ResizeObserver`.\n2. Удаление собственного scroll-listener оказалось полезнее попытки сделать его быстрее. ScrollTrigger уже предоставлял нужное направление.\n3. `requestAnimationFrame` заменил произвольную задержку и объединил частые resize-сигналы, но сама работа с DOM никуда не исчезла.\n4. `disconnectedCallback()` должен останавливать Tween, наблюдатели, слушатели и запланированные кадры. Остановка только анимации решает не всю задачу.\n5. В этой версии стало меньше обработчиков и короче runtime-код, но измерений прироста производительности у меня пока нет.\n\n## Интересное\n\n- [Документация GSAP ScrollTrigger](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FPlugins\u002FScrollTrigger\u002F)\n- [Свойство ScrollTrigger.direction](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FPlugins\u002FScrollTrigger\u002Fdirection\u002F)\n- [MDN: ResizeObserver](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FResizeObserver)\n- [MDN: ResizeObserver.disconnect()](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FResizeObserver\u002Fdisconnect)\n- [MDN: requestAnimationFrame()](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWindow\u002FrequestAnimationFrame)","animacziya-beskonechnaya-slushateli-tozhe-marquee-content-3-0","2023-10-31","2026-08-28T13:55:07.531Z",[],{"id":24,"documentId":25,"title":26,"content":27,"slug":28,"author":10,"displayDate":29,"publishedAt":30,"tags":31},15,"iw9bln325br894kn79zuz125","Первая стабильная версия marquee-content: эксперимент с GSAP и Custom Elements","Бегущая строка выглядит максимально простой задачей — сложить элементы в ряд, сдвигать их в сторону и начинать сначала. Примерно так я думал, начиная эксперимент с GSAP, ScrollTrigger и Custom Elements.\n\nК первоначальной простой анимации добавил: клонирование содержимого, три направления движения, адаптивные границы, паузу за пределами экрана и отдельную логику для мобильного resize.\n\nРазберу, что вошло в первую стабильную версию `marquee-content@1.0.0`, какие решения сработали и где маленький эксперимент с `repeat: -1` успел обзавестись взрослым техническим долгом.\n\n## Минимальная оболочка: Custom Element\n\nЯ хотел, чтобы компонент подключался без отдельной разметки из служебных обёрток. Пользователь описывает содержимое, а библиотека отвечает за движение:\n\n```html\n\u003Cmarquee-content\n  data-mc-duration=\"20\"\n  data-mc-direction=\"auto\"\n  role=\"marquee\"\n>\n  \u003Cul>\n    \u003Cli>Primary\u003C\u002Fli>\n    \u003Cli>Secondary\u003C\u002Fli>\n    \u003Cli>Tertiary\u003C\u002Fli>\n  \u003C\u002Ful>\n\u003C\u002Fmarquee-content>\n```\n\nКомпонент — автономный Custom Element:\n\n```js\nexport class MarqueeContent extends HTMLElement {\n  constructor() {\n    super();\n\n    this.mm = gsap.matchMedia();\n    this.tl = gsap.timeline();\n    \u002F\u002F Инициализация параметров и анимации\n  }\n}\n\ncustomElements.get('marquee-content')\n  || customElements.define('marquee-content', MarqueeContent);\n```\n\nПроверка через `customElements.get()` не позволяет зарегистрировать элемент заново при следующем подключении скрипта. Сам компонент остаётся в обычном DOM, без Shadow DOM: стили страницы видят содержимое и могут управлять им напрямую.\n\nУ этого решения есть шероховатость. Либа читает атрибуты и дочерние узлы прямо в конструкторе, хотя требования к Custom Elements предписывают отложить такую работу до `connectedCallback()`. Демо работает, потому что скрипт регистрирует элемент после разбора разметки, но при более раннем подключении инициализация может получить пустой элемент.\n\nИнтеграционный долг: демо загружает GSAP 3.11.5 и ScrollTrigger отдельными CDN-скриптами. Пакет уже объявляет `gsap` зависимостью, но исходный модуль всё равно ждёт глобальные `gsap` и `ScrollTrigger`. Версию можно назвать стабильной, а вот способ подключения — не совсем.\n\n## Откуда берётся бесконечность\n\nОдной копии содержимого недостаточно. Когда она уедет за границу экрана, слева или справа (в зависимости от направления движения) появится пустое место. Поэтому компонент сначала вычисляет, сколько копий нужно для заполнения контейнера:\n\n```js\nlet requiredQuantity = (\n  this.clientWidth \u002F this.firstElementChild.clientWidth + 3\n).toFixed(0);\n\nfor (let i = 1; i \u003C requiredQuantity; i++) {\n  const item = this.firstElementChild;\n  const clone = item.cloneNode(true);\n  item.parentNode.append(clone);\n}\n```\n\nВ более компактной записи:\n\n```js\nN = round(W \u002F w + 3)\n```\n\nгде `W` — ширина контейнера, `w` — ширина исходного блока, а `N` — итоговое количество блоков вместе с оригиналом.\n\nНапример, для контейнера шириной `960px` и блока шириной `320px` получается:\n\n```js\nN = round(960 \u002F 320 + 3) = 6\n```\n\nТри блока закрывают видимую ширину, ещё три дают избыточное покрытие во время циклического сдвига. Формула не ищет минимум, а сознательно создаёт лишние копии.\n\n`toFixed(0)` возвращает строку, которую цикл затем неявно преобразует обратно в число. Код работает, но эта маленькая прогулка числа через строку не даёт ничего, кроме будущего вопроса \"а зачем?\". Здесь достаточно `Math.round()`.\n\nПосле клонирования GSAP создаёт timeline — временную шкалу анимации — для всех дочерних элементов:\n\n```js\nthis.tl.to(this.children, {\n  duration: this.duration,\n  x: '-100%',\n  ease: 'none',\n  repeat: -1,\n});\n```\n\nКаждая копия смещается на собственную ширину. Линейная функция плавности (`ease: 'none'`) и бесконечное повторение (`repeat: -1`) создают непрерывный цикл, а одинаковые копии скрывают переход.\n\n## Направление без второй анимации\n\nДля `rtl` и `ltr` не нужны два timeline. Достаточно менять знак `timeScale`:\n\n```js\nthis.tl\n  .to(this.children, {\n    duration: this.duration,\n    x: '-100%',\n    ease: 'none',\n    repeat: -1,\n  })\n  .timeScale(this.dir === 'ltr' ? -1 : 1)\n  .totalProgress(0.5);\n```\n\nПоложительный масштаб времени проигрывает timeline вперёд, отрицательный — назад. `totalProgress(0.5)` помещает позицию воспроизведения в середину условной общей длительности бесконечно повторяющейся анимации, чтобы отрицательный `timeScale` не остановился сразу на абсолютном начале.\n\nТретье значение, `auto`, связывает направление с прокруткой страницы. Обработчик сравнивает текущий `pageYOffset` с предыдущим и плавно переводит `timeScale` в `1` или `-1`:\n\n```js\nconst orientation = window.pageYOffset > currentScroll ? 1 : -1;\n\nif (orientation !== scrollDirection) {\n  gsap.to(this.tl, {\n    timeScale: orientation,\n    overwrite: true,\n  });\n}\n```\n\nScrollTrigger решает другую задачу: ставит анимацию на паузу, когда компонент покидает область просмотра (`viewport`), и возобновляет при возвращении. Невидимая анимация — бесполезное расходование ресурсов.\n\n## Адаптивность через gsap.matchMedia()\n\nКомпонент поддерживает `data-mc-min` и `data-mc-max` по отдельности. Каждый из них превращается в media query, внутри которого создаются клоны и timeline. Если указать оба, `min` перезапишет запрос для `max`, поэтому полноценного диапазона из двух границ пока не получилось.\n\nДля этого использую `gsap.matchMedia()` из GSAP 3.11. Метод запускает переданную функцию при совпадении запроса, а при выходе откатывает созданные GSAP-анимации и вызывает функцию очистки:\n\n```js\nthis.mm.add(this.breakpoint, () => {\n  \u002F\u002F Создание клонов и анимации\n\n  return () => {\n    removingClones();\n  };\n});\n```\n\nЭто оказалось удобнее отдельного набора `matchMedia().addEventListener()` и ручного согласования состояния. При выходе из запроса нужно убрать клоны и встроенные стили, иначе отключённый компонент продолжит влиять на раскладку страницы.\n\nGSAP автоматически откатывает созданные внутри callback анимации и ScrollTrigger, а возвращённая функция удаляет клоны. Нативные обработчики `scroll`, `resize` и `change` не снимаются, поэтому очистка жизненного цикла остаётся неполной.\n\n## \"Мобильные сюрпризы\"\n\nПервый неприятный сюрприз пришёл от iOS. Изменение видимой области браузера во время прокрутки генерировало `resize`. Обработчик безусловно пересобирал timeline и заново считал клоны, даже когда ширина не менялась. Повторная инициализация во время прокрутки могла нарушить непрерывность бегущей строки.\n\nДальше были пробы, ошибки и тесты на реальных мобильных устройствах. Окончательная логика обработки указателей появилась позже. Некоторые ошибки очень убедительно говорят \"починил\", пока ещё раз не откроешь страницу на телефоне.\n\nПроверял несколько подходов:\n\n- Сравнивал новую ширину окна с предыдущей;\n- Пробовал отделять мобильные устройства через `userAgent`;\n- Слушал изменение ориентации;\n- Менял задержку debounce;\n- Полностью убивал timeline перед повторным клонированием.\n\nПроверка `userAgent` вроде бы отделяла известные мобильные платформы, но не определяла причину конкретного `resize`, а увеличение debounce только откладывало лишнюю пересборку.\n\nВ текущей версии обработчики подключаются через два media query:\n\n```js\nthis.mm.add('(any-pointer: coarse)', () => {\n  const portrait = window.matchMedia('(orientation: portrait)');\n\n  portrait.addEventListener('change', (event) => {\n    if (!event.matches) {\n      resetAmin();\n    }\n  });\n});\n\nthis.mm.add('(any-pointer: fine)', () => {\n  window.addEventListener(\n    'resize',\n    this.debounce(resetAmin, 250),\n  );\n});\n```\n\n`(any-pointer: coarse)` включает обработку смены ориентации, а `(any-pointer: fine)` — обычный `resize` с debounce в `250 ms`. Запросы не взаимоисключающие: на гибридном устройстве могут сработать оба.\n\nВетка `coarse` тоже получилась узкой: она пересобирает компонент только при выходе из портретной ориентации. Но это устраняет конкретный сбой, не заставляя тяжёлую пересборку срабатывать вслед за изменением высоты вьюпорта из-за движения адрес-бара на touch-only устройстве.\n\n## API версии 1.0\n\nВ первый стабильный API вошли пять атрибутов:\n\n- `data-mc-duration` — длительность одного цикла, то есть сдвига на ширину блока в секундах, по умолчанию `20`;\n- `data-mc-direction` — `rtl`, `ltr` или `auto`;\n- `data-mc-skew` — наклон по оси Y;\n- `data-mc-min` — минимальная ширина для запуска;\n- `data-mc-max` — максимальная ширина для запуска.\n\n`data-mc-min` и `data-mc-max` работают как альтернативы, а не как совместный диапазон.\n\n## Итоги первой версии\n\n1. Бесконечное движение — это прежде всего управление геометрией. Timeline занимает несколько строк, правильное число копий и пересборка после изменения размеров занимают всё остальное.\n2. `resize` сообщает о событии браузера, а не о намерении пользователя. На ПК эти вещи часто совпадают. На мобильном устройстве `resize` может также возникать при движении адрес-бара или при повороте экрана.\n3. Custom Element — не только красивый тег. Он приносит жизненный цикл, повторное подключение к DOM и обязанность корректно освобождать ресурсы. В текущем варианте эта часть ещё слишком тесно связана с конструктором.\n\nЖелание посмотреть, насколько далеко можно уехать на одном `repeat: -1`, постепенно превращается в небольшой npm-пакет.\n\n## Интересное\n\n- [GSAP 3.11: gsap.matchMedia()](https:\u002F\u002Fgsap.com\u002Fblog\u002F3-11\u002F);\n- [Документация GSAP ScrollTrigger](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FPlugins\u002FScrollTrigger\u002F);\n- [Документация GSAP totalProgress()](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FGSAP\u002FTimeline\u002FtotalProgress()\u002F);\n- [HTML Standard: Custom Elements](https:\u002F\u002Fhtml.spec.whatwg.org\u002Fmultipage\u002Fcustom-elements.html);\n- [Media Queries Level 4: any-pointer](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fmediaqueries-4\u002F#any-input).","pervaya-stabilnaya-versiya-marquee-content-eksperiment-s-gsap-i-custom-elements","2023-03-24","2026-08-28T13:43:14.299Z",[],{"id":5,"documentId":6,"title":7,"content":8,"slug":9,"author":10,"displayDate":11,"publishedAt":12,"tags":33,"html":34},[],"\u003Cp class=\"body-large\">Custom Element давал \u003Ccode>marquee-content\u003C\u002Fcode> готовые lifecycle callbacks. Проблема появилась при интеграции с фреймворком: одним DOM-узлом одновременно управляли браузер и приложение.\u003C\u002Fp>\n\u003Cp class=\"body-large\">К версии 4 я хотел управлять инициализацией явно. Заодно накопились задачи по типам, форматам сборки и устройству npm-пакета. В результате один архитектурный переход растянулся на несколько релизов, а TypeScript пришлось внедрять дважды.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Последний Custom Element\u003C\u002Fh2>\n\u003Cp class=\"body-large\">В версии \u003Ccode>3.1.1\u003C\u002Fcode> либа всё ещё экспортировала класс, унаследованный от \u003Ccode>HTMLElement\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">connectedCallback\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">() {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">init\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">disconnectedCallback\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">() {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">destroy\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Пользователь добавлял собственный тег, а браузер запускал \u003Ccode>connectedCallback()\u003C\u002Fcode>. При удалении элемента \u003Ccode>disconnectedCallback()\u003C\u002Fcode> вызывал \u003Ccode>destroy()\u003C\u002Fcode>: текущий Tween останавливался, \u003Ccode>this.af\u003C\u002Fcode> отменялся, а \u003Ccode>ResizeObserver\u003C\u002Fcode> отключался. Cleanup оставался неполным. Отложенный кадр внутри debounce и контексты \u003Ccode>matchMedia\u003C\u002Fcode> не отменялись, а после повторного подключения observer уже не восстанавливался.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Для изолированного компонента такая схема удобна. Во фреймворке появляется второй жизненный цикл: приложение управляет компонентом страницы, браузер — Custom Element внутри него. Инициализацию и очистку приходится согласовывать между ними.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Мне требовались три вещи:\u003C\u002Fp>\n\u003Cul class=\"list-large\">\n\u003Cli>Принимать уже существующий DOM-элемент, а не требовать специальный тег;\u003C\u002Fli>\n\u003Cli>Явно запускать и останавливать анимацию вместе с компонентом приложения;\u003C\u002Fli>\n\u003Cli>Сократить количество неявного поведения внутри библиотеки.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp class=\"body-large\">Поэтому в \u003Ccode>4.0.0\u003C\u002Fcode> \u003Ccode>MarqueeContent\u003C\u002Fcode> перестал наследоваться от \u003Ccode>HTMLElement\u003C\u002Fcode>, а управление инициализацией и очисткой перешло к вызывающему коду.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Обычный класс и явные init() \u002F destroy()\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Разметка стала обычной:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">div\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  class\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">=\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"marquee\"\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  data-mc-duration\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">=\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"20\"\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  data-mc-direction\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">=\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"auto\"\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">span\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>Primary&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">span\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">span\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>Secondary&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">span\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">span\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>Tertiary&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">span\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">div\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Вместо регистрации HTML-тега пользователь создавал экземпляр класса и вызывал \u003Ccode>init()\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> marquee\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> new\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> MarqueeContent\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">({\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  element: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'.marquee'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">});\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">marquee.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">init\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">При размонтировании нужно было симметрично вызвать:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">marquee.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">destroy\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Первый вариант API в \u003Ccode>4.0.0\u003C\u002Fcode> принимал элемент или селектор напрямую. Уже в \u003Ccode>4.1.0\u003C\u002Fcode> конструктор получил объект настроек \u003Ccode>{ element }\u003C\u002Fcode>. Такой вызов немного длиннее, зато его проще расширять, не добавляя позиционные аргументы.\u003C\u002Fp>\n\u003Cp class=\"body-large\">С \u003Ccode>4.1.0\u003C\u002Fcode> по \u003Ccode>4.5.0\u003C\u002Fcode> отсутствующий target приводил к раннему выходу из конструктора и оставлял частично созданный экземпляр. В \u003Ccode>4.6.0\u003C\u002Fcode> форма \u003Ccode>{ element }\u003C\u002Fcode> сохранилась, но ошибка снова стала явной: конструктор бросал \u003Ccode>Target element not found\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Явные \u003Ccode>init()\u003C\u002Fcode> и \u003Ccode>destroy()\u003C\u002Fcode> можно привязать к монтированию и размонтированию компонента во фреймворке. Очистка при этом становится обязанностью пользователя: без \u003Ccode>destroy()\u003C\u002Fcode> \u003Ccode>ResizeObserver\u003C\u002Fcode> и ScrollTrigger продолжат жить после удаления DOM-узла.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Зависимости нужно передавать полностью\u003C\u002Fh2>\n\u003Cp class=\"body-large\">В ранней реализации четвёртой версии GSAP регистрировался через статический метод, но \u003Ccode>ScrollTrigger.refresh()\u003C\u002Fcode> всё ещё вызывался через глобальный \u003Ccode>ScrollTrigger\u003C\u002Fcode>. Модульный API получился не до конца модульным.\u003C\u002Fp>\n\u003Cp class=\"body-large\">В \u003Ccode>4.2.0\u003C\u002Fcode> регистрация стала явной для обеих зависимостей:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">gsap.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">registerPlugin\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(ScrollTrigger);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">MarqueeContent.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">registerGSAP\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(gsap, ScrollTrigger);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Метод \u003Ccode>registerGSAP()\u003C\u002Fcode> сохранял обе ссылки и убирал свободное глобальное имя из пути \u003Ccode>refresh()\u003C\u002Fcode>. Регистрация плагина в самом GSAP оставалась отдельной операцией. В \u003Ccode>4.6.0\u003C\u002Fcode> импортированные GSAP и ScrollTrigger уже служили значениями по умолчанию, а статический метод позволял их переопределить.\u003C\u002Fp>\n\u003Cp class=\"body-large\">В пакетах такие детали важнее, чем в коде одной страницы. Приложение может рассчитывать на глобальный объект, потому что само контролирует порядок скриптов. Библиотека не должна молча предполагать, что нужное имя уже существует в \u003Ccode>window\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Первая попытка в TypeScript\u003C\u002Fh2>\n\u003Cp class=\"body-large\">До \u003Ccode>4.2.0\u003C\u002Fcode> включительно исходники оставались на JavaScript и собирались Parcel. В версии \u003Ccode>4.3.0\u003C\u002Fcode> основной файл стал TypeScript, появился \u003Ccode>tsconfig.json\u003C\u002Fcode>, а пакет начал публиковать \u003Ccode>dist\u002Findex.d.ts\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp class=\"body-large\">На уровне списка файлов задача выглядела выполненной. На уровне публичного API — ещё нет.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Параметры регистрации GSAP и часть полей получили тип \u003Ccode>never\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">static \u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">registerGSAP\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(gsap: never, ScrollTrigger: never): \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">void\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Такой тип утверждает, что допустимого значения не существует. Реальный JavaScript ожидал GSAP и ScrollTrigger, но типизированный код не мог передать их в этот метод.\u003C\u002Fp>\n\u003Cp class=\"body-large\">В декларации нашлась и вторая проблема — два default export:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> default\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> class\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> MarqueeContent\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  \u002F\u002F ...\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> default\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> MarqueeContent;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">При проверке \u003Ccode>.d.ts\u003C\u002Fcode> это приводило к ошибке \u003Ccode>TS2528\u003C\u002Fcode>. Первая миграция создала TypeScript-файлы, но публичный контракт остался некорректным.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">duration переименован в speed\u003C\u002Fh2>\n\u003Cp class=\"body-large\">В версии \u003Ccode>4.4.0\u003C\u002Fcode> изменился атрибут \u003Ccode>data-mc-duration\u003C\u002Fcode> на \u003Ccode>data-mc-speed\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">div\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> class\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">=\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"marquee\"\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> data-mc-speed\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">=\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"20\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  &#x3C;!-- элементы ленты -->\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">div\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Математика анимации при этом не изменилась. Значение по-прежнему передавалось в GSAP как \u003Ccode>duration\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">timeline.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">to\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(element.children, {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  duration: speed,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  x: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'-100%'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">});\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Поэтому меньшее значение двигало ленту быстрее, а большее — медленнее. \u003Ccode>speed\u003C\u002Fcode> здесь означал пользовательскую настройку темпа, а не физическую скорость в пикселях в секунду. Имя стало короче, но семантика осталась обратной привычной скорости.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Для HTML это было breaking-изменение: старый \u003Ccode>data-mc-duration\u003C\u002Fcode> библиотека больше не читала.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Шаг назад без маскировки\u003C\u002Fh2>\n\u003Cp class=\"body-large\">В \u003Ccode>4.5.0\u003C\u002Fcode> я убрал TypeScript из исходников. Декларации исчезли из npm-пакета, а закрытые поля снова использовали нативный синтаксис JavaScript:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">class\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> MarqueeContent\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  #element\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  #timeline\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  #resizeObserver\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Сохранять декларации с \u003Ccode>never\u003C\u002Fcode> только ради непрерывной TypeScript-истории не имело смысла: они неверно описывали публичный контракт.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Возврат к JavaScript был промежуточным состоянием, а не итоговым решением. Публичный runtime API не изменился: \u003Ccode>{ element }\u003C\u002Fcode>, \u003Ccode>init()\u003C\u002Fcode>, \u003Ccode>destroy()\u003C\u002Fcode> и \u003Ccode>registerGSAP()\u003C\u002Fcode> продолжили работать как раньше.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">TypeScript со второй попытки\u003C\u002Fh2>\n\u003Cp class=\"body-large\">К версии \u003Ccode>4.6.0\u003C\u002Fcode> я вернул TypeScript, но начал с границы пакета. В декларации появились реальные типы GSAP и ScrollTrigger, а параметры элемента были описаны как строковый селектор или \u003Ccode>HTMLElement\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">interface\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> MarqueeOptions\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#FFAB70\">  element\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">?:\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> string\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> |\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> HTMLElement\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Содержимое декларации стало соответствовать способу использования библиотеки. Публикация типов при этом осталась незавершённой: export map не содержал условия \u003Ccode>types\u003C\u002Fcode>, поэтому современные режимы module resolution могли не увидеть \u003Ccode>dist\u002Findex.d.ts\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Одновременно сборка переехала с Parcel на Vite в library mode. Пакет начал публиковать три формата:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">{\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  \"main\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"dist\u002Findex.cjs.js\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  \"module\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"dist\u002Findex.es.js\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  \"browser\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\".\u002Fdist\u002Findex.umd.js\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  \"types\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"dist\u002Findex.d.ts\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  \"exports\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">: {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">    \".\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">: {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">      \"require\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\".\u002Fdist\u002Findex.cjs.js\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">      \"import\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\".\u002Fdist\u002Findex.es.js\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">      \"default\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\".\u002Fdist\u002Findex.umd.js\"\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    },\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">    \".\u002Fdist\u002F*\"\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\".\u002Fdist\u002F*\"\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Vite создал отдельные CJS-, ESM- и UMD-файлы, а export map направлял \u003Ccode>require\u003C\u002Fcode> и \u003Ccode>import\u003C\u002Fcode> к разным сборкам. Конфигурация была рассчитана прежде всего на bundlers. Без \u003Ccode>&quot;type&quot;: &quot;module&quot;\u003C\u002Fcode> файл \u003Ccode>index.es.js\u003C\u002Fcode> не образовывал полностью корректный native Node dual package.\u003C\u002Fp>\n\u003Cp class=\"body-large\">GSAP не входил в bundle библиотеки: Vite оставлял \u003Ccode>gsap\u003C\u002Fcode> и \u003Ccode>gsap\u002FScrollTrigger\u003C\u002Fcode> внешними модулями. Это уменьшало риск дублирования, но не гарантировало единственную копию зависимости.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Незакрытый долг\u003C\u002Fh2>\n\u003Cp class=\"body-large\">\u003Ccode>this.matchMedia.add()\u003C\u002Fcode> по-прежнему вызывается при повторной настройке для клонирования и анимации, а общий \u003Ccode>revert()\u003C\u002Fcode> при \u003Ccode>destroy()\u003C\u002Fcode> отсутствует. Кроме того, GSAP в \u003Ccode>4.6.0\u003C\u002Fcode> используется во время выполнения, но указан только в \u003Ccode>devDependencies\u003C\u002Fcode>, а не в \u003Ccode>peerDependencies\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Четвёртая версия стала удобнее для фреймворков не потому, что обычный класс всегда лучше Custom Element. В этой библиотеке явное управление оказалось полезнее автоматического lifecycle. В другом компоненте с изолированной разметкой и минимальной интеграцией выбор мог быть обратным.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Итоги версии 4.6\u003C\u002Fh2>\n\u003Col class=\"list-large\">\n\u003Cli>Отказ от Custom Element убрал второй автоматический lifecycle и позволил работать с существующей разметкой.\u003C\u002Fli>\n\u003Cli>Ручные \u003Ccode>init()\u003C\u002Fcode> и \u003Ccode>destroy()\u003C\u002Fcode> дали приложению контроль, но передали ему ответственность за очистку.\u003C\u002Fli>\n\u003Cli>Передача GSAP и ScrollTrigger через один API убрала скрытую глобальную зависимость.\u003C\u002Fli>\n\u003Cli>Первая миграция на TypeScript создала декларации, которые не проходили собственную проверку.\u003C\u002Fli>\n\u003Cli>Вторая миграция исправила содержимое типов и добавила сборки CJS, ESM и UMD, но метаданные пакета ещё требуют доработки.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp class=\"body-large\">Возврат к JavaScript в \u003Ccode>4.5.0\u003C\u002Fcode> — промежуточный этап: в \u003Ccode>4.6.0\u003C\u002Fcode> типы вернулись уже от границы публичного API.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Интересное\u003C\u002Fh2>\n\u003Cul class=\"list-large\">\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWeb_components\u002FUsing_custom_elements\">MDN: Using custom elements\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.typescriptlang.org\u002Fdocs\u002Fhandbook\u002Fdeclaration-files\u002Fintroduction.html\">TypeScript: Declaration Files\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fvite.dev\u002Fguide\u002Fbuild.html#library-mode\">Vite: Library Mode\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fnodejs.org\u002Fapi\u002Fpackages.html#package-entry-points\">Node.js: Package entry points\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FInstallation\u002F\">GSAP: Install\u003C\u002Fa>\u003C\u002Fli>\n\u003C\u002Ful>\n",1787926341873]