Kurs JavaScript i TypeScript · Moduł 5: Zaawansowany JavaScript

Web APIs: Intersection Observer i Resize Observer

8 min czytania
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 obserwacje

root 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:

  1. Walidacja schematu - czy odpowiedź to tablica obiektów z polami name i species
  2. Walidacja typów - czy te pola są napisami
  3. Walidacja reguł biznesowych - czy gatunek istnieje w rejestrze parku
  4. 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:"

  1. IntersectionObserver - reaguje, gdy element wchodzi/wychodzi z widocznego obszaru
  2. Lazy loading - ładuj obrazy/treści dopiero gdy użytkownik do nich przewinie
  3. Infinite scroll - automatycznie doładowuj dane przy przewijaniu
  4. ResizeObserver - reaguje na zmianę rozmiaru elementów DOM
  5. 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. 1. Do czego służy IntersectionObserver w JavaScript?

  2. 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:

Przydatne artykuły