Почему сайт тормозит: reflow, repaint, composite
Что браузер делает между «получил CSS» и «показал картинку», какие свойства стоят дорого и как это увидеть, а не угадать.
Что ты научишься делать
- Поймёшь, из каких этапов складывается отрисовка кадра
- Отличишь дорогие свойства от дешёвых и объяснишь почему
- Найдёшь причину подтормаживания во вкладке Performance
#Пять этапов одного кадра
Чтобы показать страницу, браузер проходит одну и ту же цепочку. Понимать её нужно ровно затем, чтобы знать: с какого этапа начнётся пересчёт после твоей правки.
Style — вычисление стилей
Для каждого элемента собирается итоговый набор свойств: каскад, наследование, специфичность.
Layout — раскладка
Считаются размеры и координаты. Самый дорогой этап: изменение одного элемента может сдвинуть половину страницы. Его же называют reflow.
Paint — отрисовка
Заполняются пиксели: фоны, текст, тени, рамки. Дешевле раскладки, но всё ещё работа процессора.
Composite — сборка слоёв
Готовые куски складываются в итоговую картинку. Это делает видеокарта, и делает очень быстро.
Показ кадра
На всё про всё около 16 миллисекунд, если хочешь 60 кадров в секунду.
#Что с какого этапа запускает пересчёт
| Меняем | Начинается с | Цена |
|---|---|---|
width, height, top, left, margin, padding | Layout | Дорого: пересчёт раскладки, потом отрисовка и сборка |
font-size, display, добавление элемента | Layout | Дорого |
color, background, box-shadow, border-radius | Paint | Средне: раскладка не трогается |
transform, opacity, filter | Composite | Дёшево: работа уходит видеокарте |
Отсюда правило, которое повторяют в каждой статье про анимацию: анимируй только transform и opacity. Теперь понятно, почему именно их: только они не заставляют браузер пересчитывать раскладку и перерисовывать пиксели.
Так не надо
/* Каждый кадр — новая раскладка всей страницы */
@keyframes slide {
from { left: 0; }
to { left: 300px; }
}
.box {
position: relative;
animation: slide 1s infinite alternate;
}Свойство left запускает пересчёт с этапа Layout. Шестьдесят раз в секунду браузер заново считает координаты — и не только этого элемента.
Так надо
/* Раскладка не трогается вовсе */
@keyframes slide {
from { transform: translateX(0); }
to { transform: translateX(300px); }
}
.box {
animation: slide 1s infinite alternate;
}Элемент остаётся на месте с точки зрения раскладки — сдвигается только его готовая картинка. Этим занимается видеокарта.
#Layout thrashing
Отдельная беда, которая случается в скриптах. Браузер откладывает пересчёт раскладки до последнего — копит правки и делает всё разом.
Но если после записи сразу прочитать геометрию, браузер обязан посчитать её немедленно. Чередование записи и чтения в цикле заставляет его пересчитывать раскладку на каждой итерации.
Так не надо
<script>
// Читаем и пишем вперемешку — пересчёт на каждой итерации
items.forEach((el) => {
el.style.width = el.offsetWidth + 10 + 'px';
});
</script>offsetWidth — чтение, style.width — запись. Сто элементов дают сто принудительных пересчётов раскладки.
Так надо
<script>
// Сначала всё прочитали…
const widths = items.map((el) => el.offsetWidth);
// …потом всё записали
items.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px';
});
</script>Один пересчёт вместо ста. Приём называют «сначала читаем, потом пишем».
#will-change: обещание, а не ускоритель
will-change просит браузер заранее вынести элемент на отдельный слой. Это помогает — но у слоя есть цена: память видеокарты.
Свойство, поставленное «на всякий случай» всем подряд, ухудшает производительность, а не улучшает. На слабых устройствах десяток лишних слоёв заметен сразу.
/* Плохо: слой существует всегда, даже когда ничего не происходит */.card { will-change: transform; }/* Хорошо: обещание даётся прямо перед анимацией */.card { transition: transform .3s var(--ease); }.card-list:hover .card { will-change: transform; }/* Ещё лучше — вообще не трогать: браузер и так создаёт слой для transform-анимаций, когда это нужно */#content-visibility и contain
Два свойства, которые говорят браузеру: «сюда можно не заглядывать».
content-visibility: auto пропускает раскладку и отрисовку содержимого, пока блок за пределами экрана. На длинной странице с сотней карточек это самая дешёвая оптимизация из существующих.
contain обещает, что внутренние изменения не выйдут наружу, — браузер перестаёт пересчитывать соседей.
.post { /* Содержимое не считается, пока блок далеко от экрана */ content-visibility: auto; /* Обязательно: иначе полоса прокрутки будет прыгать, ведь высоту невычисленного блока браузер не знает */ contain-intrinsic-size: auto 420px;}#Как это увидеть
Гадать не нужно. Вкладка Performance в инструментах разработчика записывает всё, что браузер делал в каждом кадре.
Запиши десять секунд
Открой вкладку Performance, нажми запись и повтори действие, которое тормозит: прокрутку, наведение, открытие меню.
Найди красные полосы
Красным помечены кадры, не уложившиеся в бюджет. Это и есть подтормаживание, которое человек чувствует.
Посмотри, чем занят кадр
Фиолетовый — раскладка и стили, зелёный — отрисовка. Много фиолетового означает, что анимируется свойство из первой строки таблицы.
Ищи предупреждения
Браузер сам помечает принудительные пересчёты раскладки — это подсвеченный Forced reflow с указанием строки в скрипте.
Проверь на слабом устройстве
В настройках записи есть замедление процессора в четыре-шесть раз. На быстром ноутбуке проблема может быть незаметна вовсе.
#Как это выглядит в реальном проекте
@layer components { /* Шапка прячется при прокрутке вниз. Двигаем transform, а не top — иначе пересчёт на каждом кадре */ .header { position: sticky; inset-block-start: 0; transition: transform .25s var(--ease); } .header.is-hidden { transform: translateY(-100%); } /* Длинная лента: браузер не считает то, чего не видно */ .feed__item { content-visibility: auto; contain-intrinsic-size: auto 380px; } /* Карточка: тень появляется через opacity отдельного слоя, а не через анимацию box-shadow */ .card { position: relative; } .card::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: 0 12px 30px -18px rgb(20 24 48 / .5); opacity: 0; transition: opacity .25s var(--ease); } .card:hover::after { opacity: 1; }}#Когда НЕ надо
- Оптимизировать без замера. Половина «оптимизаций» из статей не даёт ничего в конкретном проекте, а часть вредит. Сначала запись, потом правка.
- Ставить
will-changeвезде. Каждый слой занимает память видеокарты. На телефоне это ощутимо. - Гнаться за кадрами в статике. Если на странице нет анимаций и прокрутки, разница между этапами не важна вовсе.
- Заменять читаемый код на быстрый заранее. Сложный приём ради миллисекунды, которую никто не заметит, — плохая сделка.
#Поддержка браузерами
| Возможность | Состояние |
|---|---|
Композитные слои для transform и opacity | Везде и давно |
will-change | Во всех современных браузерах |
contain | Во всех современных браузерах |
content-visibility | Поддерживается везде, Safari подтянулся позже остальных |
| Вкладка Performance | Есть во всех настольных браузерах, устройство немного различается |
Где спотыкаются почти все
Анимация через <code>top</code> и <code>left</code>
Почему Каждый кадр запускает пересчёт раскладки. На слабом устройстве анимация начинает дёргаться.
Как надо Двигай через transform: translate() — раскладка не трогается вовсе.
Чтение геометрии в цикле после записи
Почему Браузер вынужден считать раскладку немедленно на каждой итерации вместо одного раза в конце.
Как надо Сначала собери все замеры, потом применяй все правки.
<code>will-change</code> на всех карточках
Почему Десятки слоёв в памяти видеокарты. Получается медленнее, чем без него.
Как надо Ставь перед анимацией и снимай после. Чаще всего он вообще не нужен.
<code>content-visibility</code> без <code>contain-intrinsic-size</code>
Почему Высота невычисленных блоков считается нулевой — прокрутка прыгает, якоря промахиваются.
Как надо Указывай примерную высоту блока.
- Пересчёт раскладки reflow, layout
- Вычисление размеров и координат. Самый дорогой этап отрисовки.
- Перерисовка repaint
- Заполнение пикселей без пересчёта координат.
- Композиция composite
- Сборка готовых слоёв в кадр. Выполняется видеокартой.
- Пробуксовка раскладки layout thrashing
- Чередование записи и чтения геометрии, вынуждающее пересчитывать раскладку многократно.
- Композитный слой compositing layer
- Отдельная картинка в памяти видеокарты. Ускоряет анимацию, но занимает память.
Проверь себя
<code>left</code> начинает пересчёт с этапа Layout. <code>transform</code> двигает уже нарисованный слой.
Браузер откладывает пересчёт, но чтение геометрии заставляет сделать его немедленно. В цикле это даёт пересчёт на каждой итерации.
Обещание браузеру стоит памяти. Ставят точечно, перед анимацией, и снимают после.
Иначе высота невычисленных блоков считается нулевой, и полоса прокрутки прыгает.
Оптимизация без замера чаще всего тратит время впустую, а иногда делает хуже.
Задание
Починить три причины подтормаживания
В коде три проблемы: анимация двигает left, тень анимируется напрямую, а will-change висит на всех карточках постоянно.
Переведи движение на transform, тень — на прозрачность отдельного слоя через ::after, а will-change либо убери, либо оставь только на время наведения. Разметку не трогай.
Подсказка 1
Движение: transform: translateX(0); в покое и translateX(8px) при наведении, transition по transform.
Подсказка 2
Тень: .card::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: …; opacity: 0; transition: opacity .3s ease; } и .card:hover::after { opacity: 1; }
Подсказка 3
will-change убери из .card совсем или перенеси в .list:hover .card — тогда слои живут только во время взаимодействия.
Показать решение
<div class="list"> <article class="card">Первая</article> <article class="card">Вторая</article> <article class="card">Третья</article></div>.list { display: grid; gap: 12px; }.card { position: relative; padding: 16px; background: #f4f4f8; border-radius: 12px; /* Двигаем готовый слой — раскладка не трогается */ transform: translateX(0); transition: transform .3s ease;}/* Тень живёт отдельным слоем и появляется через прозрачность */.card::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: 0 12px 30px -18px rgb(20 24 48 / .5); opacity: 0; transition: opacity .3s ease; pointer-events: none;}/* Обещание браузеру — только на время взаимодействия */.list:hover .card { will-change: transform; }.card:hover { transform: translateX(8px); }.card:hover::after { opacity: 1; }