Ленивая загрузка изображений (Lazy Loading)

Зачем нужно

Ленивая загрузка (lazy loading) -- техника, при которой изображения загружаются только когда попадают в зону видимости пользователя. Это ускоряет начальную загрузку страницы, экономит трафик и улучшает Core Web Vitals (LCP). Три подхода: нативный атрибут loading="lazy", IntersectionObserver для старых браузеров и LQIP (low-quality image placeholder) для плавного появления.

Где используется

  • Каталоги товаров с десятками карточек
  • Ленты новостей и постов
  • Галереи изображений
  • Длинные статьи с иллюстрациями
  • Любая страница с большим количеством изображений

Подход 1: Нативный loading="lazy" (рекомендуемый)

Самый простой вариант -- один атрибут решает задачу. Поддерживается всеми современными браузерами.

<!-- Изображения ниже fold -- ленивая загрузка -->
<img src="product-1.jpg"
     alt="Описание товара"
     width="400"
     height="300"
     loading="lazy"
     decoding="async" />

<!-- Изображение выше fold -- НЕ делаем lazy! -->
<img src="hero-banner.jpg"
     alt="Главный баннер"
     width="1200"
     height="600"
     loading="eager"
     fetchpriority="high" />

Важные правила

<!-- ВСЕГДА указывай width и height для предотвращения layout shift -->
<img src="photo.jpg" alt="..." width="600" height="400" loading="lazy" />

<!-- Или через CSS aspect-ratio -->
<img src="photo.jpg" alt="..." loading="lazy" class="lazy-img" />
.lazy-img {
  width: 100%;
  height: auto;
  aspect-ratio: 3 / 2; /* Резервирует место до загрузки */
}

fetchpriority для критических изображений

<!-- Hero image: загружай первым -->
<img src="hero.jpg" alt="..." fetchpriority="high" />

<!-- Менее важные: загружай после -->
<img src="sidebar-ad.jpg" alt="..." fetchpriority="low" loading="lazy" />

Подход 2: IntersectionObserver (полный контроль)

Для случаев, когда нужен контроль загрузки, анимация появления или поддержка старых браузеров.

HTML

<img class="lazy"
     src="placeholder.svg"
     data-src="real-image.jpg"
     data-srcset="real-image-400.jpg 400w, real-image-800.jpg 800w"
     alt="Описание"
     width="400"
     height="300" />

Placeholder SVG (минимальный)

<!-- Inline SVG placeholder (серый прямоугольник) -->
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'
     viewBox='0 0 400 300'%3E%3Crect fill='%23f0f0f0'
     width='400' height='300'/%3E%3C/svg%3E"
     data-src="real-image.jpg"
     alt="Описание"
     class="lazy"
     width="400"
     height="300" />

CSS

.lazy {
  opacity: 0;
  transition: opacity 0.3s ease;
}

.lazy.loaded {
  opacity: 1;
}

JavaScript

class LazyLoader {
  constructor(selector = '.lazy') {
    this.images = document.querySelectorAll(selector);

    if ('IntersectionObserver' in window) {
      this.initObserver;
    } else {
      // Fallback: загрузить всё сразу
      this.loadAll;
    }
  }

  initObserver {
    const options = {
      root: null,            // viewport
      rootMargin: '200px',   // Начинать загрузку за 200px до появления
      threshold: 0,
    };

    this.observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          this.loadImage(entry.target);
          this.observer.unobserve(entry.target);
        }
      });
    }, options);

    this.images.forEach((img) => this.observer.observe(img));
  }

  loadImage(img) {
    const src = img.dataset.src;
    const srcset = img.dataset.srcset;

    if (!src) return;

    // Предзагрузка через Image объект
    const tempImg = new Image();

    tempImg.onload = () => {
      img.src = src;
      if (srcset) img.srcset = srcset;
      img.classList.add('loaded');
      img.removeAttribute('data-src');
      img.removeAttribute('data-srcset');
    };

    tempImg.onerror = () => {
      img.src = 'fallback.svg'; // Изображение-заглушка при ошибке
      img.classList.add('loaded');
      img.alt = 'Изображение не загрузилось';
    };

    tempImg.src = src;
  }

  loadAll {
    this.images.forEach((img) => this.loadImage(img));
  }

  // Для динамически добавленных изображений
  observe(img) {
    if (this.observer) {
      this.observer.observe(img);
    } else {
      this.loadImage(img);
    }
  }
}

// Инициализация
const lazyLoader = new LazyLoader();

Подход 3: LQIP (Low-Quality Image Placeholder)

Показываем размытую миниатюру (1-2 KB), пока загружается полноразмерное изображение.

HTML

<div class="lqip-wrapper">
  <img class="lqip-placeholder"
       src="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
       alt="" aria-hidden="true" />
  <img class="lqip-full lazy"
       data-src="high-quality-photo.jpg"
       alt="Описание фото"
       width="800"
       height="600" />
</div>

CSS (Blur-Up эффект)

.lqip-wrapper {
  position: relative;
  overflow: hidden;
  aspect-ratio: 4 / 3;
  background: #f0f0f0;
}

/* Маленькая размытая картинка-заглушка */
.lqip-placeholder {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: blur(20px);
  transform: scale(1.1); /* Скрыть края размытия */
  transition: opacity 0.5s;
}

/* Полноразмерное изображение */
.lqip-full {
  position: relative;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.5s;
}

.lqip-full.loaded {
  opacity: 1;
}

/* Скрыть placeholder, когда основное изображение загрузилось */
.lqip-full.loaded ~ .lqip-placeholder,
.lqip-wrapper:has(.lqip-full.loaded) .lqip-placeholder {
  opacity: 0;
}

Адаптивные изображения + Lazy Loading

<picture>
  <source media="(min-width: 1024px)"
          data-srcset="hero-desktop.webp" type="image/webp" />
  <source media="(min-width: 768px)"
          data-srcset="hero-tablet.webp" type="image/webp" />
  <img class="lazy"
       src="placeholder.svg"
       data-src="hero-mobile.jpg"
       alt="Описание"
       loading="lazy"
       width="800"
       height="400" />
</picture>
// Дополнение LazyLoader для <picture>
loadImage(img) {
  const picture = img.closest('picture');
  if (picture) {
    picture.querySelectorAll('source[data-srcset]').forEach((source) => {
      source.srcset = source.dataset.srcset;
      source.removeAttribute('data-srcset');
    });
  }

  img.src = img.dataset.src;
  img.classList.add('loaded');
}

Что НЕ делать lazy

Элемент Почему НЕ lazy
Hero image Влияет на LCP (Largest Contentful Paint)
Логотип в header Виден сразу при загрузке
Изображения above the fold Пользователь видит их первыми
Фоновые изображения первого экрана Критический контент

Частые ошибки

Ошибка Проблема Решение
Lazy на hero image Ухудшает LCP loading="eager" + fetchpriority="high"
Нет width/height Layout shift (CLS) Всегда указывай размеры или aspect-ratio
rootMargin: '0px' Изображение начинает грузиться слишком поздно rootMargin: '200px' для предзагрузки
Нет fallback при ошибке Сломанная иконка onerror с заглушкой
Lazy на все изображения Даже above-fold грузятся с задержкой Только для below-fold

Связанные темы

Ресурсы