javascript

Как виджет для интернет‑магазина чуть не спалил мой iPhone: Lottie vs видео‑спрайты на телефонах

  • суббота, 5 сентября 2026 г. в 00:00:14
https://habr.com/ru/articles/1078644/

Я делаю встраиваемый виджет‑конфигуратор: карточка товара с интерактивным сравнением моделей, режимов, габаритов и комплектации — всё с анимациями. Ничего эзотерического — обычный веб‑виджет, который вставляется на страницу товара в интернет‑магазине.

После активной разработки тестирование на реальном устройстве закончилось фиаско. iPhone 15 Pro Max — топовый флагман, который в теории должен переваривать веб‑анимации не моргнув, — троттлил и нереально грелся в районе процессора.

Разница в температуре между Lottie и видео спрайтом
Разница в температуре между Lottie и видео спрайтом

Замеры температуры чипа и системной нагрузки снимались на iPhone 15 Pro Max через утилиту Device Status во время непрерывного 2-минутного скролла и переключения состояний виджета.


Коротко: Lottie vs видео‑спрайт

Параметр

Lottie

Видео‑спрайт

Подключение

Один JSON + плеер, пять минут

Рендер, кодирование, раскладка кадров в сетку

Встраивание в вёрстку

Обычный контейнер, плеер сам рисует

Двойной clip: viewport по размеру ячейки + <video> в увеличенном масштабе со сдвигом

Переключение между состояниями

Загрузка нового JSON, перемонтирование плеера

Бесшовно: меняется только обрезка уже смонтированного элемента

Декодирование

Каждый кадр пересчитывается в JS

Один раз аппаратно на весь файл, сколько бы состояний в нём ни было

Управление

Полное и программное: скраб на кадр, скорость, цвет

Ограничено: seek по времени и выбор ячейки

Синхронизация с состоянием UI

Тривиальная — смонтировал нужную анимацию

Нужен явный контроль паузы/видимости, иначе всё преимущество теряется

Прозрачность фона

Нативная

Нет, фон впечён в файл

Качество на любом масштабе

Векторное, идеальное

Растровое, зависит от исходника

Фотореализм

Нет, только вектор

Да, любой реальный рендер

CPU и нагрев на мобильных

Высокие на крупных частых анимациях

Низкие — но только при грамотной настройке

Главный неочевидный плюс видео‑спрайта — не только аппаратное декодирование, а бесшовность переключения. Все состояния лежат в одном файле, элемент смонтирован один раз и остаётся на месте, а смена режима — это сдвиг обрезки, а не новая загрузка. У Lottie каждое переключение — это новый JSON и новое монтирование плеера.

Главный неочевидный минус — цена управления. Видео не знает, видно ли его на самом деле. Всю синхронизацию с реальным состоянием интерфейса приходится писать руками, и именно на этом можно потерять весь выигрыш, если что‑то недосмотреть.

Дальше — как я до этого дошёл.

Правило, которому подчинены все решения

Прежде чем оптимизировать, я зафиксировал одно ограничение: нельзя жертвовать анимацией того, на что пользователь реально смотрит в моменте. Ни паузой по бездействию, ни ограничением циклов, ни заменой на статику. Виджет продаёт товар, анимация — часть этой продажи.

Допустима только одна категория правок: устранение работы по декодированию и анимации, которую пользователь физически не может увидеть — спрятанное под непрозрачным оверлеем, скрытое через opacity: 0 или чисто служебный элемент интерфейса вроде подсказки, а не сам товар.

Это правило потом отсекло половину «очевидных» решений вроде «паузить всё через 10 секунд бездействия».

Что анимируем

Виджет показывает несколько состояний товара: режимы работы, сравнение размера, области применения. На вкладке «Габариты» объект можно приложить к руке (размер S или L) или сопоставить с рюкзаком или сумкой. Статичная картинка выглядит мёртво, анимация сразу показывает, как это работает. В общем — по UX полный выигрыш, но по технической части много нюансов.

Начинали с Lottie: рисуем вектор в Illustrator, экспортируем в After Effects, анимируем, гоним в JSON через Bodymovin, подключаем плеер. Никаких кодеков, никакой возни со сжатием, векторное качество на любом экране.


Первые проблемы

С ПК — отлично. С телефона — сильный нагрев, а через пару минут активного использования заметный троттлинг и фризы.

Lottie рендерит покадрово через JS — либо в SVG, либо через canvas. Каждый кадр — пересчёт всех кривых Безье, слоёв и масок заново. Для маленькой иконки это копейки. Для крупной детализированной анимации, которая ещё и часто переключается, это постоянная нагрузка на CPU/GPU, и она не ускоряется аппаратно так, как декодирование видео.

При постоянной смене Lottie растёт температура
При постоянной смене Lottie растёт температура

Первая попытка: выжать максимум из Lottie

