Kurs JavaScript i TypeScript · Moduł 9: Wzorce projektowe i architektura

Lazy loading - obrazów, tras, komponentów

15 min czytania
W tej lekcji8

Wyobraź sobie zwiedzającego, który przy bramie parku musi odebrać mapy wszystkich sektorów, zanim zrobi pierwszy krok. Tak działa aplikacja ładująca wszystko na starcie. Lazy loading to strategia optymalizacji polegająca na opóźnieniu ładowania zasobów do momentu, gdy stają się rzeczywiście potrzebne: gdy użytkownik przewija stronę, przechodzi na nową trasę albo wykonuje akcję wymagającą określonego komponentu.

Zalety lazy loading

Zyskujesz szybsze pierwsze ładowanie: mniejszy początkowy bundle, szybsze renderowanie pierwszej zawartości (FCP) i lepszą percepcję wydajności. Oszczędzasz pamięć RAM, transfer danych i baterię urządzeń mobilnych. Użytkownik dostaje płynne przewijanie, progresywne ładowanie treści i adaptację do możliwości urządzenia oraz połączenia.

Lazy loading obrazów

Natywny lazy loading w HTML

Najprostszy sposób nie wymaga JavaScriptu. Atrybut loading mówi przeglądarce, kiedy pobrać obraz:

1<!-- Natywny lazy loading dla obrazów poniżej pierwszego ekranu (wspierany przez nowoczesne przeglądarki) -->
2<img src="gallery-photo.jpg" alt="Zdjęcie w galerii" loading="lazy" />
3
4<!-- Eager loading dla obrazów above-the-fold -->
5<img src="logo.jpg" alt="Logo" loading="eager" />
6
7<!-- Bez atrybutu: domyślnie eager (wartości "auto" nie ma w standardzie HTML) -->
8<img src="banner.jpg" alt="Banner" />

Standard zna tylko wartości lazy i domyślne eager. Obrazów widocznych od razu nie oznaczaj jako lazy, bo opóźnisz największy element strony, a podając width i height, unikniesz przesunięć układu.

Intersection Observer API

Gdy potrzebujesz więcej kontroli, użyj Intersection Observer. To API zgłasza wejście elementu w obszar widoku (viewport) bez nasłuchiwania na każde przewinięcie:

1// Implementacja lazy loading z Intersection Observer
2class LazyImageLoader {
3  constructor() {
4    this.imageObserver = new IntersectionObserver(
5      this.handleIntersection.bind(this),
6      {
7        root: null, // viewport
8        rootMargin: '50px', // ładuj 50px przed wejściem w viewport
9        threshold: 0.1 // trigger gdy 10% obrazu jest widoczne
10      }
11    );
12
13    this.initLazyImages();
14  }
15
16  initLazyImages() {
17    const lazyImages = document.querySelectorAll('img[data-src]');
18    lazyImages.forEach(img => {
19      this.imageObserver.observe(img);
20    });
21  }
22
23  handleIntersection(entries) {
24    entries.forEach(entry => {
25      if (entry.isIntersecting) {
26        const img = entry.target;
27        this.loadImage(img);
28        this.imageObserver.unobserve(img);
29      }
30    });
31  }
32
33  loadImage(img) {
34    // Ładowanie obrazu z atrybutu data-src
35    img.src = img.dataset.src;
36    img.classList.add('loaded');
37
38    // Usuwanie data-src po załadowaniu
39    delete img.dataset.src;
40
41    // Obsługa błędów ładowania
42    img.onerror = () => {
43      img.src = '/images/error-placeholder.png';
44      img.classList.add('error');
45    };
46  }
47}
48
49// Inicjalizacja
50document.addEventListener('DOMContentLoaded', () => {
51  new LazyImageLoader();
52});

Adres czeka w data-src do wejścia w widok, a unobserve sprawia, że obraz ładuje się tylko raz.

React komponenty dla lazy loading obrazów

W React tę logikę zamykamy w hooku useIntersectionObserver i komponencie LazyImage:

