Lazy-loading компонентов и изображений
Зачем lazy
Что даёт:
- Меньше initial.
- Быстрее LCP.
- Меньше трафика.
- Дешевле CDN.
Что стоит:
- Сложнее UX.
- Нужны fallback.
- SEO учитывать.
Правило: если не видно сразу — lazy.
Loading="lazy"
Изображения:
<img src="photo.jpg" alt="..." loading="lazy" width="800" height="600">
Что даёт: грузится при приближении к viewport.
Правило: все ниже первого экрана — loading="lazy".
Что НЕ lazy
- LCP-картинка.
- Первый экран.
- Логотип в шапке.
- Иконки меню.
Правило: LCP — fetchpriority="high".
LCP-картинка
<img
src="hero.webp"
alt="Hero"
width="1200"
height="600"
fetchpriority="high"
decoding="async"
>
Что здесь:
fetchpriority="high"— приоритет.decoding="async"— не блокирует.- Без
loading="lazy".
iframe
<iframe src="..." loading="lazy"></iframe>
Что даёт: iframe (YouTube, карты) грузится по требованию.
Правило: iframe — всегда lazy.
Video
<video preload="none" poster="preview.jpg">
<source src="video.mp4" type="video/mp4">
</video>
Что даёт: видео не грузится без клика.
Правило: preload="none" — для видео.
IntersectionObserver
Для кастомных:
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadContent(entry.target);
observer.unobserve(entry.target);
}
});
});
observer.observe(document.querySelector('.lazy'));
Что даёт: контроль над lazy.
Правило: IntersectionObserver — нативный API.
content-visibility
CSS:
.card {
content-visibility: auto;
contain-intrinsic-size: 300px;
}
Что даёт: браузер не рендерит невидимое.
Правило: для длинных списков — content-visibility.
React lazy
import { lazy, Suspense } from 'react';
const Chart = lazy(() => import('./Chart'));
<Suspense fallback={<Skeleton />}>
<Chart />
</Suspense>
Что даёт: chart грузится отдельно.
React Suspense
Fallback:
<Suspense fallback={<Spinner />}>
<AsyncComponent />
</Suspense>
Что важно: fallback — skelleton, не спиннер.
Правило: skeleton — быстрее воспринимается.
React lazy + IntersectionObserver
Для компонентов:
function LazyOnScroll({ children }) {
const [visible, setVisible] = useState(false);
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(([e]) => {
if (e.isIntersecting) setVisible(true);
});
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return <div ref={ref}>{visible ? children : <Skeleton />}</div>;
}
Что даёт: компонент грузится при скролле.
Next.js dynamic
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./Chart'), {
ssr: false,
loading: () => <Skeleton />,
});
Что даёт: только на клиенте.
Next.js Image
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority
/>
Что даёт:
- Оптимизация авто.
- WebP/AVIF авто.
- Lazy по умолчанию.
priority— для LCP.
Правило: в Next.js — всегда next/image.
Nuxt Image
<NuxtImg src="/hero.jpg" width="1200" height="600" />
Что даёт: то же самое для Nuxt.
Приоритеты
Правило:
- LCP —
priority/fetchpriority="high". - Первый экран — normal.
- Ниже —
loading="lazy". - Скрытое — вообще не грузить.
Skeleton
Что это: заглушка.
<div className="skeleton" style={{ height: 200 }} />
CSS:
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton 1.5s infinite;
}
Правило: skeleton вместо спиннера.
CLS и lazy
Проблема: lazy + без размеров = CLS.
Решение:
<img src="..." width="800" height="600" loading="lazy">
Правило: всегда width + height.
aspect-ratio
.image {
aspect-ratio: 16 / 9;
width: 100%;
object-fit: cover;
}
Что даёт: резерв места без размеров.
Preload критичного
<link rel="preload" as="image" href="hero.jpg" fetchpriority="high">
Что даёт: начало загрузки до парсинга HTML.
Правило: только для LCP-картинки.
Отложенный iframe
YouTube:
<iframe
src="https://www.youtube.com/embed/xxx"
loading="lazy"
srcdoc="<style>body{background:#000}</style>"
></iframe>
Или:
- Клик → вставить iframe.
- Плейсхолдер с превью.
Правило: > 500 KB iframe — по клику.
Плейсхолдеры для карт
{loaded ? (
<iframe src={mapsUrl} />
) : (
<div onClick={() => setLoaded(true)}>
<img src={staticMapPreview} alt="Карта" />
</div>
)}
Правило: карты — по клику.
Практика
1. Добавьте loading="lazy" ко всем img ниже экрана.
2. Уберите lazy с LCP-картинки.
3. Добавьте fetchpriority="high" на LCP.
4. Lazy для iframe.
5. content-visibility для длинных списков.
6. next/image для Next.js.
7. Skeleton для lazy-компонентов.
8. Замерьте LCP.
9. Проверьте CLS.
10. Сравните трафик.
Частые ошибки
1. Lazy на LCP:
LCP ухудшается
Решение: fetchpriority.
2. Без размеров:
CLS
Решение: width/height.
3. Lazy на первом экране:
Мигает
Решение: только ниже.
4. Всё loading="lazy":
Не помогает
Решение: только нужное.
5. Без skeleton:
Пустота
Решение: skeleton.
6. iframe без lazy:
+1 MB
Решение: lazy / по клику.
7. Без preload:
LCP медленнее
Решение: preload.
8. Next без Image:
Без оптимизации
Решение: next/image.
Итог
loading="lazy"— ниже экрана.- LCP —
fetchpriority="high". - iframe — lazy / по клику.
- Video —
preload="none". - IntersectionObserver — API.
content-visibility— для списков.- React lazy + Suspense.
next/image— обязательно.- Skeleton — не спиннер.
- width/height — против CLS.
Дальше — оптимизация изображений: WebP, AVIF, srcset.