Олег Марков
Ленивая загрузка lazy loading в веб разработке
Введение
Ленивая загрузка (lazy loading) — это подход, при котором вы загружаете только то, что действительно нужно пользователю в данный момент, а остальное — по мере необходимости. Вместо того чтобы сразу тянуть все ресурсы (изображения, скрипты, данные, компоненты интерфейса), вы откладываете их загрузку до момента, когда они реально потребуются.
Смотрите, идея очень простая:
- пользователь видит лишь часть страницы;
- вы загружаете только то, что попало в зону видимости или скоро туда попадет;
- остальное подгружаете позже — при скролле, переходе по кнопкам, взаимодействиях.
В итоге вы:
- ускоряете первую загрузку страницы;
- снижаете потребление трафика;
- уменьшаете нагрузку на сервер;
- делаете интерфейс отзывчивее.
Давайте разберемся, как это работает технически и какие варианты реализации вы можете использовать.
Что такое ленивая загрузка с точки зрения архитектуры
Ключевая идея
Ленивая загрузка всегда строится вокруг одного простого вопроса: «Нужно ли это прямо сейчас». Если ответ «нет», ресурс откладывается.
Схематично это выглядит так:
Инициализация:
- вы рендерите минимально необходимый набор элементов;
- вместо тяжелых ресурсов (картинок, виджетов, компонент) ставите заглушки.
Отслеживание:
- вы отслеживаете события (скролл, клик, пересечение с видимой областью, роутинг).
Догрузка:
- при наступлении нужного события вы загружаете отложенный ресурс;
- подменяете заглушку реальным содержимым.
Где обычно применяют lazy loading
Чаще всего ленивая загрузка используется:
- для изображений и видео;
- для тяжелых виджетов (карты, графики, редакторы);
- для модулей JavaScript (code splitting);
- для компонент UI в SPA (React, Vue, Angular);
- для данных (ленточная подгрузка ленты постов, infinite scroll);
- для маршрутов (ленивая подгрузка страниц).
Сейчас я покажу вам это на практических примерах.
Ленивая загрузка изображений в браузере
Встроенный атрибут loading в HTML
Самый простой вариант, который уже поддерживается большинством браузеров, — атрибут loading у тега img.
Пример:
<!-- Браузер сам решает, когда грузить картинку -->
<img
src="https://example.com/image-1.jpg"
alt="Пример изображения"
loading="lazy"
/>
<!-- Можно явно задать eager если нужно грузить сразу -->
<img
src="https://example.com/hero.jpg"
alt="Главное изображение"
loading="eager"
/>
Комментарии к примеру:
// Атрибут loading="lazy" говорит браузеру
// "Загружай это изображение только тогда, когда оно скоро попадет в viewport"
// Атрибут loading="eager" (по умолчанию) — загружать сразу
Преимущества встроенного подхода:
- никакого JavaScript;
- простая поддержка;
- автоматически учитываются особенности браузера.
Ограничение: если вам нужен более точный контроль (анимация при появлении, сложная логика предзагрузки), вам может понадобиться JavaScript.
Ленивая загрузка изображений через IntersectionObserver
Если вам нужна гибкость, используйте IntersectionObserver. Он позволяет отслеживать, когда элемент попадает в зону видимости.
Давайте разберем пример.
HTML:
<!-- Вместо src используем data-src чтобы не грузить картинку сразу -->
<img
data-src="https://example.com/photo-1.jpg"
alt="Фото 1"
class="lazy-img"
/>
<img
data-src="https://example.com/photo-2.jpg"
alt="Фото 2"
class="lazy-img"
/>
JavaScript:
// Находим все картинки для ленивой загрузки
const lazyImages = document.querySelectorAll('.lazy-img');
// Создаем наблюдатель за пересечением с viewport
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
// Если элемент не пересекается с viewport, ничего не делаем
if (!entry.isIntersecting) return;
const img = entry.target;
// Переносим реальный src из data-src
img.src = img.dataset.src;
// Опционально - можно удалить класс заглушки
// img.classList.remove('lazy-img');
// Перестаем наблюдать за этим элементом
obs.unobserve(img);
});
}, {
root: null, // null означает - использовать viewport
rootMargin: '200px', // Начинать загрузку чуть заранее до появления в зоне видимости
threshold: 0.1 // Процент видимости элемента для срабатывания
});
// Подключаем наблюдатель ко всем картинкам
lazyImages.forEach(img => observer.observe(img));
Здесь важно несколько моментов:
// rootMargin='200px' - мы начинаем подгружать картинки за 200px до того
// как они фактически попадут в видимую часть экрана
// Это дает время на загрузку и снижает эффект "пустых" блоков
// threshold=0.1 - достаточно чтобы было видно хотя бы 10% элемента
// чтобы считать его "достигнутым"
Такой подход удобно использовать, если вы хотите, например:
- добавить эффект плавного появления;
- показывать скелетоны вместо реальных картинок;
- собирать метрики, сколько элементов реально просмотрено.
Ленивая загрузка модулей JavaScript (code splitting)
Зачем лениво грузить JS
Большие SPA-приложения часто страдают от "толстого" бандла JavaScript. Пользователь открывает страницу, а браузер:
- скачивает весь бандл;
- парсит его;
- выполняет даже те части, которые могут понадобиться только через несколько кликов.
Ленивая загрузка модулей (code splitting + dynamic import) решает эту проблему: вы делите код на куски и загружаете нужные фрагменты только при необходимости.
Простой пример с dynamic import
Давайте посмотрим на базовый JavaScript без фреймворков.
// Например, у вас есть тяжелый модуль charts.js
// и вы не хотите грузить его сразу
const button = document.getElementById('load-charts');
button.addEventListener('click', async () => {
// Динамический импорт - модуль грузится только при клике
const module = await import('./charts.js');
// charts.js экспортирует функцию initCharts
// Теперь можно проинициализировать графики
module.initCharts();
});
Комментарии:
// import('./charts.js') - браузер загружает отдельный JS-файл
// только в момент вызова этого import
// Возвращается промис - поэтому используем await
// Webpack Rollup Vite и другие бандлеры автоматически создадут chunk
// для этого модуля и подставят путь к нему
В результате код для графиков вообще не участвует в первой загрузке страницы. Он появится только тогда, когда пользователь явно запросит эту функциональность.
Ленивая загрузка в React: React.lazy и Suspense
В React ленивая загрузка компонентов реализуется через React.lazy.
Пример:
// App.jsx
import React, { Suspense } from 'react';
// Ленивая загрузка компонента Dashboard
const Dashboard = React.lazy(() => import('./Dashboard'));
function App() {
return (
<div>
<h1>Главная страница</h1>
{/* Suspense показывает fallback пока компонент догружается */}
<Suspense fallback={<div>Загрузка панели...</div>}>
<Dashboard />
</Suspense>
</div>
);
}
export default App;
Комментарии:
// React.lazy(() => import('./Dashboard')) - компонент Dashboard
// попадет в отдельный JS-chunk и загрузится только при его первом рендере
// Suspense с fallback - контент который вы показываете
// пока JavaScript-файл с компонентом еще загружается
Смотрите, как это можно связать с роутингом. Например, с React Router:
// routes.jsx
import React, { Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const HomePage = React.lazy(() => import('./pages/HomePage'));
const ProfilePage = React.lazy(() => import('./pages/ProfilePage'));
export function AppRoutes() {
return (
<Suspense fallback={<div>Загрузка страницы...</div>}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/profile" element={<ProfilePage />} />
</Routes>
</Suspense>
);
}
В таком варианте:
- главная и профиль грузятся как разные чанки;
- пользователь загружает только ту страницу, на которую переходит;
- первая загрузка упрощается и ускоряется.
Ленивая загрузка данных (infinite scroll, подгрузка по запросу)
Две основные стратегии
Когда вы работаете с данными (списками, таблицами, лентами), чаще всего встречаются две схемы:
- Подгрузка по кнопке: "Показать еще" / "Загрузить больше".
- Бесконечный скролл (infinite scroll) — данные догружаются при достижении конца списка.
Первая стратегия проще в реализации и предсказуемее для пользователя. Вторая кажется удобнее, но требует осторожности (производительность, доступность, SEO).
Пример: подгрузка по кнопке
Давайте разберем базовый пример с fetch и кнопкой.
HTML:
<ul id="posts-list"></ul>
<button id="load-more">Загрузить еще</button>
JavaScript:
const list = document.getElementById('posts-list');
const loadMoreBtn = document.getElementById('load-more');
let page = 1;
const limit = 10; // количество элементов на страницу
async function loadPosts() {
// Загружаем данные с сервера с учетом пагинации
const response = await fetch(`/api/posts?page=${page}&limit=${limit}`);
const data = await response.json();
// Добавляем элементы в список
data.posts.forEach(post => {
const li = document.createElement('li');
li.textContent = post.title; // выводим заголовок поста
list.appendChild(li);
});
// Если постов больше нет - скрываем кнопку
if (!data.hasMore) {
loadMoreBtn.style.display = 'none';
}
// Переходим к следующей странице
page += 1;
}
// Обработчик для кнопки
loadMoreBtn.addEventListener('click', () => {
loadPosts().catch(console.error);
});
// Подгружаем первую порцию при загрузке страницы
loadPosts().catch(console.error);
Комментарии:
// Сервер должен возвращать структуру вида
// { posts: [...], hasMore: true/false }
// Вы контролируете объем данных через limit
// и не грузите всю ленту сразу
Пример: infinite scroll с IntersectionObserver
Теперь давайте посмотрим, как реализовать бесконечную прокрутку без "грязных" обработчиков scroll.
HTML:
<ul id="feed"></ul>
<!-- Этот невидимый элемент будет триггером догрузки -->
<div id="sentinel"></div>
JavaScript:
const feed = document.getElementById('feed');
const sentinel = document.getElementById('sentinel');
let page = 1;
let loading = false;
let finished = false;
async function loadMore() {
// Если уже грузим или данных больше нет - выходим
if (loading || finished) return;
loading = true;
const response = await fetch(`/api/feed?page=${page}`);
const data = await response.json();
data.items.forEach(item => {
const li = document.createElement('li');
li.textContent = item.text;
feed.appendChild(li);
});
if (!data.hasMore) {
finished = true;
// Можно перестать наблюдать за sentinel
observer.unobserve(sentinel);
} else {
page += 1;
}
loading = false;
}
// Создаем наблюдатель за sentinel
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
// Если sentinel вошел в зону видимости - подгружаем данные
if (entry.isIntersecting) {
loadMore().catch(console.error);
}
});
});
observer.observe(sentinel);
Комментарии:
// sentinel - "маячок" внизу страницы
// когда пользователь докручивает до него - вызывается loadMore()
// loading и finished - защита от лишних запросов
// чтобы не отправлять несколько запросов одновременно
Такой подход стабильно работает и лучше, чем ручной анализ позиции скролла, потому что IntersectionObserver уже оптимизирован браузером.
Ленивая загрузка в SPA-фреймворках
Vue: динамические компоненты и defineAsyncComponent
В Vue 3 вы можете лениво подгружать компоненты через defineAsyncComponent.
Пример:
// lazyChart.js
import { defineAsyncComponent } from 'vue';
// Создаем асинхронный компонент
export const LazyChart = defineAsyncComponent({
// Функция загрузки компонента (динамический импорт)
loader: () => import('./Chart.vue'),
// Компонент заглушка - показывается во время загрузки
loadingComponent: {
template: '<div>Загрузка графика...</div>'
},
// Компонент ошибки - если загрузка не удалась
errorComponent: {
template: '<div>Не удалось загрузить график</div>'
},
delay: 200, // задержка перед показом loadingComponent (мс)
timeout: 10000 // через сколько мс считать что произошла ошибка
});
Использование:
<!-- App.vue -->
<template>
<div>
<h1>Отчет</h1>
<!-- График загрузится лениво -->
<LazyChart />
</div>
</template>
<script setup>
import { LazyChart } from './lazyChart.js';
</script>
Комментарии:
// loader возвращает import('./Chart.vue') - это динамическая загрузка
// Webpack/Vite создадут отдельный chunk для этого компонента
// loadingComponent и errorComponent помогают управлять UX
// и показывать пользователю понятные состояния
Angular: ленивая загрузка модулей и роутов
В Angular ленивая загрузка применяется в первую очередь к модулям и роутам.
Пример маршрута с ленивой загрузкой модуля:
// app-routing.module.ts
const routes: Routes = [
{
path: 'admin',
loadChildren: () =>
import('./admin/admin.module').then(m => m.AdminModule) // ленивый модуль
},
{
path: '',
loadChildren: () =>
import('./public/public.module').then(m => m.PublicModule)
}
];
Комментарии:
// loadChildren с динамическим import говорит Angular
// "загружай этот модуль только когда пользователь перейдет по этому пути"
// В результате код админки не попадает в главный бандл
// и не замедляет первых пользователей
Когда ленивая загрузка полезна, а когда нет
Типичные сценарии, где lazy loading сильно помогает
Длинные страницы с большим количеством изображений:
- каталоги товаров;
- фотогалереи;
- лендинги с большим количеством блоков.
"Тяжелые" функциональные блоки:
- редакторы (WYSIWYG, Markdown);
- сложные графики и дашборды;
- карты (Google Maps, Leaflet).
Большие SPA:
- панели администратора;
- приложения с большим количеством страниц;
- системы, где не все разделы используются часто.
Мобильные пользователи:
- трафик ограничен;
- скорость соединения нестабильна;
- важно максимально сократить ненужные загрузки.
Когда ленивая загрузка может навредить
Есть ситуации, когда чрезмерное использование lazy loading ухудшает UX:
Ключевой контент страницы:
- главный заголовок;
- первый экран (hero) с основным смыслом;
- критически важные элементы навигации.
SEO-страницы, где важен индексируемый контент:
- если поисковый робот не исполняет JS или исполняет частично, он может не увидеть содержимое, подгружаемое лениво;
- решается SSR (server-side rendering) или SPA-friendly фреймворками.
Маленькие ресурсы:
- если ресурс очень маленький и загружается мгновенно, лишняя логика lazy loading только усложнит код без реальной выгоды.
Правильный подход — баланс: лениво грузить всё, что не влияет на первый полезный рендер (First Contentful Paint) и не является критически важным для понимания страницы.
Типичные ошибки при реализации ленивой загрузки
Ошибка 1: слишком поздняя загрузка
Если вы начнете загружать ресурсы только в тот момент, когда пользователь уже их видит, он может заметить "пустое" место или задержку.
Решение:
- используйте небольшой запас через rootMargin (например, 200–400px);
- подгружайте ресурсы немного заранее.
Пример настройки IntersectionObserver:
const observer = new IntersectionObserver(callback, {
root: null,
rootMargin: '300px', // подгружаем за 300px до появления
threshold: 0.01
});
Комментарии:
// rootMargin '300px' - вы начинаете загрузку когда элемент
// еще не виден пользователю но скоро появится в зоне видимости
Ошибка 2: отсутствие запасного варианта (fallback)
Если вы полностью полагаетесь на JS, а он по какой-то причине не отработал (ошибка, блокировка, отключен JS), пользователь может так и не увидеть часть контента.
Решения:
- для критичных вещей используйте progressive enhancement:
- базовый HTML-контент — сразу в разметке;
- ленивую загрузку — только как улучшение.
- для изображений можно комбинировать src и data-src:
- src ставить на низкокачественный preview;
- data-src — на полноразмерную картинку.
Пример:
<img
src="small-preview.jpg" <!-- Загружается сразу -->
data-src="large-original.jpg" <!-- Полная версия для lazy loading -->
class="lazy-img"
alt="Фотография"
/>
Комментарии:
// Если JS сломается - пользователь хотя бы увидит preview
// Если JS сработает - вы подмените src на более качественную версию
Ошибка 3: отсутствие учета доступности (a11y)
Бесконечный скролл может быть неудобен пользователям с клавиатурами и скринридерами:
- сложно "добраться" до подвала сайта;
- нет ощущения, сколько элементов всего;
- могут возникнуть проблемы с фокусом.
Решения:
- вместо жесткого infinite scroll сделать опцию с кнопкой "Показать еще";
- добавлять скрытые для зрения, но доступные для скринридеров описания;
- снабжать новые элементы правильными ARIA-атрибутами.
Практический мини-гайд: как внедрить lazy loading в существующий проект
Здесь я покажу вам план действий, который удобно применять пошагово.
Шаг 1. Измерьте текущую производительность
Перед оптимизацией важно понимать, что у вас сейчас.
Используйте:
- Chrome DevTools (вкладка Performance / Lighthouse);
- WebPageTest, PageSpeed Insights;
- встроенные метрики (Time to First Byte, First Contentful Paint, Largest Contentful Paint).
Фиксируем:
- сколько весит ваш JS/CSS/изображения;
- как быстро загружается первый экран;
- какие ресурсы грузятся, но не используются сразу.
Шаг 2. Найдите "тяжелые" кандидаты для ленивой загрузки
Чаще всего это:
- большие изображения ниже первого экрана;
- сторонние виджеты (карты, чаты, аналитические скрипты);
- редко используемые страницы/модули;
- тяжелые таблицы и списки.
Смотрите, здесь важно не "ленивить всё подряд", а выбирать те части, где выгода очевидна.
Шаг 3. Начните с изображений
Это самый быстрый и безопасный шаг.
- Добавьте loading="lazy" для всех некритичных картинок.
- Для ключевых изображений первого экрана можно оставить eager.
- Для сложных кейсов подключите IntersectionObserver.
Хорошая практика:
- использовать адаптивные изображения (srcset, sizes);
- сжимать изображения на стороне сервера;
- отдавать WebP/AVIF, если браузер их поддерживает.
Шаг 4. Включите ленивую загрузку модулей
Если вы используете:
- React:
- примените React.lazy и Suspense для крупных страниц и модулей;
- настройте ленивую загрузку роутов.
- Vue:
- используйте defineAsyncComponent для тяжелых компонент;
- разделяйте роуты с помощью dynamic import.
- Angular:
- настраивайте lazy loading модулей через loadChildren.
Обратите внимание:
- не стоит дробить код на слишком мелкие чанки;
- важна логическая группировка по страницам/функциональным блокам.
Шаг 5. Оптимизируйте подгрузку данных
- реализуйте пагинацию или infinite scroll;
- ограничьте количество элементов на одну "страницу";
- добавьте индикаторы загрузки (spinner, skeleton, надпись "Загрузка...").
Важно:
- не забыть про лимитирование запросов (debounce, защита от множественных вызовов);
- обрабатывать состояние "данных больше нет".
Шаг 6. Повторно измерьте метрики
После внедрения:
- снова прогоните тесты в Lighthouse / WebPageTest;
- сравните вес бандлов;
- посмотрите, как изменились LCP, FCP, TTI.
Если результат хороший, можно углубляться дальше — оптимизировать критический CSS, использовать prefetch/prerender для маршрутов, которые, скорее всего, будут посещены.
Заключение
Ленивая загрузка — это не один конкретный инструмент, а общий подход к проектированию интерфейсов и архитектуры. Суть в том, чтобы не загружать и не выполнять ничего лишнего до тех пор, пока это не станет действительно нужно.
Вы можете применять lazy loading:
- на уровне ресурсов браузера (изображения, видео, iframe);
- на уровне кода (динамический импорт модулей, ленивые компоненты);
- на уровне данных (подгрузка порциями, infinite scroll);
- на уровне маршрутов (ленивая загрузка страниц).
Ключевой момент — баланс. Если вы подгружаете слишком поздно, пользователю будет казаться, что интерфейс "подвисает". Если не используете ленивую загрузку там, где она нужна, стартовая загрузка страдает от лишнего веса.
Практический подход:
- Измерить текущие метрики.
- Найти самые тяжелые и при этом не критичные для первого экрана элементы.
- Внедрить ленивую загрузку постепенно: сначала для изображений, затем для модулей и данных.
- Перепроверить метрики и доработать UX (fallback, скелетоны, анимации).
Так вы получите более быстрый и отзывчивый интерфейс без излишнего усложнения кода.
Частозадаваемые технические вопросы
Как ленивую загрузку совместить с SSR чтобы не потерять SEO
Если вы используете SSR (Next.js, Nuxt, Angular Universal), отдавайте критически важный контент сразу на сервере. Ленивая загрузка относится к тому, что не влияет на понимание страницы. Картинки и дополнительные блоки можно грузить лениво. Для поисковых систем важно чтобы основной текст и структура DOM были доступны в HTML еще до выполнения JS. Если вы лениво грузите текстовый контент убедитесь что SSR-слой уже отдает его в HTML а ленивая загрузка лишь заменяет заглушки на улучшенный вид (например форматирование или интерактив).
Как избежать многократного запроса одного и того же ресурса при повторной ленивой загрузке
Используйте кэширование на уровне приложения. Например храните флаг "загружено" или результат запроса в сторе (Redux, Vuex, Zustand) либо в памяти модуля. При попытке повторной загрузки проверяйте этот флаг и переиспользуйте уже полученные данные вместо нового сетевого запроса. На уровне HTTP включайте кеширующие заголовки Cache Control и ETag чтобы браузер не скачивал один и тот же ресурс несколько раз.
Что делать если IntersectionObserver не поддерживается в старых браузерах
Добавьте полифилл либо используйте деградацию до обработчика событий scroll и resize. Алгоритм простой при скролле замеряете позицию элемента относительно viewport с помощью getBoundingClientRect и если он близко к видимой области - запускаете загрузку. Важно ограничить частоту этих проверок через throttle чтобы не перегружать основной поток.
Как тестировать логику ленивой загрузки в автоматических тестах
Для модульных тестов абстрагируйте слой наблюдения за видимостью в отдельный сервис и замокайте его поведение. В e2e-тестах (Cypress Playwright) эмулируйте скролл страницы и проверяйте появление новых элементов и сетевых запросов. Можно замедлять сеть через devtools или конфигурацию тест раннера чтобы убедиться что fallback и состояние загрузки ведут себя корректно.
Как сочетать ленивую загрузку с prefetch чтобы сделать переходы еще быстрее
Prefetch используется для вероятных будущих переходов. Например когда пользователь навел курсор на ссылку вы можете вызвать динамический импорт с опцией prefetch (у многих бандлеров есть поддержка через специальные комментарии или плагины). Таким образом ресурс будет скачан в фоне но выполнится только при реальном переходе. Главное правило не префетчить слишком много иначе вы потеряете выигрыш ленивой загрузки за счет лишнего сетевого трафика.
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

Vue 3 и Pinia
Антон Ларичев
TypeScript с нуля
Антон Ларичев