Kurs JavaScript i TypeScript · Moduł 8: Zdarzenia i DOM

Zaawansowane DOM API - Obserwatory i nowoczesne interfejsy

7 min czytania ·
W tej lekcji7

Systemy monitoringu w Parku Jurajskim muszą reagować na zmiany w czasie rzeczywistym - pojawienie się dinozaura w strefie, zmiana rozmiaru obszaru widoczności, mutacje w strukturze danych. JavaScript oferuje zaawansowane API do obserwowania tych zmian bez konieczności ciągłego odpytywania (polling).

Odpytywanie to strażnik, który co minutę zagląda do każdej zagrody i pyta, czy coś się zmieniło. Męczą się przy tym i strażnik, i procesor, a reakcja i tak bywa spóźniona. Obserwatory odwracają ten układ: to przeglądarka daje znać, gdy coś się wydarzy.

IntersectionObserver - obserwowanie widoczności

IntersectionObserver informuje, kiedy element wchodzi lub wychodzi z widocznego obszaru strony. Idealny do lazy loading, infinite scroll i animacji przy scrollowaniu. Konstruktor przyjmuje callback i obiekt opcji: root wskazuje obszar odniesienia (domyślnie null, czyli viewport), threshold próg widoczności, a rootMargin margines wokół tego obszaru.

// Podstawowe użycie
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log(entry.target.id + ' jest widoczny');
      entry.target.classList.add('visible');
    } else {
      entry.target.classList.remove('visible');
    }
  });
}, {
  threshold: 0.5,    // Wymagane 50% widoczności
  rootMargin: '100px' // Dodatkowy margines "przed" elementem
});

// Obserwuj elementy
document.querySelectorAll('.zone-card').forEach(card => {
  observer.observe(card);
});

Callback zadziała, gdy widoczna część karty przekroczy połowę, w górę albo w dół. Właściwość isIntersecting mówi, czy element jest w obszarze widoku, a entry.target wskazuje, którego elementu dotyczy wpis. Dokładny ułamek widocznej powierzchni podaje intersectionRatio.

Leniwe ładowanie obrazów składa się z czterech kroków: obrazy dostają adres w data-src zamiast src, tworzymy obserwator z callbackiem, gdy obraz wejdzie w widok, kopiujemy data-src do src, a na koniec wywołujemy unobserve().

// Lazy loading obrazów
const imageObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src; // Ładuj obraz
      imageObserver.unobserve(img); // Przestań obserwować
    }
  });
});

document.querySelectorAll('img[data-src]').forEach(img => {
  imageObserver.observe(img);
});

Po unobserve() załadowany obraz znika z listy obserwowanych, więc nie zużywa więcej uwagi. Moja rada: dla samych obrazków najpierw sprawdź atrybut HTML loading="lazy", który robi to bez JavaScriptu. Obserwatora używaj do reszty: animacji, liczników i doładowywania list.

MutationObserver - obserwowanie zmian DOM

MutationObserver reaguje na zmiany w drzewie DOM - dodanie/usunięcie elementów, zmiana atrybutów, modyfikacja tekstu.

// Obserwuj zmiany w kontenerze
const mutObserver = new MutationObserver((mutations) => {
  mutations.forEach(mutation => {
    if (mutation.type === 'childList') {
      mutation.addedNodes.forEach(node => {
        if (node.nodeType === 1) { // Element
          console.log('Dodano element:', node.tagName);
        }
      });
      mutation.removedNodes.forEach(node => {
        if (node.nodeType === 1) {
          console.log('Usunięto element:', node.tagName);
        }
      });
    }
    if (mutation.type === 'attributes') {
      console.log('Zmieniony atrybut:', mutation.attributeName);
    }
  });
});

Callback dostaje tablicę rekordów zmian. Rekord typu childList niesie listy addedNodes i removedNodes, a warunek nodeType === 1 (stała Node.ELEMENT_NODE) pomija węzły tekstowe. Rekord typu attributes podaje nazwę zmienionego atrybutu. Przeglądarka dostarcza rekordy paczką, asynchronicznie po wprowadzeniu zmian, a nie po jednym. Czas go podłączyć.

const container = document.getElementById('dino-list');
mutObserver.observe(container, {
  childList: true,   // Obserwuj dodawanie/usuwanie dzieci
  attributes: true,  // Obserwuj zmiany atrybutów
  subtree: true      // Obserwuj też potomków
});

// Zatrzymanie obserwowania
// mutObserver.disconnect();

Opcje decydują, czego słuchamy, a subtree rozszerza nasłuch na wszystkich potomków. Przynajmniej jedna z opcji childList, attributes lub characterData musi być włączona, inaczej observe() rzuci błąd TypeError. Metoda disconnect() kończy obserwację.