Прежде чем переписывать архитектуру, попробовал оптимизировать то, что есть. Детект «слабых» устройств — не по ширине экрана, а по типу указателя:

window.matchMedia('(max-width: 768px), (pointer: coarse)')

pointer: coarse надёжно ловит реальные телефоны независимо от ширины вьюпорта, в отличие от одной только ширины, которая сработает и на десктопе с узким окном.

На устройствах, попадающих под это условие:

Что именно подкрутил в Lottie
  • Canvas‑рендерер вместо SVG. SVG на слабых мобильных GPU держит нагрев ощутимо выше. Canvas перерисовывает растр целиком, но обходится дешевле по construction/layout.

  • Капы devicePixelRatio. Не полный DPR, а 1.5x для крупных иллюстраций (максимальная сторона от 140 CSS px) и 2x для мелких иконок. На крупных элементах это примерно на 44% меньше пикселей на кадр относительно честного 2x, визуально почти незаметно.

  • setQuality('low'). В canvas‑режиме это даёт меньше сегментов кривых Безье на кадр.

  • setSubframe(false). Отключает интерполяцию между кадрами.

Плюс базовая экономия, которая была ещё до этого: IntersectionObserver ставит анимацию на паузу, когда контейнер не пересекается с вьюпортом, а visibilitychange — при уходе со вкладки. Запомните этот механизм, дальше он ещё сыграет.

Эффект был, но проблему не убрал. На самых тяжёлых и часто переключаемых анимациях телефон всё ещё грелся сильнее, чем хотелось бы.

Как устроен видео‑спрайт

Для крупных, часто переключаемых мест векторная анимация заменена на аппаратно декодируемое видео, упакованное как сетка cols × rows кадров и состояний в одном файле.

Как это устроено внутри

Монтирование. mountVideoSprite(container, src, options) рендерит одну ячейку сетки через двойной clip: внешний viewport, обрезанный по размеру одной ячейки после масштабирования по contain, и внутри него сам <video> в увеличенном масштабе, сдвинутый так, чтобы нужная ячейка легла ровно во viewport. Снаружи это выглядит как обычный блок с анимацией нужного размера.

Декодирование. Браузер декодирует файл один раз аппаратно, независимо от того, сколько ячеек в итоге показывается. Переключение между состояниями вообще не требует новой сессии декодирования.

Бесшовность. update(options) меняет обрезку — то есть выбирает, какая ячейка показана, — на уже смонтированном <video>, без пересоздания элемента. Это критично: пересоздание элемента при каждом переключении было исходной причиной подвисаний в сотни миллисекунд на iOS Safari, потому что Safari перезапускает декодирование с нуля для каждого нового <video>.

Persistent video layer. Отсюда и паттерн: один <video>‑слой постоянно остаётся смонтированным и просто скрывается или показывается через CSS, а не размонтируется. Сначала сделал так в карусели режимов, потом распространил на сравнение габаритов (насадка + спрайт руки).

Принцип работы видео спрайта
Принцип работы видео спрайта

На бумаге всё отлично: декодирование ушло в отдельный блок чипа. На практике вылезли нюансы, которых в Lottie просто нет.

Проблема: видео не знает, видно ли его или нет

Ключевая находка: IntersectionObserver следит только за геометрическим пересечением элемента с вьюпортом. Он не видит, что элемент скрыт через opacity: 0 или полностью перекрыт другим элементом сверху — модалкой или непрозрачным оверлеем. В обоих случаях видео продолжает считаться пересекающимся и декодируется впустую, хотя реально невидимо.

Добавил ручной шлюз поверх автоматического — setVisible(visible: boolean) на хендле спрайта. Итоговое условие воспроизведения:

if (
  playbackRequested &&
  isIntersecting &&
  manuallyVisible &&
  document.visibilityState !== 'hidden'
) {
  void video.play().catch(() => undefined);
} else {
  video.pause();
}

Плюс отдельная функция renderSwappableMedia() в дополнение к обычному рендеру медиа: она возвращает не только dispose, но и сам хендл — он нужен везде, где вызывающий код должен уметь руками ставить видео на паузу.

Дальше — охота за местами, где это происходило вживую. Нашлось два класса.

Скрытые дубликаты. На «Габаритах» одновременно декодировались анимируемый элемент и статичный спрайт руки, хотя показывается только один из них, а второй скрыт через CSS.

Видео за модальными окнами. Зум‑просмотр габаритов, модалка чипа характеристик поверх hero‑видео (оверлей с 82% непрозрачности), карусель режимов за «умным зумом». Во всех случаях модалка визуально перекрывает видео на заднем плане, но геометрически оно всё ещё во вьюпорте — и декодируется параллельно с тем, что играет в самой модалке.

Самое интересное: где именно вызывать паузу

