Оптимизация CLS (Cumulative Layout Shift)
CLS — суммарная оценка смещений элементов страницы во время загрузки. Когда текст или кнопки прыгают в момент, когда пользователь на них кликает — это CLS. Цель: ≤ 0.1.
Формула расчёта
CLS = Σ (impact fraction × distance fraction) для каждого неожиданного смещения.
Impact fraction — доля viewport, которую занимали смещённые элементы. Distance fraction — расстояние смещения относительно viewport.
Диагностика
DevTools → Performance → запись → найти отметки «Layout Shift» (фиолетовые). Или:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log('Layout shift:', entry.value, entry.sources);
}
}
}).observe({ type: 'layout-shift', buffered: true });
entry.sources показывает конкретные DOM-элементы, которые сместились.
Изображения без размеров — самая частая причина
<!-- Плохо: браузер не знает размер до загрузки -->
<img src="product.webp" alt="...">
<!-- Хорошо: атрибуты width и height -->
<img src="product.webp" width="800" height="600" alt="...">
CSS для адаптивности с сохранением соотношения:
img {
max-width: 100%;
height: auto;
}
/* Или через aspect-ratio */
.product-image {
aspect-ratio: 4 / 3;
width: 100%;
object-fit: cover;
}
Шрифты и FOUT
Font Swap вызывает смещение когда основной шрифт загружается и меняет системный (fallback).
/* Способ 1: font-display: optional — не показывать swap вообще */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: optional;
}
/* Способ 2: size-adjust — привести fallback к размеру основного шрифта */
@font-face {
font-family: 'InterFallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'InterFallback', sans-serif;
}
Инструмент для подбора size-adjust: fontaine пакет или next/font (автоматически подбирает size-adjust для Google Fonts).
Динамический контент
/* Баннер/рекламный блок: резервировать место до загрузки */
.ad-banner {
min-height: 90px; /* стандартный leaderboard */
background: #f5f5f5; /* placeholder */
}
/* Для адаптивных блоков */
.hero-section {
aspect-ratio: 16 / 9;
max-height: 600px;
}
Скелетоны вместо пустого места
Не оставлять место пустым — показывать skeleton placeholder с правильными размерами:
function ProductCardSkeleton() {
return (
<div className="product-card-skeleton">
<div className="skeleton-image" style={{ aspectRatio: '1 / 1' }} />
<div className="skeleton-title" style={{ height: '1.5rem', width: '80%' }} />
<div className="skeleton-price" style={{ height: '1.25rem', width: '40%' }} />
</div>
);
}
.skeleton-image, .skeleton-title, .skeleton-price {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
Анимации
Анимации, изменяющие layout-свойства (width, height, top, left, margin, padding) вызывают CLS. Использовать только transform и opacity:
/* Плохо — вызывает layout reflow */
.modal { animation: slideDown 300ms; }
@keyframes slideDown { from { height: 0; } to { height: 400px; } }
/* Хорошо — только transform */
.modal { animation: slideDown 300ms; }
@keyframes slideDown {
from { transform: translateY(-100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
contain: layout
Для изолированных компонентов — contain: layout или contain: strict предотвращает влияние внутренних изменений на внешний layout:
.widget-container {
contain: layout;
}
Хедер с fixed-позиционированием
Sticky/fixed header при появлении не должен смещать контент. Паддинг на body:
body {
padding-top: var(--header-height, 64px);
}
.header {
position: fixed;
top: 0;
height: var(--header-height, 64px);
}
Срок оптимизации: 1–3 дня, основная работа — изображения и шрифты.







