Kurs JavaScript i TypeScript · Moduł 5: Zaawansowany JavaScript
Web APIs: Intersection Observer i Resize Observer
W tej lekcji8
Dr. Alan Grant stoi przed panoramicznym oknem centrum kontroli Parku Jurajskiego. "Nasze monitory wyświetlają setki elementów jednocześnie - obrazy z kamer, wykresy, mapy sektorów," mówi. "Ale ładowanie wszystkiego naraz zabija wydajność. Potrzebujemy sposobu, aby reagować na to, co użytkownik faktycznie widzi i jak zmienia się układ strony." Odpowiedzią są Intersection Observer i Resize Observer - nowoczesne Web APIs do obserwacji elementów DOM.
Problem: Ręczne śledzenie widoczności
Tradycyjne podejście do sprawdzania, czy element jest widoczny na ekranie, wymaga nasłuchiwania zdarzenia scroll i ręcznego obliczania pozycji:
1// Stare podejście - wolne i skomplikowane
2window.addEventListener("scroll", () => {
3 const images = document.querySelectorAll("img[data-src]");
4
5 images.forEach(img => {
6 const rect = img.getBoundingClientRect();
7 const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
8
9 if (isVisible) {
10 img.src = img.dataset.src; // Załaduj obrazek
11 }
12 });
13});
14// Problem: scroll event odpala się dziesiątki razy na sekundę!
15// getBoundingClientRect() może wymusić synchroniczne przeliczenie układu (reflow)Handler uruchamia się dziesiątki razy na sekundę i za każdym razem przelicza pozycje wszystkich obrazków w głównym wątku, tym samym, który obsługuje przewijanie i kliknięcia. Obrazki już załadowane wciąż są sprawdzane, bo nikt nie zdejmuje z nich atrybutu data-src.
Intersection Observer - obserwacja widoczności
IntersectionObserver pozwala asynchronicznie obserwować, kiedy element wchodzi lub wychodzi z widocznego obszaru (viewport) lub innego elementu. To czujnik na granicy wybiegu: nie patrzysz na monitor bez przerwy, tylko dostajesz sygnał, gdy ktoś przekroczy linię:
1// Tworzenie obserwatora
2const observer = new IntersectionObserver((entries) => {
3 entries.forEach(entry => {
4 // entry.isIntersecting - czy element jest widoczny
5 // entry.intersectionRatio - jaki procent elementu jest widoczny (0-1)
6 // entry.target - obserwowany element DOM
7
8 if (entry.isIntersecting) {
9 console.log(`Element ${entry.target.id} jest teraz widoczny!`);
10 console.log(`Widoczność: ${(entry.intersectionRatio * 100).toFixed(0)}%`);
11 }
12 });
13}, {
14 // Opcje konfiguracyjne
15 root: null, // null = viewport przeglądarki
16 rootMargin: "0px", // margines wokół root (jak CSS margin)
17 threshold: 0.5 // callback gdy 50% elementu jest widoczne
18});
19
20// Rozpoczęcie obserwacji
21const dinoCard = document.querySelector("#dino-card");
22observer.observe(dinoCard);
23
24// Zatrzymanie obserwacji
25// observer.unobserve(dinoCard);
26// observer.disconnect(); // Zatrzymuje wszystkie obserwacjeroot to obszar odniesienia, rootMargin poszerza go lub zwęża jak margines w CSS, a threshold mówi, jaka część elementu musi być widoczna, by padł sygnał. Funkcja dostaje tablicę wpisów, bo jeden obserwator pilnuje wielu elementów. Wywoła się też raz tuż po observe(), z bieżącym stanem - dlatego zawsze sprawdzaj isIntersecting.
Lazy loading obrazów
Najpopularniejsze zastosowanie Intersection Observer to lazy loading - ładowanie obrazów dopiero, gdy użytkownik do nich przewinie:
1class LazyImageLoader {
2 constructor(options = {}) {
3 this.observer = new IntersectionObserver(
4 (entries) => this.handleIntersection(entries),
5 {
6 rootMargin: options.rootMargin || "200px", // Ładuj 200px przed widokiem
7 threshold: 0
8 }
9 );
10 }
11
12 handleIntersection(entries) {
13 entries.forEach(entry => {
14 if (entry.isIntersecting) {
15 const img = entry.target;
16
17 // Zamień placeholder na prawdziwy obrazek
18 img.src = img.dataset.src;
19 img.classList.add("loaded");
20
21 // Przestań obserwować załadowany obrazek
22 this.observer.unobserve(img);
23
24 console.log(`Załadowano obraz: ${img.dataset.src}`);
25 }
26 });
27 }
28
29 observe(selector) {
30 const images = document.querySelectorAll(selector);
31 images.forEach(img => this.observer.observe(img));
32 console.log(`Obserwuję ${images.length} obrazów`);
33 }
34
35 destroy() {
36 this.observer.disconnect();
37 }
38}
39
40// Użycie
41const loader = new LazyImageLoader({ rootMargin: "300px" });
42loader.observe("img[data-src]");Domyślny rootMargin sprawia, że obraz zaczyna się ładować 200 pikseli przed wejściem na ekran, a w przykładzie użycia nawet 300. Po załadowaniu unobserve() odpina obrazek, żeby obserwator nie pracował na próżno. Dla zwykłych obrazków wystarczy dziś atrybut HTML loading="lazy" - obserwatora użyj, gdy potrzebujesz własnej logiki.
Infinite scroll
Intersection Observer świetnie nadaje się do implementacji nieskończonego przewijania. Zamiast obserwować każdą kartę, pilnujemy jednego niewidocznego znacznika na końcu listy, czyli strażnika:
1class InfiniteScroll {
2 constructor(containerSelector, loadMore) {
3 this.container = document.querySelector(containerSelector);
4 this.loadMore = loadMore;
5 this.isLoading = false;
6 this.page = 1;
7
8 // Tworzymy element "strażnika" na końcu listy
9 this.sentinel = document.createElement("div");
10 this.sentinel.className = "scroll-sentinel";
11 this.container.appendChild(this.sentinel);
12
13 // Obserwujemy strażnika
14 this.observer = new IntersectionObserver(
15 (entries) => {
16 if (entries[0].isIntersecting && !this.isLoading) {
17 this.loadNextPage();
18 }
19 },
20 { rootMargin: "100px" }
21 );
22
23 this.observer.observe(this.sentinel);
24 }
25
26 async loadNextPage() {
27 this.isLoading = true;
28 console.log(`Ładuję stronę ${this.page}...`);
29
30 try {
31 const items = await this.loadMore(this.page);
32
33 items.forEach(item => {
34 const element = document.createElement("div");
35 element.className = "dino-card";
36 element.textContent = `${item.name} (${item.species})`;
37 // Wstawiamy PRZED strażnikiem
38 this.container.insertBefore(element, this.sentinel);
39 });
40
41 this.page++;
42 } catch (error) {
43 console.error("Błąd ładowania:", error);
44 } finally {
45 this.isLoading = false;
46 }
47 }
48}Gdy strażnik zbliży się do ekranu, ładujemy kolejną stronę i wstawiamy karty przed nim, więc on zawsze zostaje na końcu. Flaga isLoading chroni przed podwójnym ładowaniem, a finally zdejmuje ją nawet po błędzie. Dzięki textContent zamiast innerHTML tekst z serwera nigdy nie zostanie potraktowany jak kod HTML.
Walidacja danych i testy
Dane z loadMore() przychodzą z zewnątrz, więc zanim trafią na stronę, przepuść je przez potok walidacji:
- Walidacja schematu - czy odpowiedź to tablica obiektów z polami
nameispecies - Walidacja typów - czy te pola są napisami
- Walidacja reguł biznesowych - czy gatunek istnieje w rejestrze parku
- Sanityzacja - oczyszczenie danych dla bezpieczeństwa przed wyświetleniem
Kod z obserwatorami testuje się warstwami: środowisko jsdom nie ma IntersectionObserver, więc w testach jednostkowych zastępujesz go atrapą, testy integracyjne sprawdzają współpracę klas, a prawdziwe przewijanie weryfikują testy end-to-end w przeglądarce i testy wydajnościowe.
Resize Observer - obserwacja zmian rozmiaru
ResizeObserver powiadamia, gdy element zmieni swoje wymiary. Jest nieoceniony dla responsywnych komponentów, a schemat użycia jest ten sam co wyżej:
1// Tworzenie Resize Observer
2const resizeObserver = new ResizeObserver((entries) => {
3 entries.forEach(entry => {
4 const { width, height } = entry.contentRect;
5 console.log(`Element ${entry.target.id} zmienił rozmiar: ${width}x${height}`);
6 });
7});
8
9// Obserwacja elementu
10const mapContainer = document.querySelector("#park-map");
11resizeObserver.observe(mapContainer);contentRect opisuje obszar treści, bez ramki i paddingu. To starsze pole zachowane dla zgodności - nowsze contentBoxSize i borderBoxSize są dokładniejsze. W przeciwieństwie do zdarzenia resize okna obserwator reaguje na zmianę rozmiaru konkretnego elementu, bez względu na przyczynę.
Responsywny komponent z Resize Observer
Obserwator rozmiaru pozwala siatce kart dopasować liczbę kolumn do własnej szerokości, a nie do szerokości całego okna:
1class ResponsiveDinoGrid {
2 constructor(containerSelector) {
3 this.container = document.querySelector(containerSelector);
4 this.currentLayout = null;
5
6 this.observer = new ResizeObserver((entries) => {
7 const entry = entries[0];
8 const width = entry.contentRect.width;
9 this.updateLayout(width);
10 });
11
12 this.observer.observe(this.container);
13 }
14
15 updateLayout(width) {
16 let newLayout;
17
18 if (width < 400) {
19 newLayout = "single-column";
20 this.container.style.gridTemplateColumns = "1fr";
21 } else if (width < 800) {
22 newLayout = "two-columns";
23 this.container.style.gridTemplateColumns = "1fr 1fr";
24 } else {
25 newLayout = "multi-column";
26 this.container.style.gridTemplateColumns = "repeat(auto-fill, minmax(250px, 1fr))";
27 }
28
29 if (newLayout !== this.currentLayout) {
30 this.currentLayout = newLayout;
31 console.log(`Layout zmieniony na: ${newLayout} (szerokość: ${width}px)`);
32 }
33 }
34
35 destroy() {
36 this.observer.disconnect();
37 }
38}Siatka przełącza układ przy 400 i 800 pikselach, a komunikat wypisuje tylko przy faktycznej zmianie układu. Gdy chodzi wyłącznie o wygląd, rozważ CSS container queries, a obserwator zostaw na logikę, której CSS nie wyrazi.
Łączenie obu Observerów
Oba obserwatory świetnie się uzupełniają. Panel monitoringu włącza tylko widoczne kamery i dobiera jakość obrazu do rozmiaru kafelka:
1// System panelu monitoringu Parku Jurajskiego
2class MonitoringPanel {
3 constructor(panelSelector) {
4 this.panel = document.querySelector(panelSelector);
5 this.cameras = [];
6
7 // Intersection Observer - włączaj/wyłączaj kamery w zależności od widoczności
8 this.visibilityObserver = new IntersectionObserver(
9 (entries) => this.handleVisibility(entries),
10 { threshold: 0.1 }
11 );
12
13 // Resize Observer - dostosuj jakość obrazu do rozmiaru panelu
14 this.resizeObserver = new ResizeObserver(
15 (entries) => this.handleResize(entries)
16 );
17 }
18
19 addCamera(cameraElement) {
20 this.cameras.push(cameraElement);
21 this.visibilityObserver.observe(cameraElement);
22 this.resizeObserver.observe(cameraElement);
23 }
24
25 handleVisibility(entries) {
26 entries.forEach(entry => {
27 const cameraId = entry.target.dataset.cameraId;
28
29 if (entry.isIntersecting) {
30 console.log(`Kamera ${cameraId}: WŁĄCZONA (widoczna)`);
31 entry.target.classList.add("active");
32 } else {
33 console.log(`Kamera ${cameraId}: WSTRZYMANA (poza widokiem)`);
34 entry.target.classList.remove("active");
35 }
36 });
37 }
38
39 handleResize(entries) {
40 entries.forEach(entry => {
41 const { width } = entry.contentRect;
42 const cameraId = entry.target.dataset.cameraId;
43
44 let quality;
45 if (width < 200) quality = "low";
46 else if (width < 500) quality = "medium";
47 else quality = "high";
48
49 entry.target.dataset.quality = quality;
50 console.log(`Kamera ${cameraId}: jakość = ${quality} (szer: ${width}px)`);
51 });
52 }
53
54 destroy() {
55 this.visibilityObserver.disconnect();
56 this.resizeObserver.disconnect();
57 }
58}Każda kamera jest pilnowana przez oba obserwatory naraz, a destroy() odłącza je metodą disconnect(). Bez tego obserwatory działałyby dalej, choć panel dawno zniknął.
Podsumowanie
Dr. Grant podsumowuje: "Obserwatory to jak nasz system czujników w parku - nie musimy ciągle sprawdzać każdej kamery ręcznie:"
- IntersectionObserver - reaguje, gdy element wchodzi/wychodzi z widocznego obszaru
- Lazy loading - ładuj obrazy/treści dopiero gdy użytkownik do nich przewinie
- Infinite scroll - automatycznie doładowuj dane przy przewijaniu
- ResizeObserver - reaguje na zmianę rozmiaru elementów DOM
- Responsywne komponenty - dostosowuj layout bez media queries
Oba API są asynchroniczne i znacznie wydajniejsze niż ręczne nasłuchiwanie scroll czy resize. Przeglądarka sama wylicza przecięcia i rozmiary, a Twój callback woła dopiero wtedy, gdy coś się zmieni. Sam callback działa jednak w głównym wątku, więc powinien być krótki. Z obserwatorami spotkasz się znowu w lokacji o lazy loadingu obrazów, tras i komponentów, a w laboratorium poniżej wypróbujesz je od razu.
Pamiętaj: obserwatory to czujniki na granicy wybiegu - odzywają się tylko wtedy, gdy coś naprawdę przekroczy linię.
Kod do tej lekcji: index.js
1// Web APIs: Intersection Observer i Resize Observer
2// Park Jurajski - System monitoringu
3
4console.log("=== INTERSECTION OBSERVER ===");
5
6// Symulacja IntersectionObserver (w przegladarce dziala z DOM)
7// Pokaz koncepcji - jak wyglada API
8
9// Tworzenie obserwatora
10// const observer = new IntersectionObserver((entries) => {
11// entries.forEach(entry => {
12// if (entry.isIntersecting) {
13// console.log("Element widoczny:", entry.target.id);
14// }
15// });
16// }, {
17// root: null, // viewport przegladarki
18// rootMargin: "0px", // margines
19// threshold: 0.5 // 50% widocznosci
20// });
21
22// === LAZY LOADING ===
23console.log("\n=== LAZY LOADING (koncepcja) ===");
24
25class LazyImageLoader {
26 constructor(rootMargin = "200px") {
27 this.loadedImages = [];
28 this.rootMargin = rootMargin;
29 // W przegladarce: this.observer = new IntersectionObserver(...)
30 }
31
32 // Symulacja ladowania obrazka
33 loadImage(imageData) {
34 console.log(`Ladowanie obrazu: ${imageData.src}`);
35 this.loadedImages.push(imageData.src);
36 return { ...imageData, loaded: true };
37 }
38
39 getStats() {
40 return {
41 loaded: this.loadedImages.length,
42 images: this.loadedImages
43 };
44 }
45}
46
47const loader = new LazyImageLoader("300px");
48loader.loadImage({ src: "/cameras/sector-a.jpg", alt: "Sektor A" });
49loader.loadImage({ src: "/cameras/sector-b.jpg", alt: "Sektor B" });
50console.log("Statystyki:", loader.getStats());
51
52// === INFINITE SCROLL ===
53console.log("\n=== INFINITE SCROLL (symulacja) ===");
54
55class InfiniteScroll {
56 constructor(loadMore) {
57 this.loadMore = loadMore;
58 this.page = 0;
59 this.items = [];
60 this.isLoading = false;
61 }
62
63 async loadNextPage() {
64 if (this.isLoading) return;
65 this.isLoading = true;
66 this.page++;
67 console.log(`Laduje strone ${this.page}...`);
68
69 const newItems = await this.loadMore(this.page);
70 this.items.push(...newItems);
71 this.isLoading = false;
72 return newItems;
73 }
74
75 getAll() {
76 return [...this.items];
77 }
78}
79
80// Symulacja API
81const fetchDinosaurs = (page) => {
82 return Promise.resolve([
83 { name: `Dino-${page}A`, species: "T-Rex" },
84 { name: `Dino-${page}B`, species: "Velociraptor" },
85 { name: `Dino-${page}C`, species: "Triceratops" },
86 ]);
87};
88
89const scroll = new InfiniteScroll(fetchDinosaurs);
90
91// Ladowanie stron
92async function demo() {
93 await scroll.loadNextPage();
94 await scroll.loadNextPage();
95 await scroll.loadNextPage();
96 console.log("Wszystkie zaladowane:", scroll.getAll().length, "dinozaurow");
97 console.log("Elementy:", scroll.getAll().map(d => d.name));
98}
99
100demo();
101
102// === RESIZE OBSERVER ===
103console.log("\n=== RESIZE OBSERVER (koncepcja) ===");
104
105class ResponsiveDinoGrid {
106 constructor() {
107 this.currentLayout = null;
108 }
109
110 updateLayout(width) {
111 let newLayout;
112 if (width < 400) newLayout = "single-column";
113 else if (width < 800) newLayout = "two-columns";
114 else newLayout = "multi-column";
115
116 if (newLayout !== this.currentLayout) {
117 this.currentLayout = newLayout;
118 console.log(`Layout: ${newLayout} (szerokosc: ${width}px)`);
119 }
120 return newLayout;
121 }
122}
123
124const grid = new ResponsiveDinoGrid();
125grid.updateLayout(300); // single-column
126grid.updateLayout(600); // two-columns
127grid.updateLayout(1200); // multi-column
128
129// TODO: Stworz klase MonitoringPanel
130// ktora laczy IntersectionObserver (wlaczanie kamer)
131// z ResizeObserver (dostosowanie jakosci)
132console.log("\n=== Cwiczenie ===");
133console.log("Stworz MonitoringPanel - patrz TODO w kodzie");Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Do czego służy IntersectionObserver w JavaScript?
2. Dlaczego IntersectionObserver jest lepszy od nasłuchiwania zdarzenia scroll do implementacji lazy loading?
Zadania praktyczne w grze
- Edytor kodu
Zaimplementuj lazy loading obrazów z IntersectionObserver i responsywną siatkę z ResizeObserver.
- Układanie w poziomie
Ułóż elementy tworzenia i użycia ResizeObserver:
- Układanie w pionie
Uporządkuj etapy testowania aplikacji od jednostkowych do systemowych:
- Układanie w pionie
Uporządkuj pipeline walidacji danych: