Lazy Loading

Lazy loading — загрузка ресурсов только когда они нужны, а не при первоначальной загрузке страницы. Уменьшает начальный размер бандла и ускоряет TTI.

Зачем нужно

Пользователь видит только верхнюю часть страницы при загрузке. Зачем загружать изображения, компоненты и скрипты для контента, до которого он может никогда не доскроллить? Lazy loading экономит трафик и ускоряет начальную загрузку.

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

Изображения ниже fold, модальные окна, тяжёлые компоненты (карты, графики), маршруты SPA, сторонние виджеты.

Предпосылки

Оптимизация изображений, Web Vitals, Browser rendering flow

Lazy Loading изображений

Нативный атрибут loading="lazy"

<!-- Загрузится только когда приблизится к viewport -->
<img src="photo.jpg" loading="lazy" alt="Фото" width="400" height="300">

<!-- Iframe тоже поддерживает -->
<iframe src="https://maps.google.com/..." loading="lazy"></iframe>

<!-- НЕ делай lazy для above-the-fold контента! -->
<img src="hero.jpg" loading="eager" fetchpriority="high" alt="Hero">

Intersection Observer API

// Более гибкий контроль
const lazyObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;

      // Заменяем data-src на src
      img.src = img.dataset.src;

      // Для srcset
      if (img.dataset.srcset) {
        img.srcset = img.dataset.srcset;
      }

      img.classList.add('loaded');
      observer.unobserve(img); // Прекращаем наблюдение
    }
  });
}, {
  rootMargin: '200px 0px', // Начать загрузку за 200px до появления
  threshold: 0.01,
});

// Применяем ко всем lazy-изображениям
document.querySelectorAll('img[data-src]').forEach(img => {
  lazyObserver.observe(img);
});
<img data-src="photo.jpg" data-srcset="photo-400.jpg 400w, photo-800.jpg 800w"
     alt="Фото" width="400" height="300"
     class="lazy">

Dynamic Import — ленивая загрузка модулей

Базовый dynamic import

// ОБЫЧНЫЙ import — загружается всегда
import { heavyFunction } from './heavyModule.js';

// DYNAMIC import — загружается по требованию
button.addEventListener('click', async () => {
  const { heavyFunction } = await import('./heavyModule.js');
  heavyFunction;
});

Условная загрузка

// Загружаем полифилл только если нужен
if (!('IntersectionObserver' in window)) {
  await import('intersection-observer');
}

// Загружаем библиотеку только при взаимодействии
async function initChart() {
  const { Chart } = await import('chart.js');
  new Chart(canvas, config);
}

// Загружаем тяжёлую библиотеку по событию
searchInput.addEventListener('focus', async () => {
  const { default: Fuse } = await import('fuse.js');
  const fuse = new Fuse(data, options);
  // ...
});

React.lazy + Suspense

import { lazy, Suspense } from 'react';

// Ленивая загрузка компонента
const HeavyChart = lazy( => import('./HeavyChart'));
const SettingsPanel = lazy( => import('./SettingsPanel'));

function App() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <button onClick={ => setShowChart(true)}>
        Показать график
      </button>

      {showChart && (
        <Suspense fallback={<div>Загрузка графика...</div>}>
          <HeavyChart data={data} />
        </Suspense>
      )}
    </div>
  );
}

Route-based Lazy Loading (React Router)

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Каждая страница — отдельный чанк
const Home = lazy( => import('./pages/Home'));
const Dashboard = lazy( => import('./pages/Dashboard'));
const Settings = lazy( => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Vue — lazy components

// Vue 3 — defineAsyncComponent
import { defineAsyncComponent } from 'vue';

const HeavyComponent = defineAsyncComponent( =>
  import('./HeavyComponent.vue')
);

// Vue Router — lazy routes
const routes = [
  { path: '/', component:  => import('./pages/Home.vue') },
  { path: '/about', component:  => import('./pages/About.vue') },
];

Lazy Loading компонентов по видимости

// React — загрузка при появлении в viewport
function LazyVisible({ children, fallback = null }) {
  const [isVisible, setIsVisible] = useState(false);
  const ref = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect();
        }
      },
      { rootMargin: '200px' }
    );

    if (ref.current) observer.observe(ref.current);
    return  => observer.disconnect();
  }, );

  return (
    <div ref={ref}>
      {isVisible ? children : fallback}
    </div>
  );
}

// Использование
<LazyVisible fallback={<Skeleton />}>
  <Suspense fallback={<Skeleton />}>
    <HeavyComponent />
  </Suspense>
</LazyVisible>

Prefetch и Preload

<!-- Preload — загрузить СЕЙЧАС (высокий приоритет) -->
<link rel="preload" href="critical.js" as="script">
<link rel="preload" href="hero.webp" as="image">

<!-- Prefetch — загрузить КОГДА СВОБОДЕН (низкий приоритет) -->
<link rel="prefetch" href="/about.js">  <!-- Вероятная следующая страница -->

<!-- DNS-prefetch — заранее разрешить DNS -->
<link rel="dns-prefetch" href="//api.example.com">

<!-- Preconnect — DNS + TCP + TLS заранее -->
<link rel="preconnect" href="https://cdn.example.com">
// Динамический prefetch при hover
link.addEventListener('mouseenter', () => {
  const prefetch = document.createElement('link');
  prefetch.rel = 'prefetch';
  prefetch.href = link.href;
  document.head.appendChild(prefetch);
});

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

1. Lazy loading для LCP-элемента

<!-- ПЛОХО: hero image загружается лениво — портит LCP -->
<img src="hero.jpg" loading="lazy">

<!-- ХОРОШО: eager + preload -->
<link rel="preload" as="image" href="hero.jpg">
<img src="hero.jpg" loading="eager" fetchpriority="high">

2. Нет fallback при загрузке

// ПЛОХО: белый экран при загрузке чанка
<Suspense>
  <HeavyComponent />
</Suspense>

// ХОРОШО: skeleton или спиннер
<Suspense fallback={<Skeleton lines={5} />}>
  <HeavyComponent />
</Suspense>

3. Слишком мелкие чанки

// ПЛОХО: отдельный чанк для каждой утилитарной функции
const add = lazy( => import('./utils/add'));  // 0.1KB чанк

// ХОРОШО: группируй по смыслу
const MathUtils = lazy( => import('./utils/math'));  // 5KB чанк

4. Нет width/height для lazy images

<!-- ПЛОХО: CLS при загрузке изображения -->
<img data-src="photo.jpg" class="lazy">

<!-- ХОРОШО: место зарезервировано -->
<img data-src="photo.jpg" class="lazy" width="400" height="300">

Практика

  1. Добавь loading="lazy" ко всем изображениям ниже fold
  2. Реализуй Intersection Observer для ленивой загрузки компонента
  3. Настрой React.lazy + Suspense для маршрутов приложения
  4. Используй dynamic import для загрузки библиотеки Chart.js по клику
  5. Добавь prefetch для вероятных следующих страниц

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

Ресурсы