[{"data":1,"prerenderedAt":26},["ShallowReactive",2],{"journal-all":3,"journal-post-marquee-content-3-0-animacziya-beskonechnaya-slushateli-tozhe":23},[4,14],{"id":5,"documentId":6,"title":7,"content":8,"slug":9,"author":10,"displayDate":11,"publishedAt":12,"tags":13},6,"kei6wjtw7x6zzx0sd6481d9z","marquee-content 3.0: анимация бесконечная, слушатели — тоже","Бесконечная анимация — нормальное поведение для бегущей строки. Бесконечные обработчики и наблюдатели — нет.\n\nПри ручных проверках заметил лишнюю нагрузку. В коде нашлись три возможных источника: повторная инициализация, новые scroll-обработчики и неполная очистка. Точный сценарий воспроизведения и вклад каждого механизма не сохранились.\n\n`marquee-content@3.0.0` опубликован. Публичный API почти не изменился, зато жизненный цикл компонента пришлось пересобрать.\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)","marquee-content-3-0-animacziya-beskonechnaya-slushateli-tozhe","Maxim","2023-10-31","2026-08-26T21:36:27.007Z",[],{"id":15,"documentId":16,"title":17,"content":18,"slug":19,"author":10,"displayDate":20,"publishedAt":21,"tags":22},4,"iw9bln325br894kn79zuz125","Бегущая строка за четыре дня: первая стабильная версия marquee-content","Бегущая строка выглядит максимально простой задачей — сложить элементы в ряд, сдвигать их в сторону и начинать сначала. Примерно так я думал, начиная эксперимент с 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```text\nN = round(W \u002F w + 3)\n```\n\nгде `W` — ширина контейнера, `w` — ширина исходного блока, а `N` — итоговое количество блоков вместе с оригиналом.\n\nНапример, для контейнера шириной `960px` и блока шириной `320px` получается:\n\n```text\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)\n","begushhaya-stroka-za-chetyre-dnya-pervaya-stabilnaya-versiya-marquee-content","2023-03-24","2026-08-26T21:01:08.447Z",[],{"id":5,"documentId":6,"title":7,"content":8,"slug":9,"author":10,"displayDate":11,"publishedAt":12,"tags":24,"html":25},[],"\u003Cp class=\"body-large\">Бесконечная анимация — нормальное поведение для бегущей строки. Бесконечные обработчики и наблюдатели — нет.\u003C\u002Fp>\n\u003Cp class=\"body-large\">При ручных проверках заметил лишнюю нагрузку. В коде нашлись три возможных источника: повторная инициализация, новые scroll-обработчики и неполная очистка. Точный сценарий воспроизведения и вклад каждого механизма не сохранились.\u003C\u002Fp>\n\u003Cp class=\"body-large\">\u003Ccode>marquee-content@3.0.0\u003C\u002Fcode> опубликован. Публичный API почти не изменился, зато жизненный цикл компонента пришлось пересобрать.\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>\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\">Свойство \u003Ccode>ScrollTrigger.direction\u003C\u002Fcode>\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FResizeObserver\">MDN: \u003Ccode>ResizeObserver\u003C\u002Fcode>\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FResizeObserver\u002Fdisconnect\">MDN: \u003Ccode>ResizeObserver.disconnect()\u003C\u002Fcode>\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWindow\u002FrequestAnimationFrame\">MDN: \u003Ccode>requestAnimationFrame()\u003C\u002Fcode>\u003C\u002Fa>\u003C\u002Fli>\n\u003C\u002Ful>\n",1787812879731]