ResizeObserver - obserwowanie rozmiaru

ResizeObserver reaguje na zmianę rozmiaru elementów - nie tylko okna, ale dowolnego elementu DOM.

const resizeObserver = new ResizeObserver((entries) => {
  entries.forEach(entry => {
    const { width, height } = entry.contentRect;
    console.log(entry.target.id + ':', width + 'x' + height);

    // Dostosuj układ na podstawie rozmiaru
    if (width < 400) {
      entry.target.classList.add('compact');
    } else {
      entry.target.classList.remove('compact');
    }
  });
});

resizeObserver.observe(document.getElementById('dashboard'));

Dashboard węższy niż 400 pikseli dostaje klasę compact, niezależnie od tego, czy zwęziło się okno, czy panel obok. Callback odpala też raz zaraz po rozpoczęciu obserwacji, o ile element jest wyrenderowany, więc układ jest poprawny od startu. Jeśli zmieniasz wyłącznie style, rozważ zapytania kontenerowe CSS (@container), które zrobią to bez JavaScriptu.

Web Storage API

localStorage i sessionStorage pozwalają przechowywać dane w przeglądarce. Oba przechowują wyłącznie tekst.

// localStorage - dane przetrwają zamknięcie przeglądarki
localStorage.setItem('parkSettings', JSON.stringify({
  theme: 'dark',
  alertLevel: 3,
  lastVisit: Date.now()
}));

const settings = JSON.parse(localStorage.getItem('parkSettings'));
console.log(settings.theme); // 'dark'

// sessionStorage - dane znikają po zamknięciu karty
sessionStorage.setItem('currentZone', 'A');

Dlatego zapis to zawsze dwa kroki: JSON.stringify(), a potem localStorage.setItem(). Odczyt działa odwrotnie: localStorage.getItem(), a potem JSON.parse(). Dla nieistniejącego klucza getItem() zwraca null. Dane z sessionStorage przetrwają odświeżenie strony, ale nie zamknięcie karty.

// Nasłuchiwanie zmian (między kartami!)
window.addEventListener('storage', (event) => {
  console.log('Zmieniono:', event.key);
  console.log('Stara wartość:', event.oldValue);
  console.log('Nowa wartość:', event.newValue);
});

// Usuwanie
localStorage.removeItem('parkSettings');
localStorage.clear(); // Usuń wszystko

Wydarzenie storage dostają inne karty z tego samego origin (protokół, domena i port), a nie karta, która dokonała zmiany, więc tak zsynchronizujesz ustawienia między zakładkami. Nie przechowuj tu haseł ani tokenów, bo odczyta je każdy skrypt na stronie.

Clipboard API

Schowek obsługuje obiekt navigator.clipboard. Jego metody zwracają obietnice, dlatego pracujemy z async i await.

// Kopiowanie tekstu
async function copyToClipboard(text) {
  try {
    await navigator.clipboard.writeText(text);
    console.log('Skopiowano:', text);
  } catch (err) {
    console.error('Błąd kopiowania:', err);
  }
}

// Wklejanie tekstu
async function pasteFromClipboard() {
  try {
    const text = await navigator.clipboard.readText();
    return text;
  } catch (err) {
    console.error('Brak dostępu do schowka');
  }
}

Clipboard API działa tylko w bezpiecznym kontekście, czyli przez HTTPS albo na localhost, a odczyt schowka przeglądarka może zablokować lub uzależnić od zgody użytkownika. Stąd try...catch w obu funkcjach. Teraz przycisk kopiowania.

// Przycisk kopiowania z feedbackiem
const copyBtn = document.getElementById('copy-btn');
copyBtn.addEventListener('click', async () => {
  const data = document.getElementById('sensor-data').textContent;
  await copyToClipboard(data);
  copyBtn.textContent = 'Skopiowano!';
  setTimeout(() => copyBtn.textContent = 'Kopiuj', 2000);
});

Dzięki await napis zmienia się dopiero po skopiowaniu danych czujnika, a po dwóch sekundach wraca do "Kopiuj".

History API i nawigacja

Aplikacja jednostronicowa może zmieniać adres w pasku przeglądarki bez przeładowania strony.

// Dodawanie wpisu do historii (bez przeładowania strony)
history.pushState({ zone: 'A' }, '', '/park/zone-a');
history.pushState({ zone: 'B' }, '', '/park/zone-b');

// Reagowanie na przycisk "Wstecz"
window.addEventListener('popstate', (event) => {
  if (event.state) {
    console.log('Powrót do strefy:', event.state.zone);
    loadZone(event.state.zone);
  }
});

// Zastępowanie aktualnego wpisu (bez dodawania nowego)
history.replaceState({ zone: 'A', view: 'map' }, '', '/park/zone-a?view=map');

Metoda pushState() przyjmuje stan, nieużywany dziś tytuł i adres, a następnie dodaje nowy wpis historii. Metoda replaceState() podmienia bieżący wpis. Samo pushState() nie wywołuje popstate. To wydarzenie przychodzi dopiero po kliknięciu "Wstecz" lub "Dalej". Warunek if (event.state) chroni przed wpisem startowym, który nie ma stanu.

DocumentFragment - optymalizacja DOM

Dodawanie wielu elementów naraz jest kosztowne. DocumentFragment pozwala przygotować elementy "poza DOM" i dodać je jedną operacją:

// BEZ fragmentu - każde appendChild osobno zmienia żywy DOM
const list = document.getElementById('dino-list');
const dinosaurs = ['Rex', 'Blue', 'Brachio', 'Stego', 'Trike'];

// ŹLE - 5 oddzielnych operacji DOM
dinosaurs.forEach(name => {
  const li = document.createElement('li');
  li.textContent = name;
  list.appendChild(li); // 5 osobnych zmian żywego DOM
});

Tu każda z pięciu iteracji osobno zmienia żywe drzewo strony. Współczesne przeglądarki grupują takie zmiany i zwykle przeliczają układ raz na klatkę, ale gdy między wstawieniami kod odczyta wymiary, na przykład offsetHeight, każdy odczyt wymusi osobny reflow.

// DOBRZE - DocumentFragment
const fragment = document.createDocumentFragment();
dinosaurs.forEach(name => {
  const li = document.createElement('li');
  li.textContent = name;
  fragment.appendChild(li); // Operacja "w pamięci"
});
list.appendChild(fragment); // 1 zmiana żywego DOM

Fragment żyje tylko w pamięci. Wstawienie go przenosi wszystkie dzieci do listy jedną operacją, a sam fragment zostaje pusty i nie pojawia się w DOM. Tak ograniczasz reflow do minimum, a MutationObserver dostaje jeden rekord zamiast pięciu.

"Zaawansowane DOM API to radar i systemy wczesnego ostrzegania Parku" - podsumowuje Ray Arnold. "IntersectionObserver to kamera termowizyjna, MutationObserver to system alarmowy, a ResizeObserver to adaptacyjny ekran kontrolny. Używaj ich mądrze!"

W piaskownicy poniżej wypróbujesz obserwatory, DocumentFragment i Web Storage w interaktywnym panelu. Wszystkie te narzędzia przydadzą się w projekcie końcowym, interaktywnym panelu kontrolnym parku.

Pamiętaj: dobry system parku nie odpytuje zagród co minutę, tylko czeka, aż obserwator sam podniesie alarm.

