Kurs JavaScript i React · Moduł 9: Nowoczesne hooki React

Wirtualizacja list i windowing

15 min czytania
W tej lekcji7

Wyobraź sobie, że patrzysz przez okno obserwacyjne statku kosmicznego na miliardy gwiazd w galaktyce. Nie widzisz wszystkich naraz - widzisz tylko te, które są w twoim polu widzenia. Właśnie na tym polega wirtualizacja list w React: zamiast renderować tysiące elementów DOM, renderujemy tylko te, które są aktualnie widoczne na ekranie.

Problem: renderowanie tysięcy elementów

Gdy mamy listę z tysiącami elementów (np. lista planet, logi z serwera, wiadomości czatu), renderowanie ich wszystkich jednocześnie może spowodować poważne problemy z wydajnością:

1// PROBLEM: Renderowanie 10 000 elementów na raz
2function SlowStarCatalog({ stars }) {
3  // To stworzy 10 000 elementów DOM na raz!
4  return (
5    <div style={{ height: '600px', overflow: 'auto' }}>
6      {stars.map(star => (
7        <div key={star.id} style={{
8          padding: '12px',
9          borderBottom: '1px solid #333',
10          backgroundColor: '#0a0a1a',
11          color: '#ccc'
12        }}>
13          <strong style={{ color: '#ffd700' }}>{star.name}</strong>
14          <span style={{ marginLeft: '10px' }}>Typ: {star.type}</span>
15          <span style={{ marginLeft: '10px' }}>Odległość: {star.distance} ly</span>
16        </div>
17      ))}
18    </div>
19  );
20}
21
22// Każdy z 10 000 elementów:
23// - tworzy węzły DOM
24// - zajmuje pamięć przeglądarki
25// - spowalnia początkowe renderowanie
26// - spowalnia scrollowanie

Efekty takiego podejścia to:

  • Długi czas początkowego renderowania - przeglądarka musi stworzyć tysiące elementów DOM
  • Wysokie zużycie pamięci - każdy element DOM zajmuje pamięć
  • Lagujące scrollowanie - przeglądarka musi obsługiwać ogromne drzewo DOM
  • Zamrażanie interfejsu - użytkownik czeka na odpowiedź aplikacji

Koncept windowing

Windowing (okienkowanie) to technika, która renderuje tylko elementy widoczne w "oknie" (viewport) kontenera scroll. Reszta elementów po prostu nie istnieje w DOM - są dodawane dopiero, gdy użytkownik przewija listę.

1// KONCEPT: Jak działa windowing
2
3// Wyobraź sobie listę 10 000 elementów, każdy o wysokości 50px
4// Kontener ma wysokość 500px - widzimy tylko 10 elementów naraz
5
6// BEZ windowing:
7// DOM zawiera: 10 000 elementów = 500 000px wysokości
8// Pamięć: ogromna
9
10// Z windowing:
11// DOM zawiera: ~15 elementów (10 widocznych + bufor)
12// "Pusta" przestrzeń symulowana przez padding/transform
13// Pamięć: minimalna
14
15// Schemat działania:
16// [padding-top: symuluje elementy powyżej]
17// [element 45] <- widoczny
18// [element 46] <- widoczny
19// [element 47] <- widoczny
20// [element 48] <- widoczny
21// ...
22// [element 54] <- widoczny
23// [padding-bottom: symuluje elementy poniżej]

Schemat pokazuje zasadę, ale za wirtualizacją nie stoi magia, tylko trochę arytmetyki. Zanim sięgniesz po bibliotekę, zbuduj takie okno sam.

Okno zbudowane ręcznie

Potrzebujesz stanu z pozycją przewinięcia i trzech obliczeń. Pierwszy widoczny wiersz to liczba przewiniętych pikseli podzielona przez wysokość wiersza, a do widocznych wierszy dokładasz kilka zapasowych (overscan) nad i pod oknem, żeby przy szybkim przewijaniu nie mignęła pustka:

1function getVisibleRange(scrollTop, { rowHeight, viewHeight, count, overscan = 3 }) {
2  const first = Math.floor(scrollTop / rowHeight);
3  const visible = Math.ceil(viewHeight / rowHeight);
4  const start = Math.max(0, first - overscan);
5  const end = Math.min(count, first + visible + overscan);
6  return { start, end };
7}

Funkcja nie wie nic o Reakcie: dla okna 400 px, wierszy po 40 px i przewinięcia o 4000 px zwraca zakres od 97 do 113. Komponent trzyma scrollTop w stanie i renderuje tylko ten wycinek tablicy:

