Kurs JavaScript i React · Moduł 9: Nowoczesne hooki React
Wirtualizacja list i windowing
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 scrollowanieEfekty 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 listyNie 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 bibliotekaCięż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:
- Renderuj tylko widoczne elementy - użyj
react-windowlubreact-virtuoso Listz liczbą wrowHeightdla elementów o stałej wysokości (najwydajniejsze; w 1.xFixedSizeList)Listz funkcją wrowHeightdla elementów o zmiennej wysokości (w 1.xVariableSizeList), auseDynamicRowHeight, gdy wysokości trzeba zmierzyć- Mierz przed i po - używaj React DevTools Profiler i
performance.now() - 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. Na czym polega wirtualizacja (windowing) list w React?
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.