Урок 02

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.