1function StarWindow({ stars }) {
2  const [scrollTop, setScrollTop] = useState(0);
3  const { start, end } = getVisibleRange(scrollTop, { rowHeight: 40, viewHeight: 400, count: stars.length });
4
5  return (
6    <div style={{ height: 400, overflowY: 'auto' }} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
7      <div style={{ height: stars.length * 40, position: 'relative' }}>
8        {stars.slice(start, end).map((star, i) => (
9          <div key={star.id} style={{ position: 'absolute', top: (start + i) * 40, height: 40 }}>
10            {star.name}
11          </div>
12        ))}
13      </div>
14    </div>
15  );
16}

Wewnętrzny div ma wysokość całej listy, więc suwak zachowuje się tak, jakby wszystkie gwiazdy były w DOM. Każdy wiersz dostaje top równy swojemu indeksowi w pełnej liście razy wysokość wiersza, a slice(start, end) nie zmienia tablicy stars, tylko zwraca jej fragment. Tak samo działa VirtualizedList w edytorze obok, a react-window robi to samo, dokładając zmienne wysokości, siatki i atrybuty dostępności.

react-window - biblioteka do wirtualizacji

react-window to lekka biblioteka autorstwa Briana Vaughna (autora oryginalnego react-virtualized), która implementuje windowing w sposób prosty i wydajny.

Ważne: w 2025 roku wyszła wersja 2 i jej API jest zupełnie inne niż w wersji 1. Komponenty FixedSizeList i VariableSizeList zniknęły, a ich miejsce zajął jeden komponent List (do siatek służy Grid). Jeśli instalujesz bibliotekę dziś, dostaniesz wersję 2.x, więc poniżej uczymy nowego API, a stare pokazujemy na końcu sekcji dla projektów, które wciąż są na 1.x.

List - elementy o stałej wysokości

Komponent List przyjmuje kilka kluczowych propsów. rowComponent to komponent rysujący jeden wiersz, rowCount to liczba wszystkich elementów, rowHeight to wysokość wiersza w pikselach, a rowProps to obiekt z dodatkowymi danymi, które biblioteka przekaże do każdego wiersza. Sam wiersz dostaje index, style oraz ariaAttributes (atrybuty dostępności, takie jak role="listitem"):

1import { List } from 'react-window';
2
3// Generujemy 10 000 gwiazd
4const stars = Array.from({ length: 10000 }, (_, index) => ({
5  id: index,
6  name: `Gwiazda-${index + 1}`,
7  type: ['Karzeł biały', 'Olbrzym czerwony', 'Gwiazda neutronowa', 'Supernowa'][index % 4],
8  magnitude: (Math.random() * 20 - 5).toFixed(2)
9}));
10
11// Komponent pojedynczego wiersza
12// Dostaje index, style, ariaAttributes + wszystko z rowProps
13function StarRow({ index, style, ariaAttributes, stars }) {
14  const star = stars[index];
15  return (
16    <div {...ariaAttributes} style={{
17      ...style,
18      display: 'flex',
19      alignItems: 'center',
20      padding: '0 16px',
21      borderBottom: '1px solid #1a1a3e',
22      backgroundColor: index % 2 === 0 ? '#0a0a1a' : '#0f0f2a',
23      color: '#ccc'
24    }}>
25      <span style={{ flex: 1, color: '#ffd700' }}>{star.name}</span>
26      <span style={{ flex: 1 }}>{star.type}</span>
27      <span style={{ width: '100px', textAlign: 'right' }}>
28        mag: {star.magnitude}
29      </span>
30    </div>
31  );
32}
33
34function VirtualizedStarCatalog() {
35  return (
36    <div style={{ padding: '20px', backgroundColor: '#0f0f23' }}>
37      <h2 style={{ color: '#00d2ff' }}>Katalog Gwiazd (10 000 obiektów)</h2>
38      <List
39        rowComponent={StarRow}     // Komponent wiersza
40        rowCount={stars.length}    // Liczba elementów
41        rowHeight={50}             // Wysokość każdego elementu (stała!)
42        rowProps={{ stars }}       // Dane przekazywane do każdego wiersza
43        style={{ height: 400 }}    // Wysokość kontenera
44      />
45    </div>
46  );
47}

Dwie rzeczy są tu obowiązkowe. Po pierwsze, style z propsów musisz przekazać do elementu wiersza, bo zawiera pozycję absolutną i przesunięcie transform, dzięki którym wiersz trafia we właściwe miejsce. Po drugie, List wypełnia wysokość określoną w style albo wysokość rodzica, więc kontener musi ją mieć. Szerokość domyślnie wynosi 100%. W teście z 10 000 gwiazd i kontenerem 400px biblioteka wyrenderowała zaledwie 11 wierszy: 8 widocznych plus kilka zapasowych (overscanCount).

Stała liczba w rowHeight to najwydajniejsza opcja, ponieważ biblioteka nie musi mierzyć wysokości elementów - zna ją z góry. Używaj jej zawsze, gdy elementy listy mają stałą, znaną wysokość.

rowHeight jako funkcja - elementy o różnej wysokości

