Как виджет для интернет‑магазина чуть не спалил мой iPhone: Lottie vs видео‑спрайты на телефонах
- суббота, 5 сентября 2026 г. в 00:00:14
Я делаю встраиваемый виджет‑конфигуратор: карточка товара с интерактивным сравнением моделей, режимов, габаритов и комплектации — всё с анимациями. Ничего эзотерического — обычный веб‑виджет, который вставляется на страницу товара в интернет‑магазине.
После активной разработки тестирование на реальном устройстве закончилось фиаско. iPhone 15 Pro Max — топовый флагман, который в теории должен переваривать веб‑анимации не моргнув, — троттлил и нереально грелся в районе процессора.

Замеры температуры чипа и системной нагрузки снимались на iPhone 15 Pro Max через утилиту Device Status во время непрерывного 2-минутного скролла и переключения состояний виджета.
Параметр | Lottie | Видео‑спрайт |
|---|---|---|
Подключение | Один JSON + плеер, пять минут | Рендер, кодирование, раскладка кадров в сетку |
Встраивание в вёрстку | Обычный контейнер, плеер сам рисует | Двойной clip: |
Переключение между состояниями | Загрузка нового 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, и она не ускоряется аппаратно так, как декодирование видео.

Прежде чем переписывать архитектуру, попробовал оптимизировать то, что есть. Детект «слабых» устройств — не по ширине экрана, а по типу указателя:
window.matchMedia('(max-width: 768px), (pointer: coarse)')
pointer: coarse надёжно ловит реальные телефоны независимо от ширины вьюпорта, в отличие от одной только ширины, которая сработает и на десктопе с узким окном.
На устройствах, попадающих под это условие:
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 заметно скачет при рендере анимации.
