Ленивая загрузка изображений (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 |
Связанные темы
- Скелетон загрузки -- placeholder при загрузке контента
- Бесконечный скролл -- подгрузка контента + изображений
- Слайдер -- lazy loading в каруселях
- Drag and Drop -- превью загруженных файлов