Gdy elementy listy mają różne wysokości (np. wiadomości czatu, posty), w wersji 1 sięgało się po VariableSizeList. W wersji 2 zostajemy przy List, a do rowHeight przekazujemy funkcję. Biblioteka wywoła ją z indeksem wiersza i obiektem rowProps:

1import { List } from 'react-window';
2
3const messages = Array.from({ length: 5000 }, (_, i) => ({
4  id: i,
5  sender: ['Kapitan Nova', 'Inżynier Rex', 'Nawigator Luna'][i % 3],
6  text: i % 5 === 0
7    ? 'Dłuższa wiadomość z raportem misji - status systemów napędowych, komunikacji i podtrzymywania życia. Wszystkie parametry w normie.'
8    : 'Krótka wiadomość #' + (i + 1)
9}));
10
11// Funkcja zwracająca wysokość elementu na podstawie indeksu
12const getRowHeight = (index, { messages }) => {
13  const message = messages[index];
14  // Dłuższa wiadomość = wyższa komórka
15  return message.text.length > 50 ? 80 : 50;
16};
17
18function MessageRow({ index, style, ariaAttributes, messages }) {
19  const msg = messages[index];
20  return (
21    <div {...ariaAttributes} style={{
22      ...style,
23      padding: '8px 16px',
24      borderBottom: '1px solid #1a1a3e',
25      backgroundColor: '#0a0a1a',
26    }}>
27      <strong style={{ color: '#e94560' }}>{msg.sender}:</strong>
28      <p style={{ color: '#ccc', margin: '4px 0 0 0', fontSize: '14px' }}>
29        {msg.text}
30      </p>
31    </div>
32  );
33}
34
35function VirtualizedChat() {
36  return (
37    <List
38      rowComponent={MessageRow}
39      rowCount={messages.length}
40      rowHeight={getRowHeight}
41      rowProps={{ messages }}
42      style={{ height: 400 }}
43    />
44  );
45}

Wiersz wiadomości wygląda tak samo jak wcześniej, zmieniło się tylko źródło wysokości. Jeśli wysokości nie da się wyliczyć z góry, bo zależą od zawijania tekstu, wersja 2 ma hook useDynamicRowHeight, który mierzy wiersze po wyrenderowaniu. Dokumentacja ostrzega jednak, że to mniej wydajne niż znane z góry wartości, więc zawsze, gdy się da, podawaj liczbę lub funkcję.

Starsze API (react-window 1.x)

Jeśli pracujesz w projekcie, który ma w package.json wersję 1.x, zobaczysz tam zapis z osobnymi komponentami. Zamiast rowComponent wiersz trafia jako children, liczbę elementów podaje itemCount, wysokość itemSize, a wymiary kontenera propsy height i width. Oba przykłady z tej lekcji wyglądały w wersji 1 tak:

1// react-window 1.x - API usunięte w wersji 2
2import { FixedSizeList, VariableSizeList } from 'react-window';
3
4<FixedSizeList
5  height={400}         // Wysokość kontenera
6  width="100%"         // Szerokość kontenera
7  itemCount={stars.length}  // Liczba elementów
8  itemSize={50}        // Wysokość każdego elementu (stała!)
9>
10  {StarRow}
11</FixedSizeList>;
12
13<VariableSizeList
14  height={400}
15  width="100%"
16  itemCount={messages.length}
17  itemSize={getItemSize}  // (index) => wysokość
18>
19  {MessageRow}
20</VariableSizeList>

W wersji 1 wiersz dostawał tylko index i style, a dane czytał z zewnętrznej zmiennej (albo z propa itemData). Przy aktualizacji do wersji 2 zamieniasz komponenty na List, itemCount na rowCount, itemSize na rowHeight i przenosisz dane do rowProps.

Kiedy używać wirtualizacji vs paginacji?

Wybór między wirtualizacją a paginacją zależy od kontekstu - tak jak wybór między teleskopem a mapą gwiazd:

Wirtualizacja jest lepsza gdy:

  • Użytkownik potrzebuje płynnego scrollowania (np. feed, czat, logi)
  • Dane są już załadowane (lub ładowane strumieniowo)
  • Zachowanie "naturalnego" scrollbara jest ważne
  • Lista jest bardzo długa, ale elementy są proste

Paginacja jest lepsza gdy:

  • Dane są ładowane z serwera stronicami
  • Użytkownik chce przeskakiwać do konkretnych stron
  • SEO jest ważne (każda strona ma osobny URL)
  • Elementy listy są bardzo złożone

Porównajmy oba podejścia w kodzie. Paginacja trzyma w stanie numer strony i wycina z tablicy tylko jej fragment, a wirtualizacja oddaje całą tablicę do List:

