Listener scroll na window może odpalać się setki razy podczas jednego przesunięcia ekranu. Jeśli przy każdym takim evencie wywołujesz getBoundingClientRect() na każdej karcie w feedzie, pięćdziesiąt obrazów wystarczy, żeby strona zaczęła się zacinać. Intersection Observer API zdejmuje to sprawdzenie ze ścieżki scrolla: rejestrujesz, jakie elementy obserwować, a przeglądarka informuje cię, gdy ich widoczność naprawdę się zmienia.

Jak działa IntersectionObserver

Observer obserwuje jeden lub więcej elementów target względem root (domyślnie viewport) i wywołuje callback, gdy target przekroczy próg widoczności.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    console.log(entry.target, entry.isIntersecting, entry.intersectionRatio);
  }
}, {
  root: null,         // null = viewport przeglądarki
  rootMargin: "0px",  // powiększa lub zmniejsza box root przed testem
  threshold: 0,       // odpala się, gdy widoczny jest 1px
});

document.querySelectorAll(".card").forEach((el) => observer.observe(el));

Każda entry niesie isIntersecting (boolean), intersectionRatio (ile targetu znajduje się wewnątrz root, od 0 do 1) oraz własny boundingClientRect targetu. Callback jest asynchroniczny. Przeglądarka liczy przecięcia raz na każdy frame, jako część własnej pracy renderującej, więc twój kod nigdy nie wymusza ponownego przeliczenia layoutu w trakcie scrollowania przez użytkownika.

threshold przyjmuje też tablicę. [0, 0.5, 1] odpala callback osobno przy 0%, 50% i 100% widoczności — tak wysyła się event “oznaczone jako przeczytane po przewinięciu do połowy” bez tworzenia trzech observerów.

Jak zrobić lazy loading obrazów z IntersectionObserver

Klasyczny przypadek użycia: nie pobieraj obrazu, dopóki nie zbliży się do viewportu.

1
<img class="lazy" data-src="/media/hero-photo.jpg" alt="Grzbiet górski o zachodzie słońca" width="1200" height="800">
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const imgObserver = new IntersectionObserver((entries, obs) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    const img = entry.target;
    img.src = img.dataset.src;
    img.classList.remove("lazy");
    obs.unobserve(img); // z tym koniec
  }
}, { rootMargin: "200px 0px" }); // zaczyna ładować 200px przed widocznością

document.querySelectorAll("img.lazy").forEach((img) => imgObserver.observe(img));

rootMargin jest tym, co sprawia, że to wszystko wydaje się natychmiastowe. Wartość dodatnia powiększa efektywny box root, więc callback odpala się, gdy obraz jest jeszcze 200px poniżej widocznego obszaru, a nie przy pierwszym widocznym pikselu. Zostaw też jawne width i height na tagu: rezerwują miejsce, żeby layout nie przeskoczył, gdy pojawi się prawdziwy obraz — to osobna przyczyna layout shift, którą większość tutoriali o lazy loadingu ignoruje.

Zanim napiszesz to wszystko, sprawdź, czy wystarczy <img loading="lazy">. Każda aktualna przeglądarka wspiera to natywnie, bez JavaScriptu. Wersja z observerem ma sens, gdy potrzebujesz czegoś, czego atrybut nie umie: własnej podmiany placeholdera, lazy video czy iframe’ów, albo obrazów tła ustawianych z CSS.

Jak zbudować infinite scroll z elementem sentinelem

Infinite scroll nie obserwuje już wczytanej treści. Obserwuje pusty element pod listą i dogrywa kolejne wyniki, gdy ten sentinel staje się widoczny.

1
2
<ul id="results"></ul>
<div id="sentinel"></div>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const sentinel = document.querySelector("#sentinel");
let page = 1;

const scrollObserver = new IntersectionObserver(async (entries) => {
  if (!entries[0].isIntersecting) return;
  scrollObserver.unobserve(sentinel); // pauza na czas ładowania tej strony
  await loadNextPage(page++);
  scrollObserver.observe(sentinel);   // wraca do obserwowania kolejnej
}, { rootMargin: "400px" });

