[{"data":1,"prerenderedAt":35},["ShallowReactive",2],{"journal-all":3,"journal-post-animacziya-beskonechnaya-slushateli-tozhe-marquee-content-3-0":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":15,"documentId":16,"title":17,"content":18,"slug":19,"author":10,"displayDate":20,"publishedAt":21,"tags":33,"html":34},[],"\u003Cp class=\"body-large\">Бесконечная анимация — нормальное поведение для бегущей строки. А вот бесконечные обработчики и наблюдатели — нет.\u003C\u002Fp>\n\u003Cp class=\"body-large\">При ручных проверках заметил лишнюю нагрузку. В коде нашлись три возможных источника: повторная инициализация, новые scroll-обработчики и неполная очистка.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Публичный API \u003Ccode>marquee-content@3.0.0\u003C\u002Fcode> почти не изменился, зато жизненный цикл компонента пришлось пересобрать.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Разметка без изменений\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Между \u003Ccode>1.9.1\u003C\u002Fcode> и \u003Ccode>3.0.0\u003C\u002Fcode> компонент по-прежнему подключался как Custom Element:\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\">marquee-content\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\">ul\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\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>Primary&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\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\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>Secondary&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\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\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>Tertiary&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\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\">ul\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\">marquee-content\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\u003Cul class=\"list-large\">\n\u003Cli>\u003Ccode>data-mc-duration\u003C\u002Fcode>;\u003C\u002Fli>\n\u003Cli>\u003Ccode>data-mc-direction\u003C\u002Fcode>;\u003C\u002Fli>\n\u003Cli>\u003Ccode>data-mc-skew\u003C\u002Fcode>;\u003C\u002Fli>\n\u003Cli>\u003Ccode>data-mc-min\u003C\u002Fcode>;\u003C\u002Fli>\n\u003Cli>\u003Ccode>data-mc-max\u003C\u002Fcode>.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp class=\"body-large\">GSAP всё так же можно было передать через \u003Ccode>MarqueeContent.registerGSAP(gsap)\u003C\u002Fcode>. В \u003Ccode>1.9.1\u003C\u002Fcode> исходный класс уже содержал этот метод, но собранный ESM ещё не экспортировал класс. Экспорт я исправил в \u003Ccode>2.0.0\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp class=\"body-large\">При переходе с \u003Ccode>2.4.1\u003C\u002Fcode> на \u003Ccode>3.0.0\u003C\u002Fcode> пользователю не требовалось менять код: переделка находилась внутри компонента.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Как накапливалась лишняя работа\u003C\u002Fh2>\n\u003Cp class=\"body-large\">В версии \u003Ccode>2.4.1\u003C\u002Fcode> после подключения элемента \u003Ccode>connectedCallback()\u003C\u002Fcode> вызывал \u003Ccode>init()\u003C\u002Fcode>. Компонент создавал обёртки, считал клоны, применял skew и запускал GSAP-анимацию.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Ширину отслеживал \u003Ccode>ResizeObserver\u003C\u002Fcode>. При каждом обновлении размеров компонент заново рассчитывал клоны и пересоздавал Tween. Само по себе это ожидаемо: после изменения ширины старая геометрия уже не гарантирует непрерывную строку.\u003C\u002Fp>\n\u003Cp class=\"body-large\">В версии \u003Ccode>2.4.1\u003C\u002Fcode> resize проходил через \u003Ccode>setTimeout\u003C\u002Fcode> с задержкой \u003Ccode>150ms\u003C\u002Fcode>, а затем через \u003Ccode>requestAnimationFrame\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\">debounce\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(fn, delay) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.timer \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> null\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">...\u003C\u002Fspan>\u003Cspan style=\"color:#FFAB70\">args\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">) \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    if\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.timer) \u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">clearTimeout\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.timer);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">    this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.timer \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> setTimeout\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(() \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> fn\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">...\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">args), delay);\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\">После таймера \u003Ccode>update()\u003C\u002Fcode> планировал ещё один кадр, в котором выполнялись клонирование и пересоздание анимации. Старый debounce отменял предыдущий timeout, поэтому ожидающий callback был только один. Обработка при этом оставалась привязана и к фиксированной задержке, и к циклу отрисовки браузера.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Для направления \u003Ccode>auto\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\">window.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">addEventListener\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'scroll'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">, handleScroll, {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  capture: \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">true\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  passive: \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">true\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\">При resize метод \u003Ccode>animation()\u003C\u002Fcode> убивал предыдущий Tween, но созданный внутри \u003Ccode>autoDirection()\u003C\u002Fcode> scroll-listener не удалялся. Несколько пересборок компонента могли оставить несколько обработчиков одного события.\u003C\u002Fp>\n\u003Cp class=\"body-large\">\u003Ccode>disconnectedCallback()\u003C\u002Fcode> убивал текущий Tween и отменял второй этап rAF, если тот уже был запланирован. Ожидающий timeout при этом не отменялся, а \u003Ccode>ResizeObserver\u003C\u002Fcode> продолжал наблюдение после удаления элемента.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Вместо таймера — один кадр\u003C\u002Fh2>\n\u003Cp class=\"body-large\">В версии 3.0 debounce больше не использует \u003Ccode>setTimeout\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\">debounce\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> () \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  let\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> timer;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> () \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    cancelAnimationFrame\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(timer);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    timer \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> requestAnimationFrame\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.update);\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\">Каждый новый вызов отменяет ранее запланированный callback. Частые сигналы объединяются в один \u003Ccode>update\u003C\u002Fcode> без фиксированной задержки \u003Ccode>150ms\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Сам \u003Ccode>update()\u003C\u002Fcode> по-прежнему планирует фактическую пересборку через \u003Ccode>requestAnimationFrame\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\">update\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">() {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  cancelAnimationFrame\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.af);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  if\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.firstElementChild) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">    this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.af \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> requestAnimationFrame\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(() \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\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\">cloning\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\">animation\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\">\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\">Получилась двухступенчатая rAF-схема. Первый кадр объединяет сигналы \u003Ccode>ResizeObserver\u003C\u002Fcode>, поступившие до ближайшей отрисовки, второй выполняет работу с DOM и анимацией. Длительная серия уведомлений всё ещё может запускать обновление в нескольких кадрах.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">ScrollTrigger уже знает направление\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Собственный \u003Ccode>window.addEventListener('scroll', ...)\u003C\u002Fcode> оказался лишним. ScrollTrigger и так обновляется при прокрутке и передаёт в \u003Ccode>onUpdate\u003C\u002Fcode> текущий экземпляр со свойством \u003Ccode>direction\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp class=\"body-large\">В версии 3.0 направление меняется внутри уже существующего ScrollTrigger:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">onUpdate\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">: (\u003C\u002Fspan>\u003Cspan style=\"color:#FFAB70\">self\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">) \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  if\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.dataset.mcDirection \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">===\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\"> 'ltr'\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\">.timeline.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">timeScale\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">-\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">1\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  } \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">else\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> if\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.dataset.mcDirection \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">===\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\"> 'auto'\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\">.timeline.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">timeScale\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(self.direction);\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\">\u003Ccode>self.direction\u003C\u002Fcode> возвращает \u003Ccode>1\u003C\u002Fcode> при движении вперёд и \u003Ccode>-1\u003C\u002Fcode> при движении назад. Отдельно хранить предыдущий \u003Ccode>scrollY\u003C\u002Fcode>, сравнивать значения и обслуживать глобальный listener больше не нужно.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Здесь оптимизация свелась к удалению кода: ScrollTrigger уже обрабатывал событие. Параллельный механизм добавлял состояние, которое приходилось синхронизировать и очищать.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Очистка в одном месте\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Несмотря на имя свойства, \u003Ccode>this.timeline\u003C\u002Fcode> содержит результат \u003Ccode>gsap.to()\u003C\u002Fcode>, то есть Tween. Работа с ним переехала в отдельный метод:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">clearTimeline\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">() {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  if\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.timeline) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">    this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.timeline.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">kill\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\">.timeline \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> null\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:#79B8FF\">  this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.gsap.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">set\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.children, { clearProps: \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">true\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\">Теперь один и тот же cleanup используется перед пересозданием анимации, при выходе из media query и при отключении компонента.\u003C\u002Fp>\n\u003Cp class=\"body-large\">При этом каждый \u003Ccode>update()\u003C\u002Fcode> снова вызывает \u003Ccode>cloning()\u003C\u002Fcode> и \u003Ccode>animation()\u003C\u002Fcode>. Оба метода регистрируют новый callback через \u003Ccode>MM.add()\u003C\u002Fcode>, но общий \u003Ccode>MM.revert()\u003C\u002Fcode> не вызывается. Нативные scroll-listeners больше не накапливаются, а matchMedia-контексты всё ещё могут.\u003C\u002Fp>\n\u003Cp class=\"body-large\">\u003Ccode>disconnectedCallback()\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\">disconnectedCallback\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">() {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  cancelAnimationFrame\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.af);\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\">clearTimeline\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  if\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.resizeObserver) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">    this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.resizeObserver.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">disconnect\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\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">\u003Ccode>ResizeObserver.disconnect()\u003C\u002Fcode> прекращает наблюдение за всеми связанными элементами. Для Custom Element это обычная часть lifecycle: созданные при подключении наблюдатели нужно отключать при удалении.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Осталась ещё одна проблема. ID первого rAF хранится в локальной переменной внутри \u003Ccode>debounce()\u003C\u002Fcode>, поэтому \u003Ccode>disconnectedCallback()\u003C\u002Fcode> отменяет только \u003Ccode>this.af\u003C\u002Fcode> второго этапа. Уже запланированный первый callback всё ещё может вызвать \u003Ccode>update()\u003C\u002Fcode> после удаления элемента.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Клоны добавляются одним вызовом\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:#F97583\">const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> requiredQuantity\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> Math.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">ceil\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\">.scrollWidth \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.firstElementChild.clientWidth \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">+\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 2\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\">Изменился способ добавления. Раньше каждый клон сразу вставлялся в DOM внутри цикла. Теперь сначала создаётся массив, а затем все элементы передаются в один \u003Ccode>append()\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\"> clones\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> Array.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">from\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  { length: requiredQuantity \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">-\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 1\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> },\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  () \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.firstElementChild.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">cloneNode\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">true\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:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">append\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">...\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">clones);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Заметного прироста это не гарантирует: клоны всё равно нужно создать, а браузер может объединить последовательные изменения DOM. Практическая польза в другом — подготовка узлов отделена от их добавления.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Что действительно стало меньше\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Исходный код стал короче (npm-тарбол немного распух из-за добавленных \u003Ccode>src\u002Fdemo.js\u003C\u002Fcode> и \u003Ccode>src\u002Findex.html\u003C\u002Fcode>).\u003C\u002Fp>\n\u003Cp class=\"body-large\">Для пользователя важнее, что из runtime исчез собственный scroll-listener, а \u003Ccode>ResizeObserver\u003C\u002Fcode> получил явный \u003Ccode>disconnect()\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Что осталось незакрытым\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Версия 3.0 закрыла часть долга, но lifecycle остался неполным.\u003C\u002Fp>\n\u003Cp class=\"body-large\">\u003Ccode>ResizeObserver\u003C\u002Fcode> создаётся и начинает наблюдение в конструкторе. После \u003Ccode>disconnect()\u003C\u002Fcode> повторное подключение того же DOM-элемента не запускает \u003Ccode>observe()\u003C\u002Fcode> заново. \u003Ccode>init()\u003C\u002Fcode> повторно оборачивает сохранившуюся структуру, а старые matchMedia-контексты и клоны не получают общего \u003Ccode>revert()\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Сценарий \u003Ccode>remove → append\u003C\u002Fcode> всё ещё работает неправильно.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Итоги версии 3.0\u003C\u002Fh2>\n\u003Col class=\"list-large\">\n\u003Cli>Пересмотр кода выявил несколько возможных источников лишней работы: повторную инициализацию, scroll-обработчики и незавершённый \u003Ccode>ResizeObserver\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>Удаление собственного scroll-listener оказалось полезнее попытки сделать его быстрее. ScrollTrigger уже предоставлял нужное направление.\u003C\u002Fli>\n\u003Cli>\u003Ccode>requestAnimationFrame\u003C\u002Fcode> заменил произвольную задержку и объединил частые resize-сигналы, но сама работа с DOM никуда не исчезла.\u003C\u002Fli>\n\u003Cli>\u003Ccode>disconnectedCallback()\u003C\u002Fcode> должен останавливать Tween, наблюдатели, слушатели и запланированные кадры. Остановка только анимации решает не всю задачу.\u003C\u002Fli>\n\u003Cli>В этой версии стало меньше обработчиков и короче runtime-код, но измерений прироста производительности у меня пока нет.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2 class=\"headline-medium\">Интересное\u003C\u002Fh2>\n\u003Cul class=\"list-large\">\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FPlugins\u002FScrollTrigger\u002F\">Документация GSAP ScrollTrigger\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FPlugins\u002FScrollTrigger\u002Fdirection\u002F\">Свойство ScrollTrigger.direction\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FResizeObserver\">MDN: ResizeObserver\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FResizeObserver\u002Fdisconnect\">MDN: ResizeObserver.disconnect()\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWindow\u002FrequestAnimationFrame\">MDN: requestAnimationFrame()\u003C\u002Fa>\u003C\u002Fli>\n\u003C\u002Ful>\n",1787926341909]