1// Paginacja - klasyczne podejście
2function PaginatedList({ items, pageSize }) {
3  const [page, setPage] = useState(1);
4  const totalPages = Math.ceil(items.length / pageSize);
5
6  return (
7    <div>
8      {/* Renderuj tylko elementy aktualnej strony */}
9      {items.slice((page - 1) * pageSize, page * pageSize).map(item => (
10        <ItemCard key={item.id} item={item} />
11      ))}
12      <div>
13        <button disabled={page === 1} onClick={() => setPage(p => p - 1)}>
14          Poprzednia
15        </button>
16        <span>Strona {page} z {totalPages}</span>
17        <button disabled={page === totalPages} onClick={() => setPage(p => p + 1)}>
18          Następna
19        </button>
20      </div>
21    </div>
22  );
23}
24
25// Wirtualizacja - nowoczesne podejście dla długich list
26function ItemRow({ index, style, items }) {
27  return (
28    <div style={style}>
29      <ItemCard item={items[index]} />
30    </div>
31  );
32}
33
34function VirtualizedList({ items }) {
35  return (
36    <List
37      rowComponent={ItemRow}
38      rowCount={items.length}
39      rowHeight={60}
40      rowProps={{ items }}
41      style={{ height: 600 }}
42    />
43  );
44}

W obu wersjach w DOM jest tylko garść kart, różni się sposób poruszania po danych: przyciski stron kontra płynne przewijanie. W wersji 2 nie przekazujesz już funkcji jako children, wiersz zawsze jest osobnym komponentem w rowComponent.

Mierzenie poprawy wydajności

Aby udowodnić, że wirtualizacja działa, możemy zmierzyć różnicę w wydajności. Komponent przełącza się między zwykłą listą a List i loguje przybliżony czas do następnej klatki:

1function NameRow({ index, style, items }) {
2  return <div style={style}>{items[index].name}</div>;
3}
4
5function PerformanceComparison() {
6  const [mode, setMode] = useState('virtualized');
7  const items = useMemo(
8    () => Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}` })),
9    []
10  );
11
12  // Mierzymy czas renderowania
13  useEffect(() => {
14    const start = performance.now();
15    // Po renderze
16    requestAnimationFrame(() => {
17      const end = performance.now();
18      console.log(`Czas renderowania (${mode}): ${(end - start).toFixed(2)}ms`);
19    });
20  }, [mode]);
21
22  return (
23    <div>
24      <div>
25        <button onClick={() => setMode('normal')}>Normalna lista</button>
26        <button onClick={() => setMode('virtualized')}>Zwirtualizowana</button>
27      </div>
28
29      {mode === 'normal' ? (
30        // 10 000 elementów DOM
31        <div style={{ height: 400, overflow: 'auto' }}>
32          {items.map(item => <div key={item.id}>{item.name}</div>)}
33        </div>
34      ) : (
35        // ~15 elementów DOM
36        <List
37          rowComponent={NameRow}
38          rowCount={items.length}
39          rowHeight={35}
40          rowProps={{ items }}
41          style={{ height: 400 }}
42        />
43      )}
44    </div>
45  );
46}
47
48// Wyniki zależą od urządzenia i przeglądarki - zmierz je u siebie.
49// Normalna lista: czas i pamięć rosną z liczbą elementów
50// Zwirtualizowana: praca zależy od wysokości okna, nie od długości listy

Nie podajemy tu konkretnych milisekund, bo na laptopie i na starym telefonie wyniki różnią się wielokrotnie. Dokładniejszy obraz da Ci zakładka Profiler w React DevTools i panel Performance w przeglądarce.

Skrzynka narzędzi wydajności: powtórka przed zadaniami

Wirtualizacja to tylko jedno z narzędzi wydajności. Część pozostałych, jak Profiler czy podział kodu, poznałeś w module o zaawansowanych wzorcach, a zadania po kolejnych lekcjach wracają do całej skrzynki. Zbierzmy ją więc w jednym miejscu: od tego, jak React renderuje, po pracę w tle, poza głównym wątkiem.

Jak React renderuje

Każda aktualizacja przechodzi przez ten sam cykl. Najpierw coś wyzwala render, np. zmiana stanu przez setState. W fazie renderu React wywołuje komponenty i dostaje nowy JSX, a podczas uzgadniania (reconciliation) porównuje nowe drzewo z poprzednim. W fazie commit nanosi na DOM tylko różnice, przeglądarka maluje ekran, a na końcu React uruchamia efekty z useEffect. Od React 18 kilka aktualizacji stanu trafia do jednego renderu (automatic batching) nie tylko w obsłudze zdarzeń, ale też w setTimeout, w obietnicach i w natywnych zdarzeniach:

1setTimeout(() => {
2  setFuel((fuel) => fuel - 10);
3  setSpeed((speed) => speed + 1);
4  // React 18+: jeden render zamiast dwóch
5}, 1000);

Wcześniej każda z tych dwóch zmian dawała osobny render. Współbieżne renderowanie (concurrent rendering) idzie krok dalej: React może przygotowywać kilka wersji interfejsu naraz i przerywać mniej pilne rendery, a korzystają z tego useTransition i useDeferredValue z tego modułu.

Profiler: mierz, zanim zoptymalizujesz

Komponent Profiler mierzy, jak długo renderuje się fragment drzewa. Owijasz nim mierzony komponent, a React wywołuje onRender po każdym renderze tego fragmentu:

1import { Profiler } from 'react';
2
3function onRender(id, phase, actualDuration) {
4  console.log(id + ' [' + phase + ']: ' + actualDuration.toFixed(1) + ' ms');
5}
6
7<Profiler id="StarMap" onRender={onRender}>
8  <StarMap />
9</Profiler>

Argument phase ma wartość "mount" przy pierwszym renderze i "update" przy kolejnych, a actualDuration to czas renderu w milisekundach. Te same dane pokaże zakładka Profiler w React DevTools: otwierasz DevTools, przechodzisz do zakładki Profiler, klikasz Start profiling, wykonujesz interakcję, zatrzymujesz nagrywanie i czytasz flamegraph, w którym od razu widać komponenty renderujące się najdłużej. Dopiero wtedy wybierasz technikę: memo, wirtualizację albo podział kodu.

Podział kodu i rozmiar paczki

Bundler, np. Vite albo webpack, buduje paczkę w kilku etapach. Analizuje kod i graf zależności, usuwa nieużywane eksporty (tree shaking), dzieli kod na mniejsze części (code splitting) i minifikuje je, a na końcu serwer kompresuje pliki przez gzip albo brotli. Tree shaking działa tylko z modułami ES, bo import i export są statyczne:

1import { debounce } from 'lodash-es'; // do paczki trafia tylko debounce
2const _ = require('lodash'); // CommonJS: trafia cała biblioteka

Ciężki widok wydzielasz do osobnej części przez lazy i dynamiczny import(). Jego kod pobiera się dopiero przy pierwszym renderze, a Suspense pokazuje w tym czasie fallback:

1const StarMap = lazy(() => import('./StarMap'));
2
3<Suspense fallback={<p>Ładowanie mapy...</p>}>
4  <StarMap />
5</Suspense>

Które moduły wydzielić, podpowie analizator paczki, np. rollup-plugin-visualizer w Vite albo webpack-bundle-analyzer. Pokazuje rozmiar każdego modułu po kompresji, więc łatwo znaleźć te, które przekraczają przyjęty budżet.

Obrazy ładowane leniwie

Pełnowymiarowe zdjęcia też nie muszą przychodzić od razu. Atrybut loading="lazy" każe przeglądarce pobrać obraz dopiero wtedy, gdy zbliża się on do ekranu. Do tego mała, rozmyta miniatura na czas ładowania i zdarzenia onLoad oraz onError, które przełączają stan komponentu:

1<img
2  src={planet.full}
3  alt={planet.name}
4  loading="lazy"
5  onLoad={() => setStatus('loaded')}
6  onError={() => setStatus('error')}
7/>

Gdy potrzebujesz własnej logiki, np. animacji przy wejściu w ekran, tę samą obserwację zrobisz przez IntersectionObserver.

Pomiary w przeglądarce

PerformanceObserver nasłuchuje wpisów z Performance API, np. własnych pomiarów (measure) i danych o ładowaniu strony (navigation):

1const observer = new PerformanceObserver((list) => {
2  list.getEntries().forEach((entry) => console.log('Performance:', entry));
3});
4observer.observe({ entryTypes: ['measure', 'navigation'] });

Wskaźniki Core Web Vitals (LCP, INP, CLS) najprościej zbierzesz biblioteką web-vitals.

Praca w tle: Web Worker i Service Worker

Ciężkich obliczeń nie musisz robić w wątku, który rysuje interfejs. Web Worker to osobny wątek, z którym rozmawiasz wiadomościami: postMessage wysyła dane, onmessage odbiera wynik w event.data, a terminate zatrzymuje wątek. W komponencie tworzysz go w efekcie i zatrzymujesz w sprzątaniu:

1useEffect(() => {
2  const worker = new Worker(new URL('./route.worker.js', import.meta.url), { type: 'module' });
3  worker.onmessage = (event) => setResult(event.data);
4  workerRef.current = worker;
5  return () => worker.terminate();
6}, []);

Kliknięcie wysyła dane przez workerRef.current.postMessage(...), a interfejs reaguje dalej, bo liczy inny wątek. Wątki robocze omówimy szerzej w następnym module. Service Worker z kolei stoi między aplikacją a siecią i w zdarzeniu fetch decyduje, skąd wziąć odpowiedź. Pliki statyczne obsługuje się zwykle strategią cache-first:

1async function cacheFirst(request, cache) {
2  const cached = await cache.match(request);
3  if (cached) return cached;
4  const response = await fetch(request);
5  await cache.put(request, response.clone());
6  return response;
7}

Dane z API dostają strategię network-first, która działa odwrotnie: najpierw pyta sieć i zapisuje świeżą kopię przez cache.put, a do cache.match sięga dopiero wtedy, gdy sieci nie ma. Kopia przez response.clone() jest potrzebna, bo treść odpowiedzi da się przeczytać tylko raz.

Podsumowanie

Wirtualizacja list to jedna z najskuteczniejszych technik optymalizacji wydajności w React:

  1. Renderuj tylko widoczne elementy - użyj react-window lub react-virtuoso
  2. List z liczbą w rowHeight dla elementów o stałej wysokości (najwydajniejsze; w 1.x FixedSizeList)
  3. List z funkcją w rowHeight dla elementów o zmiennej wysokości (w 1.x VariableSizeList), a useDynamicRowHeight, gdy wysokości trzeba zmierzyć
  4. Mierz przed i po - używaj React DevTools Profiler i performance.now()
  5. Rozważ paginację jako alternatywę, gdy dane są ładowane z serwera

Tak jak teleskop na statku kosmicznym pokazuje nam tylko fragment nieba w danym momencie, wirtualizacja pokazuje użytkownikowi tylko fragment listy - ale z płynnością godną najlepszych systemów nawigacyjnych.

Kod do tej lekcji: App.jsx
1import React, { useState, useRef, useCallback, useEffect } from 'react';
2
3// Generowanie dużego zbioru danych - 10 000 obiektów gwiezdnych
4function generateStarCatalog(count) {
5  const types = ['Gwiazda', 'Mgławica', 'Galaktyka', 'Pulsar', 'Kwazar'];
6  const constellations = ['Orion', 'Andromeda', 'Cassiopeia', 'Lyra', 'Cygnus', 'Draco'];
7  const catalog = [];
8  for (let i = 0; i < count; i++) {
9    catalog.push({
10      id: i,
11      name: `${types[i % types.length]}-${(i + 1).toString().padStart(5, '0')}`,
12      type: types[i % types.length],
13      constellation: constellations[i % constellations.length],
14      magnitude: (Math.random() * 15 - 2).toFixed(2),
15      distance: (Math.random() * 100000).toFixed(0),
16    });
17  }
18  return catalog;
19}
20
21// Wersja BEZ wirtualizacji - renderuje WSZYSTKIE elementy
22function NaiveList({ items, itemHeight }) {
23  return (
24    <div style={{
25      height: '300px', overflow: 'auto',
26      border: '1px solid #ff4444', borderRadius: '8px',
27    }}>
28      {items.map(item => (
29        <div key={item.id} style={{
30          height: itemHeight, padding: '0 12px',
31          display: 'flex', alignItems: 'center', gap: '12px',
32          borderBottom: '1px solid #1a2a3c',
33          background: item.id % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent',
34          fontSize: '13px', color: '#c0c8d0',
35        }}>
36          <span style={{ color: '#00d4ff', width: '180px' }}>{item.name}</span>
37          <span style={{ color: '#778da9', width: '100px' }}>{item.constellation}</span>
38          <span style={{ color: '#ffd700', width: '60px' }}>{item.magnitude}m</span>
39          <span style={{ color: '#556677' }}>{Number(item.distance).toLocaleString()} ly</span>
40        </div>
41      ))}
42    </div>
43  );
44}
45
46// Wersja Z wirtualizacją - renderuje TYLKO widoczne elementy
47function VirtualizedList({ items, itemHeight, containerHeight }) {
48  const containerRef = useRef(null);
49  const [scrollTop, setScrollTop] = useState(0);
50
51  // Obliczamy które elementy są widoczne
52  const totalHeight = items.length * itemHeight;
53  const startIndex = Math.floor(scrollTop / itemHeight);
54  const visibleCount = Math.ceil(containerHeight / itemHeight);
55  // Dodajemy bufor - kilka elementów więcej z góry i z dołu
56  const overscan = 5;
57  const renderStart = Math.max(0, startIndex - overscan);
58  const renderEnd = Math.min(items.length, startIndex + visibleCount + overscan);
59  const visibleItems = items.slice(renderStart, renderEnd);
60  const offsetY = renderStart * itemHeight;
61
62  const handleScroll = useCallback((e) => {
63    setScrollTop(e.target.scrollTop);
64  }, []);
65
66  return (
67    <div
68      ref={containerRef}
69      onScroll={handleScroll}
70      style={{
71        height: containerHeight, overflow: 'auto',
72        border: '1px solid #4ecdc4', borderRadius: '8px',
73        position: 'relative',
74      }}
75    >
76      {/* Pusty kontener o pełnej wysokości - dla scrollbara */}
77      <div style={{ height: totalHeight, position: 'relative' }}>
78        {/* Przesunięta grupa widocznych elementów */}
79        <div style={{
80          position: 'absolute', top: offsetY, left: 0, right: 0,
81        }}>
82          {visibleItems.map(item => (
83            <div key={item.id} style={{
84              height: itemHeight, padding: '0 12px',
85              display: 'flex', alignItems: 'center', gap: '12px',
86              borderBottom: '1px solid #1a2a3c',
87              background: item.id % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent',
88              fontSize: '13px', color: '#c0c8d0',
89            }}>
90              <span style={{ color: '#00d4ff', width: '180px' }}>{item.name}</span>
91              <span style={{ color: '#778da9', width: '100px' }}>{item.constellation}</span>
92              <span style={{ color: '#ffd700', width: '60px' }}>{item.magnitude}m</span>
93              <span style={{ color: '#556677' }}>{Number(item.distance).toLocaleString()} ly</span>
94            </div>
95          ))}
96        </div>
97      </div>
98    </div>
99  );
100}
101
102// Wirtualizowana siatka: ta sama zasada co w liście, tylko w każdym wierszu stoją 3 elementy
103function VirtualizedGrid({ items, containerHeight }) {
104  const [scrollTop, setScrollTop] = useState(0);
105  const COLUMNS = 3;
106  const ROW_HEIGHT = 70;
107  const overscan = 2;
108
109  const rowCount = Math.ceil(items.length / COLUMNS);
110  const firstRow = Math.floor(scrollTop / ROW_HEIGHT);
111  const visibleRows = Math.ceil(containerHeight / ROW_HEIGHT);
112  const startRow = Math.max(0, firstRow - overscan);
113  const endRow = Math.min(rowCount, firstRow + visibleRows + overscan);
114  // Do DOM trafiają tylko elementy z widocznych wierszy
115  const visibleItems = items.slice(startRow * COLUMNS, endRow * COLUMNS);
116
117  return (
118    <div
119      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
120      style={{
121        height: containerHeight, overflow: 'auto',
122        border: '1px solid #ffd700', borderRadius: '8px',
123      }}
124    >
125      <div style={{ height: rowCount * ROW_HEIGHT, position: 'relative' }}>
126        {visibleItems.map((item, i) => {
127          const index = startRow * COLUMNS + i;
128          const row = Math.floor(index / COLUMNS);
129          const column = index % COLUMNS;
130          return (
131            <div key={item.id} style={{
132              position: 'absolute', top: row * ROW_HEIGHT,
133              left: `${(column * 100) / COLUMNS}%`, width: `${100 / COLUMNS}%`,
134              height: ROW_HEIGHT, padding: '6px', boxSizing: 'border-box',
135            }}>
136              <div style={{
137                height: '100%', borderRadius: '6px', padding: '8px',
138                background: 'rgba(255, 215, 0, 0.06)', fontSize: '12px',
139              }}>
140                <div style={{ color: '#00d4ff' }}>{item.name}</div>
141                <div style={{ color: '#778da9' }}>{item.constellation}, {item.magnitude}m</div>
142              </div>
143            </div>
144          );
145        })}
146      </div>
147    </div>
148  );
149}
150
151export default function App() {
152  const [data] = useState(() => generateStarCatalog(10000));
153  const [mode, setMode] = useState('virtual');
154  const ITEM_HEIGHT = 40;
155  const CONTAINER_HEIGHT = 300;
156
157  // Mierzymy czas renderowania
158  const [renderTime, setRenderTime] = useState(null);
159  useEffect(() => {
160    const start = performance.now();
161    requestAnimationFrame(() => {
162      setRenderTime((performance.now() - start).toFixed(1));
163    });
164  }, [mode]);
165
166  return (
167    <div style={{
168      minHeight: '100vh',
169      background: 'linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%)',
170      padding: '24px', fontFamily: 'monospace', color: '#e0e1dd',
171    }}>
172      <h1 style={{ color: '#00d4ff', textAlign: 'center', marginBottom: '4px' }}>
173        Wirtualizacja list
174      </h1>
175      <p style={{ color: '#778da9', textAlign: 'center', marginBottom: '20px', fontSize: '13px' }}>
176        Renderuj tysiące elementów bez spadku wydajności
177      </p>
178
179      <div style={{ maxWidth: '750px', margin: '0 auto' }}>
180        <div style={{ display: 'flex', gap: '8px', marginBottom: '16px', justifyContent: 'center' }}>
181          {['naive', 'virtual', 'grid'].map(m => (
182            <button
183              key={m}
184              onClick={() => setMode(m)}
185              style={{
186                padding: '8px 16px',
187                background: mode === m ? '#1a3a5c' : 'transparent',
188                border: `1px solid ${mode === m ? '#00d4ff' : '#1a3a5c'}`,
189                borderRadius: '6px',
190                color: mode === m ? '#00d4ff' : '#778da9',
191                cursor: 'pointer', fontSize: '13px',
192              }}
193            >
194              {m === 'naive' ? 'Bez wirtualizacji' : m === 'virtual' ? 'Wirtualizowana lista' : 'Wirtualizowana siatka'}
195            </button>
196          ))}
197        </div>
198
199        <div style={{
200          padding: '10px 16px', marginBottom: '12px',
201          background: 'rgba(0, 212, 255, 0.08)', borderRadius: '8px',
202          display: 'flex', justifyContent: 'space-between', fontSize: '13px',
203        }}>
204          <span>Elementów: <strong style={{ color: '#ffd700' }}>{data.length.toLocaleString()}</strong></span>
205          <span>Renderowanych DOM: <strong style={{ color: mode === 'naive' ? '#ff4444' : '#4ecdc4' }}>
206            {mode === 'naive' ? data.length.toLocaleString() : mode === 'virtual' ? 'do 18' : 'do 27'}
207          </strong></span>
208          {renderTime && <span>Czas: <strong style={{ color: '#00d4ff' }}>{renderTime}ms</strong></span>}
209        </div>
210
211        {mode === 'naive' && (
212          <>
213            <p style={{ color: '#ff4444', fontSize: '12px', marginBottom: '8px' }}>
214              Uwaga: renderowanie {data.length.toLocaleString()} elementów DOM - może być wolne!
215            </p>
216            <NaiveList items={data.slice(0, 500)} itemHeight={ITEM_HEIGHT} />
217            <p style={{ color: '#778da9', fontSize: '11px', marginTop: '4px' }}>
218              (Ograniczone do 500 dla bezpieczeństwa przeglądarki)
219            </p>
220          </>
221        )}
222
223        {mode === 'virtual' && (
224          <VirtualizedList
225            items={data}
226            itemHeight={ITEM_HEIGHT}
227            containerHeight={CONTAINER_HEIGHT}
228          />
229        )}
230
231        {mode === 'grid' && (
232          <VirtualizedGrid items={data} containerHeight={CONTAINER_HEIGHT} />
233        )}
234
235        <div style={{
236          marginTop: '20px', padding: '16px',
237          background: 'rgba(255, 215, 0, 0.06)', borderRadius: '10px',
238          fontSize: '13px', color: '#c0c8d0',
239        }}>
240          <strong style={{ color: '#ffd700' }}>Jak działa wirtualizacja:</strong>
241          <ul style={{ margin: '8px 0', paddingLeft: '20px', lineHeight: '1.8' }}>
242            <li>Zamiast renderować 10 000 elementów, renderujemy tylko widoczne i kilka zapasowych</li>
243            <li>Używamy jednego dużego kontenera (phantom) dla scrollbara</li>
244            <li>Przesuwamy widoczne elementy na odpowiednią pozycję</li>
245            <li>Overscan (bufor) zapobiega miganiu przy szybkim przewijaniu</li>
246          </ul>
247        </div>
248      </div>
249    </div>
250  );
251}

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. Na czym polega wirtualizacja (windowing) list w React?

  2. 2. Jak biblioteka react-window implementuje wirtualizację list?

Zadania praktyczne w grze

  • Edytor kodu

    Katalog ma 100 000 gwiazd, a okno listy mieści tylko 10 wierszy po 40 px. Uzupełnij wirtualizację: ___BLANK1___ to wartość ze stanu, która mówi, o ile pikseli przewinięto okno, ___BLANK2___ to metoda tablicy, która wycina gwiazdy od indeksu start do end, a ___BLANK3___ to indeks gwiazdy w pełnej liście (pole obiektu star), od którego zależy pozycja top wiersza. W DOM ma być tylko kilkanaście wierszy, a po przewinięciu mają się pojawić właściwe gwiazdy na właściwej wysokości. Przewiń listę w podglądzie i sprawdź numery gwiazd.

  • Układanie w poziomie

    Ułóż składnię komponentu List z react-window 2.x:

  • Edytor kodu

    Formularz rejestracji załogi składa się z trzech pól CrewField. Każde pole ma etykietę i tekst pomocniczy, a komponent może pojawić się na ekranie wiele razy. Uzupełnij luki: ___BLANK1___ to hook Reacta, który generuje unikalny i stabilny identyfikator dla tej instancji pola, ___BLANK2___ to identyfikator, który etykieta wskazuje przez htmlFor, a ___BLANK3___ to identyfikator akapitu z tekstem pomocniczym, który pole wskazuje przez aria-describedby. Kliknięcie etykiety w podglądzie ma ustawić kursor w polu, a dwa takie formularze na jednej stronie nie mogą mieć wspólnych identyfikatorów.

Przydatne artykuły