Оба фикса с первого раза не сработали, и по разным причинам. Это и есть та самая цена управления, о которой я писал в начале.

Случай 1: не туда положил вызов. На «Габаритах» я сначала попробовал переключать видимость внутри renderObject()/clearObject(). Не сработало по двум причинам сразу: sync() пропускает повторный рендер, если ключ сцены не изменился, а самый первый вызов sync() происходит на этапе создания экрана, когда медиа ещё не смонтировано и хендл равен null. Правильное место — безусловно в начале sync(), а не внутри условного рендера.

Случай 2: не то событие. Для hero‑видео за модалкой чипа я сначала вызывал setVisible внутри sync(state), подписанного на стор. Тоже не сработало: открытие модалки не меняет состояние стора, поэтому sync() в этот момент просто не перезапускается. Правильное место — колбэк onOpenChange, который модалка вызывает синхронно при каждом открытии и закрытии.

Общий вывод: пауза должна быть привязана к тому событию, которое реально совпадает с моментом скрытия, а не к тому, которое кажется логичным местом для этой логики.


Мелкие находки, которые дали неожиданно больше, чем кажется

Бесконечная CSS‑анимация подсказки. На вкладке «Комплектация» нет ни видео, ни Lottie, только картинки — а нагрев держится, пока пользователь просто читает вкладку. Причина нашлась в CSS: подсказка‑пузырёк «Нажмите на любую карточку» крутилась с animation: ... 3.2s ease-in-out infinite — бесконечно, пока пользователь не тапнет. Это чисто служебный элемент, а не сам товар, значит под главное правило подходит. Ограничил тремя циклами: подсказка так же несколько раз подмигивает, потом останавливается, а текст остаётся видимым.

Статичные чипы вместо Lottie на мобильных. Маленькие анимированные иконки характеристик (амплитуда, мотор, батарея, время) на такой площади не оправдывают живую анимацию. Добавил поле mobileMedia в модель:

const chipMedia = isConstrainedRenderer() && tag.mobileMedia ? tag.mobileMedia : tag.media;

На телефонах — статичный WebP (нулевой кадр видео через ffmpeg, Lanczos, libwebp -q:v 85, 160 × 160, 1–7 КБ на файл), на десктопе — та же живая Lottie.

И побочный регресс от этого же изменения. После замены на <img> ожидание готовности первого экрана начало упираться в полный fallback‑таймаут 4500 мс при каждом открытии виджета на мобильной ширине. Причина: готовность экрана отслеживалась только через MutationObserver, а обычный <img> вставляется в DOM ещё до того, как картинка загрузилась, — в отличие от Lottie и видео, чей отрендеренный элемент появляется только после завершения асинхронной загрузки. Починил прямым отслеживанием load/error на самих картинках.

Тот же баг вернулся через полгода

На «Габаритах» изначально было два состояния: видео с рукой или пустой силуэт без сравнения. Логика паузы была написана как бинарный выбор — не сброшено, значит играем видео.

Полгода спустя в модель добавили ещё два состояния: картинки «в рюкзаке» и «в сумке». Условие «не сброшено → играть видео» было верно, пока сцен было ровно две. С появлением рюкзака и сумки то же условие стало запускать видео и для картиночных сцен тоже.

Было / стало
// Было:
persistentVideoHandle?.setVisible(activeKey !== CLEAR_SCENE_KEY);

// Стало:
const activeScene = activeKey === CLEAR_SCENE_KEY
  ? undefined
  : sceneByKey.get(activeKey);

persistentVideoHandle?.setVisible(
  activeScene?.objectMedia?.type === 'video-sprite'
);

Вместо проверки «не пустое ли состояние» стали явно проверять тип медиа у самой сцены. Такая же дыра нашлась в карусели режимов.

Фикс, написанный под текущую форму данных, не переживает расширение этой формы автоматически. Проверять надо инвариант («это видео‑сцена?»), а не текущее количество состояний.

Как проверял, что оно реально работает

Визуально «видео на паузе» и «видео играет под непрозрачной модалкой» выглядят абсолютно одинаково. Поэтому все пункты про паузы проверялись через Chrome DevTools Protocol — headless Chrome и сырые CDP‑команды, без Puppeteer. Состояние video.paused у всех <video> на странице снималось до, во время и после нужного действия: открытия модалки, переключения вкладки, клика по чипу. После каждого исправления — пересборка, три подряд прогона smoke‑тестов на стабильность и только потом деплой.


Итог

Сейчас с телефона на видео‑спрайтах температура держится стабильно в районе ~30 °C, CPU‑график — мелкие короткие всплески. На Lottie за тот же набор действий температура ползёт вверх до 42 °C, а график CPU заметно скачет при рендере анимации.

Температура нормализовалась, троттлинг ушёл
Температура нормализовалась, троттлинг ушёл