FLIP: анимация layout без layout на каждом кадре
Анимация top или height гоняет layout по всем элементам на каждом кадре. FLIP платит за layout один раз: измерь First, примени Last, Invert трансформом, Play к identity. Батчируй чтения до записей против трэшинга; layoutId во Framer Motion — это FLIP с автоизмерением.
Дизайн хотел, чтобы карточки канбана плавно скользили на место при переупорядочивании, и первая реализация сделала очевидное: JS-твин на top каждой карточки. С 200 карточками перетаскивание шло на 22 fps на ноутбуке продакта — трейс показывал Layout 8 мс плюс Paint 4 мс, повторяющиеся каждый кадр, потому что каждое интерполированное значение top заново гоняло геометрию всей колонки. Вторая попытка была более красивым кодом с той же физикой: CSS-переходы на top. Те же фиолетовые полосы, те же 22 fps — декларативная форма ничего не изменила в том, какую стадию конвейера пачкает свойство. Третья попытка ещё и привезла бонусный баг: хелпер, «синхронизировавший» позиции чтением getBoundingClientRect после каждой записи стиля в цикле, форсировал 200 синхронных layout за один кадр — заморозка на 380 мс при каждом дропе. Лечение, которое наконец удержалось, — FLIP в 30 строк: измерить каждую карточку до коммита переупорядочивания, дать React закоммитить новый layout одним махом, измерить снова, затем анимировать каждую карточку от инвертированного трансформа обратно к identity. Layout выполнялся дважды на дроп вместо двух раз на кадр; каждый анимированный кадр был чисто трансформной работой компоновщика; тот же ноутбук рендерил те же 200 карточек на ровных 60 fps. Урок не в том, что анимация layout запрещена, — а в том, что платить за layout можно только в конечных точках, никогда в середине.
Проблема: направление движения — изменение layout
Переупорядоченные списки, раскрывающиеся карточки, элементы, входящие в сетку, — конечное состояние действительно отличается геометрией, так что какая-то работа layout неизбежна. Наивные реализации анимируют сами layout-свойства (top, left, width, height), а это худший случай из прошлого урока, умноженный на число элементов: каждый кадр заново гоняет style, layout и paint для каждого анимируемого элемента и всего, чего касается их геометрия. Числа из Hook: layout колонки в 200 карточек стоит ~8 мс, paint ~4 мс — 12 мс из кадра в 16,7 мс при 60 Гц потрачены до всякого JS, а при 120 Гц это арифметически невозможно. Озарение за FLIP: браузеры превосходны в анимации transform, а любое движение между двумя известными прямоугольниками выражается трансформом. Так вычислите оба прямоугольника — и не анимируйте геометрию вовсе.
FLIP: First, Last, Invert, Play
First — измерить прямоугольник каждого элемента до изменения. Last — применить настоящее изменение DOM (React коммитит переупорядочивание) и измерить новые прямоугольники. Invert — для каждого элемента вычислить дельту и применить трансформ, визуально возвращающий его назад, где он был: DOM в финальном состоянии, но экран всё ещё показывает старое. Play — анимировать трансформ к identity; элемент скользит туда, где он уже находится. В React шов для Last и Invert — useLayoutEffect: он выполняется после мутации DOM, но до отрисовки, так что пользователь никогда не видит неинвертированный кадр. Все четыре шага вместе означают: layout выполняется только в конечных точках, никогда в середине; пропусти Invert — и пользователь увидит визуальный скачок ещё до начала анимации.
function reorder(nextOrder) {
// First: измеряем каждую строку ДО того, как React закоммитит новый layout
const first = new Map();
refs.current.forEach((el, id) => first.set(id, el.getBoundingClientRect()));
firstRects.current = first;
setOrder(nextOrder);
}
useLayoutEffect(() => {
const first = firstRects.current;
if (!first) return;
firstRects.current = null;
// Last: новый layout уже в DOM, но ещё не отрисован
refs.current.forEach((el, id) => {
const f = first.get(id);
if (!f) return; // вошедший элемент: нет First-прямоугольника
const l = el.getBoundingClientRect(); // батч чтений — записей ещё нет
const dx = f.left - l.left;
const dy = f.top - l.top;
if (dx === 0 && dy === 0) return;
// Invert + Play: стартуем смещёнными, анимируем к identity
el.animate(
[{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }],
{ duration: 220, easing: "cubic-bezier(0.2, 0, 0, 1)" }
);
});
}, [order]);На дроп это платит за layout дважды — один раз неявно, когда чтения First едут на чистом layout прошлого кадра, и один раз, когда чтения Last форсируют layout для свежезакоммиченного DOM, — а затем каждый анимированный кадр — трансформная работа, годная компоновщику. WAAPI el.animate — правильный инструмент для Play: он компонуется с остальными стилями элемента, сам прибирается и даёт finished и cancel для прерывания.
getBoundingClientRect и layout-трэшинг
getBoundingClientRect дёшев, когда layout чист, — он читает кэшированную геометрию. Дорог он ровно в одной ситуации: layout грязный (что-то записало стиль или мутировало DOM после последнего layout), и тогда браузер обязан выполнить форсированный синхронный layout прямо сейчас, чтобы ответить корректно. Перемежайте чтения и записи в цикле — и вы форсируете один layout на итерацию: заморозка из Hook на 380 мс была 200 форсированными layout в одном кадре. Дисциплина — батчирование: сначала все чтения, потом все записи. Корректно написанный FLIP батчируется естественно: First — проход чтений, коммит — один залп записей, Last — проход чтений, Invert — проход записей. Сломайте порядок (прочитать карточку, трансформировать, прочитать следующую) — сами трансформы в порядке, но чередование воскрешает трэшинг.
Обработчик дропа в цикле по 200 карточкам делает el.getBoundingClientRect(), а затем сразу el.style.top = ... для каждой карточки по очереди. Дроп замирает на ~400 мс. Каков механизм?
layoutId во Framer Motion: FLIP с автоматизированными измерениями
Проп layout и layoutId во Framer Motion — не другая технология, это FLIP, где бухгалтерию ведут за вас. На каждом рендере библиотека измеряет bounding box каждого отслеживаемого элемента, сравнивает с предыдущими измерениями и, если бокс сдвинулся или изменил размер, применяет инвертированную дельту как трансформ и проигрывает к identity. layoutId расширяет тот же трюк между компонентами: когда монтируется новый элемент с layoutId только что размонтированного, библиотека берёт rect старого элемента как First, а rect нового как Last — переход общего элемента, собранный из тех же getBoundingClientRect и transform, которые вы можете написать руками. Это знание демистифицирует режимы отказа: layout-анимации стоят проход измерений на рендер (поэтому layout навешивают на то, что двигается, а не на всё дерево), и всё, что FLIP не может подделать — перенос текста, отдельные эффекты paint, — Framer Motion тоже не может.
Искажение масштаба и контр-масштабная коррекция
Перемещение элементов — лёгкий случай: translate ничего не искажает. Изменение размера через FLIP означает анимацию scale, а scale умножает всё внутри: раскрывающаяся карточка, едущая с 320 px к 640 px ширины, в полёте визуально сжимает или растягивает своих детей — текст, аватары, рамки. Коррекция — контр-масштабирование детей: применить обратный коэффициент (1/масштаб-родителя, пересчитываемый на каждом кадре, потому что масштаб родителя меняется на каждом кадре) к обёртке детей. Framer Motion делает это автоматически для вложенных layout-элементов; рукописный FLIP обязан вести обе анимации от одних часов. У border-radius та же болезнь: радиус 12 px под scaleX(2) отрисовывается эллипсом 24 на 12 px — поэтому продакшен-FLIP либо анимирует радиус отдельно, либо принимает краткое искажение на быстрых (до ~250 мс) анимациях.
Где FLIP ломается
- Перенос текста. Если реальная ширина меняется, текст переносится по строкам на ширине назначения. FLIP показывает масштабированный — растянутый — текст в полёте, а в конце текст внезапно правильный; не существует трансформа, который переносит строки. Варианты: зафиксировать ширину контейнера текста и анимировать только бокс; кроссфейд между двумя снимками; держать анимацию настолько короткой, что растяжение читается как motion blur.
- Входящие и выходящие элементы. Только что смонтированный элемент не имеет First-прямоугольника (нечего инвертировать — дайте ему отдельную анимацию входа); размонтированный нельзя измерить для Last (его больше нет — анимациям выхода нужен живой элемент, что и делает AnimatePresence).
- Протухшее измерение. First измерили, а затем что-то ещё сдвинуло страницу (догрузилась картинка, схлопнулся баннер) до Last — дельты неверны, и элементы скользят из неправильного начала. Измеряйте как можно позже, непосредственно перед коммитом.
Коллега утверждает, что layoutId во Framer Motion использует нативный браузерный API общих элементов — потому и может анимировать элемент между двумя компонентами без покадровой цены JS. Что layoutId делает на самом деле?
Числа: переупорядочивание 200 элементов, наивно против FLIP
Наивный твин на top, 200 элементов, 60 Гц: каждый кадр платит пересчёт стилей (~1,5 мс) + layout (~8 мс) + paint (~4 мс) ≈ 13,5 мс — бюджет превышен при нулевом JS, наблюдаемые ~22 fps, а при 120 Гц это невозможно даже теоретически. FLIP на том же списке: кадр дропа платит два прохода layout (чтения First едут на чистом layout прошлого кадра; чтения Last форсируют один layout ≈ 8 мс) плюс 200 вызовов el.animate (~1 мс) — один длинноватый кадр в ~10–15 мс однократно, а дальше каждый анимированный кадр — только transform: микросекунды главного потока, остальное делает компоновщик, ровные 60 fps на ноутбуке из Hook. Сделка честная: FLIP покупает покадровую свободу ценой ограниченной разовой стоимости измерений — и эта стоимость растёт с числом элементов, поэтому Framer Motion ограничивает измерения элементами с тегом layout, а не всем деревом.
▸Почему это работает
Почему transform дёшев там, где width дорог? Не магия — область действия. Геометрия глобальна: одно изменение width может сдвинуть соседей, растянуть предков, перенести текст в трёх компонентах отсюда, поэтому layout обязан рассматривать документ, и движок не может ограничить работу. transform определён локальным: он применяется после layout, в собственной системе координат элемента, и гарантированно не влияет на геометрию любого другого элемента — именно эта гарантия позволяет компоновщику применять его к растеризованному битмапу, не спрашивая главный поток. FLIP — искусство отмывания глобальной неограниченной операции в локальную ограниченную: сделать глобальную работу (layout) ровно дважды в конечных точках, выразить разницу локально (трансформ на элемент) и пустить движение по дешёвой полосе.
- 01Пройдите четыре шага FLIP для переупорядочивания списка в React, называя, где выполняется каждый и почему useLayoutEffect — правильный шов.
- 02Объясните layout-трэшинг, почему корректный FLIP его избегает, и два визуальных артефакта масштабирования через FLIP с их коррекциями.
Анимация layout желанна ровно там, где дороже всего: переупорядочивания, раскрытия и общие элементы заканчиваются действительно другой геометрией, и прямая анимация layout-свойств умножает худший случай прошлого урока на число элементов — 200 карточек из Hook платили 8 мс layout плюс 4 мс paint на кадр и шли на 22 fps, одинаково для JS-твина и более красивого CSS-перехода, потому что стоимость конвейера решает класс свойства, а не синтаксис. FLIP сбегает, платя за layout только в конечных точках: измерить First до изменения, дать React закоммитить, измерить Last в useLayoutEffect — после мутации DOM, до отрисовки, — затем инвертировать каждый элемент на его дельту и проиграть трансформ к identity через el.animate, оставляя каждый промежуточный кадр трансформной, годной компоновщику работой. Техника неотделима от дисциплины чтений и записей: getBoundingClientRect стоит микросекунды на чистом layout, но форсирует синхронный reflow на грязном, так что чередование чтений с записями воскрешает layout на каждой итерации — батчируйте все чтения, затем все записи, что фазы самого FLIP естественно навязывают. layout и layoutId во Framer Motion — эта техника в автоматизированном виде: измерить на рендере, инвертировать, проиграть, включая межкомпонентный случай общего элемента, собранный из монтирования и размонтирования с общим id, — что объясняет и их пределы: проходы измерений стоят время, пропорциональное отслеживаемому, а чего не выражает transform, не выразит и библиотека. Масштаб приносит два честных артефакта — искажение детей, корректируемое покадровым контр-масштабированием, и растяжение border-radius, корректируемое отдельно или терпимое накоротке, — и одну глухую стену: перенос текста, где строки переносятся только на ширине назначения и никакой трансформ этого не подделает. Арифметика на память: два прохода layout на изменение против одного на кадр — в этом соотношении вся техника. Теперь, когда встретишь переупорядочивание списка или раскрывающуюся карточку, ты потянешься к FLIP раньше, чем к layout-свойству — потому что знаешь, что означают фиолетовые блоки Layout в трейсе при 60 Гц, не говоря уже о 120.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.