1// Hook dla lazy loading obrazów
2import { useState, useRef, useEffect } from 'react';
3
4function useIntersectionObserver(options = {}) {
5  const [isIntersecting, setIsIntersecting] = useState(false);
6  const [hasIntersected, setHasIntersected] = useState(false);
7  const elementRef = useRef(null);
8
9  useEffect(() => {
10    const element = elementRef.current;
11    if (!element) return;
12
13    const observer = new IntersectionObserver(
14      ([entry]) => {
15        const isElementIntersecting = entry.isIntersecting;
16        setIsIntersecting(isElementIntersecting);
17
18        if (isElementIntersecting && !hasIntersected) {
19          setHasIntersected(true);
20        }
21      },
22      { threshold: 0.1, rootMargin: '50px', ...options }
23    );
24
25    observer.observe(element);
26
27    return () => observer.disconnect();
28  }, [hasIntersected, options]);
29
30  return { elementRef, isIntersecting, hasIntersected };
31}
32
33// Komponent LazyImage
34function LazyImage({ src, alt, placeholder, className = '' }) {
35  const [imageLoaded, setImageLoaded] = useState(false);
36  const [imageError, setImageError] = useState(false);
37  const { elementRef, hasIntersected } = useIntersectionObserver();
38
39  const handleImageLoad = () => {
40    setImageLoaded(true);
41  };
42
43  const handleImageError = () => {
44    setImageError(true);
45  };
46
47  return (
48    <div ref={elementRef} className={`lazy-image-container ${className}`}>
49      {/* Placeholder wyświetlany podczas ładowania */}
50      {!imageLoaded && (
51        <img
52          src={placeholder}
53          alt={`${alt} placeholder`}
54          className="placeholder-image"
55        />
56      )}
57
58      {/* Rzeczywisty obraz ładowany lazy */}
59      {hasIntersected && (
60        <img
61          src={imageError ? '/images/error.png' : src}
62          alt={alt}
63          onLoad={handleImageLoad}
64          onError={handleImageError}
65          className={`lazy-image ${imageLoaded ? 'loaded' : 'loading'}`}
66        />
67      )}
68
69      {/* Loading spinner */}
70      {hasIntersected && !imageLoaded && !imageError && (
71        <div className="image-spinner">Ładowanie...</div>
72      )}
73    </div>
74  );
75}
76
77// Użycie komponentu
78function Gallery({ images }) {
79  return (
80    <div className="gallery">
81      {images.map((image, index) => (
82        <LazyImage
83          key={index}
84          src={image.url}
85          alt={image.alt}
86          placeholder={image.placeholder}
87          className="gallery-item"
88        />
89      ))}
90    </div>
91  );
92}

Uwaga: domyślne options = {} to nowy obiekt przy każdym renderze, więc efekt co render odtwarza obserwator. W produkcji przekaż stały obiekt.

Progresywne ładowanie obrazów

Znany efekt: rozmyty podgląd płynnie zmienia się w ostry obraz:

1// Komponent z progresywnym ładowaniem (blur → sharp)
2function ProgressiveImage({ src, placeholder, alt }) {
3  const [imageLoaded, setImageLoaded] = useState(false);
4  const { elementRef, hasIntersected } = useIntersectionObserver();
5
6  return (
7    <div ref={elementRef} className="progressive-image">
8      {/* Rozmyty placeholder */}
9      <img
10        src={placeholder}
11        alt={alt}
12        className={`progressive-placeholder`}
13        style={{
14          filter: 'blur(10px)',
15          transition: 'opacity 0.3s ease'
16        }}
17      />
18
19      {/* Ostry obraz */}
20      {hasIntersected && (
21        <img
22          src={src}
23          alt={alt}
24          onLoad={() => setImageLoaded(true)}
25          className={`main-image ${imageLoaded ? 'visible' : 'hidden'}`}
26          style={{
27            opacity: imageLoaded ? 1 : 0,
28            transition: 'opacity 0.3s ease'
29          }}
30        />
31      )}
32    </div>
33  );
34}

Lekki placeholder widać od razu, a pełny obraz wyłania się dopiero po zdarzeniu onLoad.

Lazy loading tras (Route-based)

React Router z Suspense

Najwięcej zyskasz, dzieląc kod według tras. Definiujesz komponent strony, owijasz go funkcją lazy z dynamicznym import() i dodajesz granicę Suspense z komponentem zastępczym (fallback):

1import { Suspense, lazy } from 'react';
2import { Routes, Route } from 'react-router';
3import ErrorBoundary from './components/ErrorBoundary';
4import LoadingSpinner from './components/LoadingSpinner';
5
6// Lazy loading komponentów tras
7const HomePage = lazy(() => import('./pages/HomePage'));
8const ProductsPage = lazy(() => import('./pages/ProductsPage'));
9const ProductDetailPage = lazy(() => import('./pages/ProductDetailPage'));
10const UserProfilePage = lazy(() => import('./pages/UserProfilePage'));
11const AdminPanel = lazy(() => import('./pages/AdminPanel'));
12
13// Komponenty z custom loading i error handling
14const LazyRoute = ({ children }) => (
15  <ErrorBoundary>
16    <Suspense fallback={<LoadingSpinner />}>
17      {children}
18    </Suspense>
19  </ErrorBoundary>
20);
21
22function App() {
23  return (
24    <Routes>
25      <Route
26        path="/"
27        element={
28          <LazyRoute>
29            <HomePage />
30          </LazyRoute>
31        }
32      />
33      <Route
34        path="/products"
35        element={
36          <LazyRoute>
37            <ProductsPage />
38          </LazyRoute>
39        }
40      />
41      <Route
42        path="/products/:id"
43        element={
44          <LazyRoute>
45            <ProductDetailPage />
46          </LazyRoute>
47        }
48      />
49      <Route
50        path="/profile"
51        element={
52          <LazyRoute>
53            <UserProfilePage />
54          </LazyRoute>
55        }
56      />
57      <Route
58        path="/admin/*"
59        element={
60          <LazyRoute>
61            <AdminPanel />
62          </LazyRoute>
63        }
64      />
65    </Routes>
66  );
67}

Dynamiczne import() zwraca Promise, który rozwiązuje się do modułu, a bundler wydziela każdą stronę do osobnego pliku. Bez Reacta to po prostu const module = await import('./module.js'). lazy wymaga eksportu domyślnego, a ErrorBoundary łapie nieudane pobranie.

Zaawansowane strategie ładowania tras