Kod do tej lekcji: index.html
<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Zaawansowane DOM API</title>
    <style>
        body { font-family: monospace; background: #1a1a2e; color: #e0e0e0; padding: 16px; }
        h2 { color: #4ecdc4; }
        .zone-card { padding: 12px; margin: 8px 0; border: 1px solid #3d3d5c; border-radius: 8px; transition: opacity 0.3s; opacity: 0.5; }
        .zone-card.visible { opacity: 1; background: #16213e; }
        #log { background: #0f3460; padding: 12px; border-radius: 8px; max-height: 200px; overflow-y: auto; font-size: 13px; }
        .log-entry { padding: 2px 0; border-bottom: 1px solid #1a1a4e; }
        button { padding: 8px 16px; margin: 4px; background: #4ecdc4; color: #1a1a2e; border: none; border-radius: 4px; cursor: pointer; }
        #dino-list { list-style: none; padding: 0; }
        #dino-list li { padding: 6px 10px; margin: 3px 0; background: #16213e; border-radius: 4px; }
    </style>
</head>
<body>
    <h2>Zaawansowane DOM API</h2>

    <h3>1. IntersectionObserver</h3>
    <div id="zone-container" style="height: 120px; overflow-y: auto;">
        <div class="zone-card" id="z1">Zone A - T-Rex</div>
        <div class="zone-card" id="z2">Zone B - Raptors</div>
        <div class="zone-card" id="z3">Zone C - Herbivores</div>
        <div class="zone-card" id="z4">Zone D - Aviary</div>
        <div class="zone-card" id="z5">Zone E - Aquatic</div>
    </div>

    <h3>2. MutationObserver + DocumentFragment</h3>
    <button onclick="addDinos()">Dodaj dinozaury</button>
    <button onclick="clearList()">Wyczyść</button>
    <ul id="dino-list"></ul>

    <h3>3. Web Storage</h3>
    <button onclick="saveVisit()">Zapisz wizytę</button>
    <button onclick="loadVisit()">Odczytaj</button>
    <p id="storage-info">-</p>

    <h3>Log</h3>
    <div id="log"></div>

    <script>
        function log(msg) {
            const el = document.getElementById('log');
            const entry = document.createElement('div');
            entry.className = 'log-entry';
            entry.textContent = new Date().toLocaleTimeString() + ' ' + msg;
            el.prepend(entry);
        }

        // IntersectionObserver
        const io = new IntersectionObserver((entries) => {
            entries.forEach(e => {
                e.target.classList.toggle('visible', e.isIntersecting);
                if (e.isIntersecting) log('Widoczny: ' + e.target.textContent);
            });
        }, { root: document.getElementById('zone-container'), threshold: 0.5 });

        document.querySelectorAll('.zone-card').forEach(c => io.observe(c));

        // MutationObserver
        const mo = new MutationObserver((muts) => {
            muts.forEach(m => {
                m.addedNodes.forEach(n => {
                    if (n.nodeType === 1) log('Dodano: ' + n.textContent);
                });
            });
        });
        mo.observe(document.getElementById('dino-list'), { childList: true });

        // DocumentFragment
        function addDinos() {
            const names = ['Rex', 'Blue', 'Brachio', 'Stego', 'Trike'];
            const frag = document.createDocumentFragment();
            names.forEach(n => {
                const li = document.createElement('li');
                li.textContent = n;
                frag.appendChild(li);
            });
            document.getElementById('dino-list').appendChild(frag);
            log('Dodano 5 dinozaurów (DocumentFragment)');
        }

        function clearList() {
            document.getElementById('dino-list').innerHTML = '';
            log('Lista wyczyszczona');
        }

        // Web Storage
        function saveVisit() {
            const data = { zone: 'A', time: new Date().toISOString(), count: Math.floor(Math.random()*10) };
            localStorage.setItem('lastVisit', JSON.stringify(data));
            log('Zapisano wizytę w localStorage');
        }

        function loadVisit() {
            const raw = localStorage.getItem('lastVisit');
            if (raw) {
                const data = JSON.parse(raw);
                document.getElementById('storage-info').textContent = 'Zone: ' + data.zone + ', Time: ' + data.time;
                log('Odczytano z localStorage');
            } else {
                document.getElementById('storage-info').textContent = 'Brak zapisanych danych';
            }
        }

        log('Panel gotowy');
    </script>
</body>
</html>

Hasła z dokumentacji w tej lekcji

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. Na jakie zmiany reaguje MutationObserver?

To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    W pliku index.html funkcja setupObservers() tworzy dwa obserwatory: IntersectionObserver śledzi, czy sekcja #radar jest widoczna co najmniej w połowie (threshold 0.5), a MutationObserver przelicza alerty na liście #alerts. Uzupełnij luki: ___BLANK1___ to właściwość wpisu (entry), która mówi, czy element jest teraz widoczny, a ___BLANK2___ to opcja observe(), dzięki której MutationObserver reaguje na dodanie i usunięcie dzieci listy. Status radaru ma się zmieniać razem z widocznością, a licznik #alert-count pokazywać liczbę alertów po każdym kliknięciu przycisku.

  • Układanie w pionie

    Uporządkuj kroki implementacji lazy loading obrazów z IntersectionObserver:

  • Klikanie w kolejności

    Ułóż kroki zapisu i odczytu danych z localStorage:

  • Edytor kodu

    W pliku index.html są trzy funkcje: renderDinosaurs(names) dodaje dinozaury do listy #dino-list, saveConfig(config) zapisuje ustawienia w localStorage pod kluczem parkConfig, a loadConfig() je odczytuje (bez zapisu zwraca { theme: "jungle" }). Uzupełnij luki: ___BLANK1___ to metoda document, która tworzy DocumentFragment, ___BLANK2___ to wyrażenie zamieniające obiekt config na tekst JSON, a ___BLANK3___ to wyrażenie zamieniające tekst raw z powrotem na obiekt. Lista ma się uzupełniać jedną operacją appendChild z fragmentem, a zapisany obiekt ma wracać z loadConfig() bez zmian.

  • Klikanie w kolejności

    Ułóż elementy wysyłania custom event:

  • Układanie w pionie

    Ułóż elementy implementacji debounce na zdarzeniu input:

  • Układanie w pionie

    Ułóż elementy tworzenia i wysyłania custom event:

  • Układanie w pionie

    Ułóż kroki tworzenia i dispatchowania Custom Event:

Przydatne artykuły