scrollObserver.observe(sentinel);

Para unobserve/observe zabezpiecza przed wolnymi sieciami. Bez niej użytkownik, który przewija wystarczająco szybko, może odpalić callback ponownie, zanim pierwsze żądanie się rozwiąże, a dwie strony wyników trafiają w złej kolejności.

loadNextPage to zwykłe wywołanie fetch, więc potrzebuje tego samego sprawdzenia response.ok co każde inne żądanie. Poradnik o obsłudze błędów Fetch API pokazuje, co robić, gdy to żądanie odpowie 404 albo sieć padnie w połowie scrolla. Przy błędzie zostaw sentinel nieobserwowany i pokaż przycisk ponownej próby. Natychmiastowe ponowne obserwowanie wpędza cię w pętlę nieudanych żądań.

Starym sposobem na to było wywołanie getBoundingClientRect() na sentinelu w handlerze scroll, ograniczone throttle’em albo debounce’em. Jak pokazuje porównanie debounce i throttle, obie techniki wciąż robią polling, tylko rzadziej. Observer nie robi pollingu wcale. Przeglądarka jest właścicielem tego sprawdzenia, a twój kod reaguje na wynik.

Kiedy odpala się callback IntersectionObserver

Nie w kolejce microtasków. Jeśli czytałeś, jak async/await współdziała z event loop, nie zakładaj, że callback zachowuje się jak kontynuacja promise. Przeglądarka planuje go z własnego pipeline’u renderowania, w okolicach paintu. Dlatego jest wystarczająco lekki, żeby używać go na scrollującej stronie, i dlatego też jest złym narzędziem do logiki, która musi wykonać się przed następnym repaintem.

Częste pułapki IntersectionObserver

Testuje bounding box, nie piksele. Element zgłoszony jako 100% intersecting może być nadal całkowicie zasłonięty przez nieprzezroczysty modal albo sticky header namalowany na wierzchu. API odpowiada na “czy ten box jest wewnątrz tamtego boxa”, nie na “czy użytkownik to widzi”.

Jeden observer na element się kumuluje. Wywołanie new IntersectionObserver() dla każdej z dwustu kart tworzy dwieście osobnych observerów. Twórz jeden na zachowanie (jeden dla wszystkich obrazów lazy, jeden dla sentinela) i wywołuj .observe() na każdym targecie, który go potrzebuje. Callback i tak otrzymuje entries dla tych targetów, które się zmieniły.

jsdom tego nie implementuje. W pakiecie testów Jest albo Vitest działającym na jsdom IntersectionObserver jest undefined, jeśli go nie zamockujesz albo nie zainstalujesz polyfilla. Zamockuj konstruktor i wywołaj callback samodzielnie z fałszywymi entries, żeby przetestować logikę lazy loadingu w izolacji. Nie próbuj symulować prawdziwego scrolla w teście jednostkowym.

Obserwowanie zanim element istnieje. W frameworku, który renderuje asynchronicznie, wywołanie observer.observe(el) na refie wciąż null zawodzi po cichu: bez błędu, bez ostrzeżenia, element nigdy nie jest obserwowany. Obserwuj wewnątrz hooka, który uruchamia się po zamontowaniu węzła DOM (useEffect w React, mounted w Vue).

Kiedy używać IntersectionObserver zamiast loading="lazy"

Dla zwykłych tagów <img> dodaj loading="lazy" i nie pisz żadnego JavaScriptu. Używaj IntersectionObserver do tego, czego atrybut nie wyraża: wyzwalaczy paginacji, autoplay wideo po pojawieniu się na ekranie, eventów “widziane” do analityki, podmiany placeholdera na obraz. Dobrym pierwszym krokiem jest znalezienie w kodzie listenera scroll, który wywołuje getBoundingClientRect(), i zastąpienie go jednym współdzielonym observerem.

Powiązane artykuły