Trasę można pobrać, zanim użytkownik kliknie: po najechaniu na link, na podstawie przewidywanej ścieżki albo w czasie bezczynności:

1// Preloading tras na podstawie user behavior
2class RoutePreloader {
3  constructor() {
4    this.preloadedRoutes = new Set();
5    this.setupPreloading();
6  }
7
8  setupPreloading() {
9    // Preload na hover
10    this.setupHoverPreloading();
11
12    // Preload na podstawie prawdopodobieństwa
13    this.setupPredictivePreloading();
14
15    // Preload w idle time
16    this.setupIdlePreloading();
17  }
18
19  setupHoverPreloading() {
20    document.addEventListener('mouseover', (e) => {
21      const link = e.target.closest('a[href]');
22      if (link && this.shouldPreload(link.href)) {
23        this.preloadRoute(new URL(link.href).pathname);
24      }
25    });
26  }
27
28  setupPredictivePreloading() {
29    // Jeśli użytkownik jest na /products, preload /products/:id
30    const currentPath = window.location.pathname;
31
32    if (currentPath === '/products') {
33      // Opóźnienie, żeby nie spowalniać ładowania bieżącej strony
34      setTimeout(() => {
35        this.preloadRoute('/products/:id');
36      }, 2000);
37    }
38  }
39
40  setupIdlePreloading() {
41    if ('requestIdleCallback' in window) {
42      requestIdleCallback(() => {
43        this.preloadCriticalRoutes();
44      });
45    }
46  }
47
48  preloadRoute(path) {
49    if (this.preloadedRoutes.has(path)) return;
50
51    const routeMap = {
52      '/products': () => import('./pages/ProductsPage'),
53      '/products/:id': () => import('./pages/ProductDetailPage'),
54      '/profile': () => import('./pages/UserProfilePage'),
55      '/admin': () => import('./pages/AdminPanel')
56    };
57
58    const preloadFunction = routeMap[path];
59    if (preloadFunction) {
60      preloadFunction().then(() => {
61        this.preloadedRoutes.add(path);
62        console.log(`Route ${path} preloaded`);
63      });
64    }
65  }
66
67  shouldPreload(href) {
68    // Preload tylko internal links
69    return href.startsWith(window.location.origin);
70  }
71
72  preloadCriticalRoutes() {
73    const critical = ['/products', '/profile'];
74    critical.forEach(route => this.preloadRoute(route));
75  }
76}
77
78// Inicjalizacja
79new RoutePreloader();

requestIdleCallback czeka na bezczynność przeglądarki (kod sprawdza, czy funkcja istnieje).

Lazy loading komponentów

Lazy loading na poziomie komponentów

Ciężkie komponenty, jak wykresy czy edytor kodu, ładujemy dopiero po otwarciu zakładki, która ich potrzebuje:

1// Lazy loading drogich komponentów
2const ExpensiveChart = lazy(() => import('./components/ExpensiveChart'));
3const DataVisualization = lazy(() => import('./components/DataVisualization'));
4const VideoPlayer = lazy(() => import('./components/VideoPlayer'));
5const CodeEditor = lazy(() => import('./components/CodeEditor'));
6
7function Dashboard({ activeTab, data }) {
8  return (
9    <div className="dashboard">
10      <nav className="dashboard-nav">
11        {/* Nawigacja zawsze widoczna */}
12      </nav>
13
14      <main className="dashboard-content">
15        {activeTab === 'charts' && (
16          <Suspense fallback={<div>Ładowanie wykresów...</div>}>
17            <ExpensiveChart data={data} />
18          </Suspense>
19        )}
20
21        {activeTab === 'visualization' && (
22          <Suspense fallback={<div>Ładowanie wizualizacji...</div>}>
23            <DataVisualization data={data} />
24          </Suspense>
25        )}
26
27        {activeTab === 'video' && (
28          <Suspense fallback={<div>Ładowanie odtwarzacza...</div>}>
29            <VideoPlayer />
30          </Suspense>
31        )}
32
33        {activeTab === 'editor' && (
34          <Suspense fallback={<div>Ładowanie edytora...</div>}>
35            <CodeEditor />
36          </Suspense>
37        )}
38      </main>
39    </div>
40  );
41}

Nawigacja pojawia się od razu, a każda zakładka ma własny fallback.

Lazy loading modali i paneli bocznych

Modale otwiera tylko część użytkowników, więc hook useLazyModal importuje je dopiero po kliknięciu:

1// Hook dla lazy modali
2function useLazyModal() {
3  const [isOpen, setIsOpen] = useState(false);
4  const [Component, setComponent] = useState(null);
5
6  const openModal = async (modalType) => {
7    const modalMap = {
8      'user-settings': () => import('./modals/UserSettingsModal'),
9      'payment': () => import('./modals/PaymentModal'),
10      'image-editor': () => import('./modals/ImageEditorModal'),
11    };
12
13    const importFunction = modalMap[modalType];
14    if (importFunction) {
15      const { default: ModalComponent } = await importFunction();
16      setComponent(() => ModalComponent);
17      setIsOpen(true);
18    }
19  };
20
21  const closeModal = () => {
22    setIsOpen(false);
23    // Opcjonalnie: unload komponentu po zamknięciu
24    setTimeout(() => setComponent(null), 300);
25  };
26
27  return { Component, isOpen, openModal, closeModal };
28}
29
30// Użycie lazy modali
31function App() {
32  const { Component: ModalComponent, isOpen, openModal, closeModal } = useLazyModal();
33
34  return (
35    <div>
36      <button onClick={() => openModal('user-settings')}>
37        Ustawienia użytkownika
38      </button>
39      <button onClick={() => openModal('payment')}>
40        Płatność
41      </button>
42      <button onClick={() => openModal('image-editor')}>
43        Edytor obrazów
44      </button>
45
46      {isOpen && ModalComponent && (
47        <Suspense fallback={<div>Ładowanie...</div>}>
48          <ModalComponent onClose={closeModal} />
49        </Suspense>
50      )}
51    </div>
52  );
53}

Zapis setComponent(() => ModalComponent) jest konieczny, bo przekazaną wprost funkcję useState potraktowałby jak aktualizator stanu.

Lazy loading całych funkcjonalności

Całe moduły funkcjonalne, jak panel admina czy koszyk, ładujemy ze szkieletem (skeleton) zamiast spinnera:

1// Lazy loading całych feature'ów
2const AdminFeature = lazy(() => import('./features/Admin'));
3const AnalyticsFeature = lazy(() => import('./features/Analytics'));
4const ShoppingCartFeature = lazy(() => import('./features/ShoppingCart'));
5
6function FeatureLoader({ feature, ...props }) {
7  const featureMap = {
8    admin: AdminFeature,
9    analytics: AnalyticsFeature,
10    cart: ShoppingCartFeature
11  };
12
13  const FeatureComponent = featureMap[feature];
14
15  if (!FeatureComponent) {
16    return <div>Nieznana funkcjonalność: {feature}</div>;
17  }
18
19  return (
20    <ErrorBoundary>
21      <Suspense fallback={<FeatureLoadingSkeleton feature={feature} />}>
22        <FeatureComponent {...props} />
23      </Suspense>
24    </ErrorBoundary>
25  );
26}
27
28// Skeleton dla różnych feature'ów
29function FeatureLoadingSkeleton({ feature }) {
30  const skeletons = {
31    admin: <AdminSkeleton />,
32    analytics: <ChartsSkeleton />,
33    cart: <CartSkeleton />
34  };
35
36  return skeletons[feature] || <DefaultSkeleton />;
37}

Skeleton pokazuje kształt przyszłej treści, więc strona mniej skacze.

Zaawansowane techniki lazy loading

Virtual scrolling z lazy loading

Virtual scrolling renderuje tylko widoczne wiersze długiej listy, tu z biblioteką react-window:

1// Virtual scrolling dla dużych list
2import { List } from 'react-window';
3
4function VirtualizedList({ items, itemHeight = 50 }) {
5  const [loadedItems, setLoadedItems] = useState(new Set());
6
7  const loadItem = useCallback(async (index) => {
8    if (loadedItems.has(index)) return;
9
10    // Symulacja ładowania danych
11    await new Promise(resolve => setTimeout(resolve, 100));
12    setLoadedItems(prev => new Set([...prev, index]));
13  }, [loadedItems]);
14
15  const Row = ({ index, style }) => {
16    const item = items[index];
17    const isLoaded = loadedItems.has(index);
18
19    useEffect(() => {
20      if (!isLoaded) {
21        loadItem(index);
22      }
23    }, [index, isLoaded, loadItem]);
24
25    return (
26      <div style={style} className="list-item">
27        {isLoaded ? (
28          <ItemContent item={item} />
29        ) : (
30          <ItemSkeleton />
31        )}
32      </div>
33    );
34  };
35
36  return (
37    <List
38      style={{ height: 400 }}
39      rowComponent={Row}
40      rowCount={items.length}
41      rowHeight={itemHeight}
42      rowProps={{}}
43      overscanCount={5} // 5 dodatkowych wierszy poza widokiem
44    />
45  );
46}

overscanCount dorenderowuje kilka wierszy poza widokiem, żeby szybkie przewijanie nie odsłaniało pustki.

Lazy loading z cache

ComponentCache trzyma komponenty w mapie, a preload pobiera je z wyprzedzeniem:

1// Cache dla lazy-loaded komponentów
2class ComponentCache {
3  constructor() {
4    this.cache = new Map();
5  }
6
7  async get(key, loader) {
8    if (this.cache.has(key)) {
9      return this.cache.get(key);
10    }
11
12    const component = await loader();
13    this.cache.set(key, component);
14    return component;
15  }
16
17  preload(key, loader) {
18    if (!this.cache.has(key)) {
19      this.get(key, loader);
20    }
21  }
22
23  clear(key) {
24    if (key) {
25      this.cache.delete(key);
26    } else {
27      this.cache.clear();
28    }
29  }
30}
31
32const componentCache = new ComponentCache();
33
34// Hook wykorzystujący cache
35function useLazyComponent(componentKey, loader) {
36  const [Component, setComponent] = useState(null);
37  const [loading, setLoading] = useState(false);
38  const [error, setError] = useState(null);
39
40  const loadComponent = useCallback(async () => {
41    if (Component) return;
42
43    setLoading(true);
44    setError(null);
45
46    try {
47      const loadedComponent = await componentCache.get(componentKey, loader);
48      setComponent(() => loadedComponent.default);
49    } catch (err) {
50      setError(err);
51    } finally {
52      setLoading(false);
53    }
54  }, [componentKey, loader, Component]);
55
56  // Preload funkcja
57  const preload = useCallback(() => {
58    componentCache.preload(componentKey, loader);
59  }, [componentKey, loader]);
60
61  return { Component, loading, error, loadComponent, preload };
62}

Przeglądarka i tak nie wykona modułu dwa razy, ale własna mapa daje kontrolę: czyszczenie, statystyki albo czas ważności (TTL).

Lazy loading dopasowany do połączenia

Na wolnym łączu ładujemy mniej i w gorszej jakości. Network Information API podaje efektywny typ połączenia (effectiveType):

1// Adaptacyjne ładowanie na podstawie połączenia
2function useConnectionAwareLazyLoading() {
3  const [connectionType, setConnectionType] = useState('4g');
4
5  useEffect(() => {
6    const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
7
8    if (connection) {
9      setConnectionType(connection.effectiveType);
10
11      const updateConnection = () => {
12        setConnectionType(connection.effectiveType);
13      };
14
15      connection.addEventListener('change', updateConnection);
16      return () => connection.removeEventListener('change', updateConnection);
17    }
18  }, []);
19
20  const shouldLazyLoad = useCallback((priority = 'normal') => {
21    const strategies = {
22      '4g': { immediate: true, normal: true, low: true },
23      '3g': { immediate: true, normal: true, low: false },
24      '2g': { immediate: true, normal: false, low: false },
25      'slow-2g': { immediate: false, normal: false, low: false }
26    };
27
28    return strategies[connectionType]?.[priority] ?? false;
29  }, [connectionType]);
30
31  const getLoadingStrategy = useCallback(() => {
32    const strategies = {
33      '4g': 'aggressive', // Ładuj wszystko
34      '3g': 'moderate',   // Ładuj tylko gdy potrzebne
35      '2g': 'conservative', // Ładuj minimalnie
36      'slow-2g': 'minimal' // Tylko niezbędne
37    };
38
39    return strategies[connectionType] || 'moderate';
40  }, [connectionType]);
41
42  return { connectionType, shouldLazyLoad, getLoadingStrategy };
43}
44
45// Komponent adaptujący się do połączenia
46function AdaptiveImageGrid({ images }) {
47  const { shouldLazyLoad, getLoadingStrategy } = useConnectionAwareLazyLoading();
48  const strategy = getLoadingStrategy();
49
50  const imageSettings = {
51    aggressive: { quality: 'high', eager: 10 },
52    moderate: { quality: 'medium', eager: 4 },
53    conservative: { quality: 'low', eager: 2 },
54    minimal: { quality: 'thumbnail', eager: 1 }
55  };
56
57  const settings = imageSettings[strategy];
58
59  return (
60    <div className="image-grid">
61      {images.map((image, index) => (
62        <AdaptiveImage
63          key={image.id}
64          src={image.url}
65          quality={settings.quality}
66          placeholder={image.placeholder}
67          loading={index < settings.eager ? 'eager' : 'lazy'}
68          shouldOptimize={strategy !== 'aggressive'}
69        />
70      ))}
71    </div>
72  );
73}

To API działa tylko w przeglądarkach opartych na Chromium, dlatego hook startuje od wartości '4g' i sprawdza, czy navigator.connection istnieje.

Monitorowanie wydajności lazy loadingu

Optymalizacji nie ocenisz na oko. LazyLoadingMonitor mierzy czas ładowania, błędy i trafienia w cache:

1// Monitoring wydajności lazy loading
2class LazyLoadingMonitor {
3  constructor() {
4    this.metrics = {
5      componentsLoaded: 0,
6      totalLoadTime: 0,
7      failedLoads: 0,
8      cacheHits: 0
9    };
10
11    this.startTime = performance.now();
12  }
13
14  trackComponentLoad(componentName, loadTime, fromCache = false) {
15    this.metrics.componentsLoaded++;
16    this.metrics.totalLoadTime += loadTime;
17
18    if (fromCache) {
19      this.metrics.cacheHits++;
20    }
21
22    // Web Vitals tracking
23    if (loadTime > 2500) { // Slow loading threshold
24      console.warn(`Slow lazy load detected: ${componentName} took ${loadTime}ms`);
25    }
26
27    // Send to analytics
28    this.sendMetrics('component_lazy_load', {
29      component: componentName,
30      loadTime,
31      fromCache,
32      timestamp: Date.now()
33    });
34  }
35
36  trackFailedLoad(componentName, error) {
37    this.metrics.failedLoads++;
38
39    console.error(`Failed to lazy load ${componentName}:`, error);
40
41    this.sendMetrics('component_lazy_load_error', {
42      component: componentName,
43      error: error.message,
44      timestamp: Date.now()
45    });
46  }
47
48  getAverageLoadTime() {
49    return this.metrics.componentsLoaded > 0
50      ? this.metrics.totalLoadTime / this.metrics.componentsLoaded
51      : 0;
52  }
53
54  getCacheHitRate() {
55    return this.metrics.componentsLoaded > 0
56      ? (this.metrics.cacheHits / this.metrics.componentsLoaded) * 100
57      : 0;
58  }
59
60  sendMetrics(event, data) {
61    // Integracja z Google Analytics, Mixpanel, itp.
62    if (typeof gtag !== 'undefined') {
63      gtag('event', event, data);
64    }
65  }
66
67  generateReport() {
68    const sessionTime = performance.now() - this.startTime;
69
70    return {
71      sessionDuration: sessionTime,
72      componentsLoaded: this.metrics.componentsLoaded,
73      averageLoadTime: this.getAverageLoadTime(),
74      cacheHitRate: this.getCacheHitRate(),
75      failureRate: (this.metrics.failedLoads / Math.max(1, this.metrics.componentsLoaded + this.metrics.failedLoads)) * 100,
76      totalFailures: this.metrics.failedLoads
77    };
78  }
79}
80
81// Singleton instance
82const lazyLoadMonitor = new LazyLoadingMonitor();
83
84// Hook z monitoringiem
85function useMonitoredLazyComponent(componentName, loader) {
86  const [Component, setComponent] = useState(null);
87  const [loading, setLoading] = useState(false);
88
89  const loadComponent = useCallback(async () => {
90    if (Component) return;
91
92    const startTime = performance.now();
93    setLoading(true);
94
95    try {
96      const loadedComponent = await loader();
97      const loadTime = performance.now() - startTime;
98
99      setComponent(() => loadedComponent.default);
100      lazyLoadMonitor.trackComponentLoad(componentName, loadTime);
101    } catch (error) {
102      lazyLoadMonitor.trackFailedLoad(componentName, error);
103      throw error;
104    } finally {
105      setLoading(false);
106    }
107  }, [componentName, loader, Component]);
108
109  return { Component, loading, loadComponent };
110}

Próg 2500 ms to granica dobrego wyniku LCP.

Dobre praktyki lazy loadingu

1. Priorytyzacja treści

Najważniejsza zasada: to, co widać od razu, ładuj natychmiast, a resztę leniwie:

1// Above-the-fold content - eager loading
2// Below-the-fold content - lazy loading
3function ContentStrategy() {
4  return (
5    <>
6      {/* Critical path - eager */}
7      <Header />
8      <Hero />
9      <MainContent />
10
11      {/* Secondary content - lazy */}
12      <Suspense fallback={<SectionSkeleton />}>
13        <LazySection name="testimonials" />
14      </Suspense>
15
16      <Suspense fallback={<SectionSkeleton />}>
17        <LazySection name="newsletter" />
18      </Suspense>
19
20      {/* Low priority - very lazy */}
21      <Suspense fallback={<FooterSkeleton />}>
22        <LazyFooter />
23      </Suspense>
24    </>
25  );
26}

Nagłówek i główna treść nie czekają, a opinie i stopka dochodzą w tle.

2. Łagodna obsługa błędów (graceful fallbacks)

Leniwe ładowanie może się nie udać, choćby po zerwaniu połączenia, więc łącz Suspense z granicą błędów:

1// Zawsze zapewnij graceful fallbacks
2function LazyComponentWrapper({ children, fallback, errorFallback }) {
3  return (
4    <ErrorBoundary
5      fallback={errorFallback || <div>Wystąpił błąd podczas ładowania</div>}
6    >
7      <Suspense fallback={fallback || <LoadingSkeleton />}>
8        {children}
9      </Suspense>
10    </ErrorBoundary>
11  );
12}

Zamiast pustego ekranu użytkownik widzi szkielet albo czytelny komunikat.

3. Preloading strategiczny

Na koniec przewidywanie: chwilę po wejściu na stronę pobieramy komponenty, które pewnie będą potrzebne:

1// Strategiczne preloadowanie na podstawie user behavior
2function useStrategicPreloading() {
3  useEffect(() => {
4    // Preload 2 sekundy po zamontowaniu komponentu
5    const timeoutId = setTimeout(() => {
6      // Preload prawdopodobnych następnych komponentów
7      import('./components/UserProfile');
8      import('./components/ShoppingCart');
9    }, 2000);
10
11    // Preload na podstawie mouse movement w kierunku linka
12    const handleMouseMove = (e) => {
13      // Jeśli kursor zbliża się do określonego elementu
14      // preload związany komponent
15    };
16
17    document.addEventListener('mousemove', handleMouseMove);
18
19    return () => {
20      clearTimeout(timeoutId);
21      document.removeEventListener('mousemove', handleMouseMove);
22    };
23  }, []);
24}

Funkcja zwracana z useEffect usuwa timer i nasłuchiwanie, gdy komponent znika.

Podsumowanie

Lazy loading w skrócie:

  1. Zmniejsza początkowy rozmiar bundla - szybsze uruchamianie aplikacji
  2. Poprawia Web Vitals - szybsze FCP i LCP, o ile nie opóźniasz treści widocznej od razu
  3. Oszczędza zasoby - pamięć, transfer, bateria
  4. Skaluje z aplikacją - nowe funkcje nie powiększają startowego bundla
  5. Adaptuje się do warunków - ładowanie dopasowane do połączenia

Lazy loading powinien być niewidoczny dla użytkownika, a jednocześnie wyraźnie przyspieszać aplikację. To jedna z technik obok cache'owania, debouncingu (odkładania kosztownej operacji, aż użytkownik przestanie pisać) i virtual scrollingu. Zacznij od podziału według tras bo daje największy zysk najmniejszym kosztem. W kolejnej lekcji zmierzysz i odchudzisz sam bundle.

Pamiętaj: zwiedzający nie potrzebuje przy bramie map całej wyspy, tylko kolejnej mapy przy wejściu do nowego sektora.

W podglądzie poniżej przewiń galerię dinozaurów: karty ładują się dopiero wtedy, gdy wjadą w widok, a licznik pokazuje, ile kilobajtów pobrano do tej pory.

Kod do tej lekcji: lazy-loading-demo.html
1<!DOCTYPE html>
2<html lang="pl">
3<head>
4    <meta charset="UTF-8">
5    <meta name="viewport" content="width=device-width, initial-scale=1.0">
6    <title>Lazy Loading - Park Jurajski</title>
7    <style>
8        body {
9            font-family: 'Courier New', monospace;
10            background: linear-gradient(135deg, #1e3c28 0%, #2d5a3d 100%);
11            color: #e0e0e0;
12            margin: 0;
13            padding: 20px;
14        }
15
16        .header {
17            text-align: center;
18            padding: 20px;
19            background: rgba(0, 0, 0, 0.3);
20            border-radius: 10px;
21            margin-bottom: 30px;
22        }
23
24        h1 {
25            color: #4CAF50;
26            text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
27        }
28
29        .gallery {
30            display: grid;
31            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
32            gap: 20px;
33            max-width: 1200px;
34            margin: 0 auto;
35        }
36
37        .dino-card {
38            background: rgba(255, 255, 255, 0.1);
39            border-radius: 10px;
40            padding: 15px;
41            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
42            transition: transform 0.3s ease;
43        }
44
45        .dino-card:hover {
46            transform: translateY(-5px);
47        }
48
49        .dino-image {
50            width: 100%;
51            height: 200px;
52            background: linear-gradient(45deg, #2d5a3d 0%, #3d6a4d 100%);
53            border-radius: 8px;
54            display: flex;
55            align-items: center;
56            justify-content: center;
57            font-size: 60px;
58            transition: opacity 0.5s ease;
59            position: relative;
60            overflow: hidden;
61        }
62
63        .dino-image.loading {
64            opacity: 0.5;
65        }
66
67        .dino-image.loaded {
68            opacity: 1;
69            background: linear-gradient(45deg, #4CAF50 0%, #45a049 100%);
70        }
71
72        .dino-image::after {
73            content: 'Ładowanie...';
74            position: absolute;
75            bottom: 10px;
76            font-size: 12px;
77            color: #fff;
78            opacity: 0;
79            transition: opacity 0.3s;
80        }
81
82        .dino-image.loading::after {
83            opacity: 1;
84        }
85
86        .dino-info {
87            margin-top: 10px;
88        }
89
90        .dino-name {
91            font-size: 18px;
92            font-weight: bold;
93            color: #4CAF50;
94            margin-bottom: 5px;
95        }
96
97        .dino-status {
98            font-size: 14px;
99            color: #aaa;
100        }
101
102        .stats {
103            background: rgba(0, 0, 0, 0.3);
104            padding: 20px;
105            border-radius: 10px;
106            margin: 30px auto;
107            max-width: 600px;
108            text-align: center;
109        }
110
111        .stat-item {
112            display: inline-block;
113            margin: 0 15px;
114        }
115
116        .stat-value {
117            font-size: 24px;
118            color: #4CAF50;
119            font-weight: bold;
120        }
121    </style>
122</head>
123<body>
124    <div class="header">
125        <h1>System Lazy Loading - Park Jurajski</h1>
126        <p>Obserwuj, jak dinozaury ładują się na żądanie!</p>
127    </div>
128
129    <div class="stats" id="stats">
130        <div class="stat-item">
131            <div class="stat-value" id="loaded-count">0</div>
132            <div>Załadowane</div>
133        </div>
134        <div class="stat-item">
135            <div class="stat-value" id="total-count">0</div>
136            <div>Wszystkie</div>
137        </div>
138        <div class="stat-item">
139            <div class="stat-value" id="bandwidth-saved">0 KB</div>
140            <div>Pobrano</div>
141        </div>
142    </div>
143
144    <div class="gallery" id="gallery"></div>
145
146    <script>
147        console.log('Inicjalizacja systemu Lazy Loading dla Parku Jurajskiego...');
148
149        // Dane dinozaurów
150        const dinosaurs = [
151            { name: 'Tyrannosaurus Rex', emoji: '', species: 'T-Rex', size: 150 },
152            { name: 'Velociraptor', emoji: '', species: 'Raptor', size: 80 },
153            { name: 'Triceratops', emoji: '', species: 'Roślinożerca', size: 120 },
154            { name: 'Brachiosaurus', emoji: '', species: 'Zauropod', size: 200 },
155            { name: 'Stegosaurus', emoji: '', species: 'Roślinożerca', size: 110 },
156            { name: 'Pteranodon', emoji: '', species: 'Latający', size: 90 },
157            { name: 'Ankylosaurus', emoji: '', species: 'Opancerzony', size: 130 },
158            { name: 'Spinosaurus', emoji: '', species: 'Mięsożerca', size: 170 },
159            { name: 'Parasaurolophus', emoji: '', species: 'Hadrozaur', size: 100 },
160            { name: 'Dilophosaurus', emoji: '', species: 'Mięsożerca', size: 95 }
161        ];
162
163        // Statystyki
164        let loadedCount = 0;
165        let totalBandwidthSaved = 0;
166
167        // Klasa do obsługi lazy loading
168        class DinosaurLazyLoader {
169            constructor() {
170                this.observer = new IntersectionObserver(
171                    this.handleIntersection.bind(this),
172                    {
173                        root: null,
174                        rootMargin: '100px',
175                        threshold: 0.1
176                    }
177                );
178
179                console.log('Intersection Observer utworzony');
180            }
181
182            handleIntersection(entries) {
183                entries.forEach(entry => {
184                    if (entry.isIntersecting) {
185                        const card = entry.target;
186                        this.loadDinosaur(card);
187                        this.observer.unobserve(card);
188                    }
189                });
190            }
191
192            loadDinosaur(card) {
193                const imageDiv = card.querySelector('.dino-image');
194                const emoji = card.dataset.emoji;
195                const size = parseInt(card.dataset.size);
196
197                console.log(`Ładowanie dinozaura: ${card.dataset.name}`);
198
199                // Symulacja opóźnienia ładowania
200                setTimeout(() => {
201                    imageDiv.textContent = emoji;
202                    imageDiv.classList.remove('loading');
203                    imageDiv.classList.add('loaded');
204
205                    loadedCount++;
206                    totalBandwidthSaved += size;
207
208                    this.updateStats();
209
210                    console.log(`Dinozaur załadowany: ${card.dataset.name}`);
211                }, Math.random() * 500 + 200);
212            }
213
214            updateStats() {
215                document.getElementById('loaded-count').textContent = loadedCount;
216                document.getElementById('bandwidth-saved').textContent = totalBandwidthSaved + ' KB';
217            }
218
219            observe(element) {
220                this.observer.observe(element);
221            }
222        }
223
224        // Inicjalizacja
225        const loader = new DinosaurLazyLoader();
226        const gallery = document.getElementById('gallery');
227
228        // Tworzenie kart dinozaurów
229        dinosaurs.forEach((dino, index) => {
230            const card = document.createElement('div');
231            card.className = 'dino-card';
232            card.dataset.name = dino.name;
233            card.dataset.emoji = dino.emoji;
234            card.dataset.size = dino.size;
235
236            card.innerHTML = `
237                <div class="dino-image loading"></div>
238                <div class="dino-info">
239                    <div class="dino-name">${dino.name}</div>
240                    <div class="dino-status">Gatunek: ${dino.species} | ID: #${(index + 1).toString().padStart(3, '0')}</div>
241                </div>
242            `;
243
244            gallery.appendChild(card);
245            loader.observe(card);
246        });
247
248        // Aktualizacja statystyk
249        document.getElementById('total-count').textContent = dinosaurs.length;
250
251        console.log(`Utworzono ${dinosaurs.length} kart dinozaurów`);
252        console.log('Przewiń stronę, aby obserwować lazy loading w akcji!');
253        console.log('Dinozaury będą ładować się automatycznie gdy wejdą w viewport');
254    </script>
255</body>
256</html>

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jaka jest główna korzyść z lazy loading komponentów?

  2. 2. Co zwraca wyrażenie dynamic import() w JavaScript?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w pionie

    Uporządkuj etapy konfiguracji lazy loading komponentu:

  • Układanie w poziomie

    Ułóż elementy dynamic import w odpowiedniej kolejności:

  • Edytor kodu

    W pliku index.js jest klasa DinosaurAI z potrzebami (hunger, fatigue, danger w skali 0-100) i drzewem decyzji. Uzupełnij luki: ___BLANK1___ to dolna granica potrzeby, poniżej której updateNeed jej nie obniży, ___BLANK2___ to metoda tablicy, która przechodzi po nazwach potrzeb i zostawia tę o najwyższej wartości, a ___BLANK3___ to operator porównania w decide(): akcję potrzeby wybieramy tylko wtedy, gdy jej wartość jest większa niż 30. Zagrożenie powyżej 70 zawsze daje 'flee', a gdy żadna potrzeba nie przekracza 30, decide() zwraca 'idle'.

  • Klikanie w kolejności

    Ułóż elementy subskrypcji we wzorcu Observer:

  • Edytor kodu

    W pliku index.js jest klasa DataManager z cache w Map (z czasem życia TTL) i zapisem w localStorage. Uzupełnij luki: ___BLANK1___ to metoda JSON, która zamienia wartość na tekst przed zapisem w localStorage, ___BLANK2___ to operator porównania, przez który get() zwraca wartość z cache tylko przed upływem czasu expires, a ___BLANK3___ to metoda JSON, która zamienia tekst z localStorage z powrotem na wartość. Po wygaśnięciu wpisu get() czyta wartość z localStorage, dla brakującego klucza zwraca null, a invalidate usuwa klucz z obu miejsc.

Przydatne artykuły