mitmario.dev

Sichtbarkeit beobachten

JavaScript Im Browser 4 Min Lesezeit 3 BeispieleLektion 3 von 6

Eine Aufgabe, die ständig vorkommt: Etwas soll passieren, sobald ein Element ins Bild kommt. Ein Bild nachladen, eine Animation starten, die nächste Seite einer Liste holen.

Warum nicht mit einem Scroll-Listener

Der teure Weg mit einem Scroll-Listener
const fenster = document.getElementById("fenster");
const zaehlung = document.getElementById("zaehlung");
const abschnitte = document.querySelectorAll(".abschnitt");

let laeufe = 0;
let rechnungen = 0;

fenster.addEventListener(
  "scroll",
  () => {
    laeufe = laeufe + 1;
    const kasten = fenster.getBoundingClientRect();

    for (const abschnitt of abschnitte) {
      rechnungen = rechnungen + 1;
      const eigener = abschnitt.getBoundingClientRect();
      const sichtbar = eigener.bottom > kasten.top && eigener.top < kasten.bottom;
      abschnitt.classList.toggle("ist-sichtbar", sichtbar);
    }

    zaehlung.textContent = `Handler-Läufe: ${laeufe}, Rechnungen: ${rechnungen}`;
  },
  { passive: true }
);

Der naheliegende Weg ist ein Listener auf scroll, der bei jedem Auslösen nachrechnet, wo die Elemente gerade liegen.

Er funktioniert, und er ist genau die Art Code, die eine Seite ruckeln lässt.

scroll feuert Dutzende Male pro Sekunde. In jedem Lauf rufst du für jedes beobachtete Element getBoundingClientRect auf, und jeder dieser Aufrufe zwingt den Browser, das Layout auf dem neuesten Stand zu haben. Bei vierzig Elementen sind das bei jeder Bewegung vierzig solcher Rechnungen, und alle davon liegen zwischen dem Nutzer und dem nächsten Bild auf dem Bildschirm.

Im ersten Beispiel zählt die Ausgabe beides mit. Scroll einmal von oben nach unten und sieh dir die Zahlen an. Mit fünf Rastungen am Mausrad bin ich unten und die Ausgabe sagt Handler-Läufe: 5, Rechnungen: 20. Zieh denselben Weg langsam am Rollbalken, und es sind schnell 30 und 120. Dasselbe Ergebnis auf dem Bildschirm, sechsmal so viel Arbeit, und die Zahl hängt allein daran, wie jemand scrollt.

Der Observer

Derselbe Effekt mit einem Observer
const fenster = document.getElementById("fenster");
const zaehlung = document.getElementById("zaehlung");

let meldungen = 0;

const beobachter = new IntersectionObserver(
  (eintraege) => {
    for (const eintrag of eintraege) {
      meldungen = meldungen + 1;
      eintrag.target.classList.toggle("ist-sichtbar", eintrag.isIntersecting);
    }
    zaehlung.textContent = `Meldungen insgesamt: ${meldungen}`;
  },
  { root: fenster }
);

for (const abschnitt of document.querySelectorAll(".abschnitt")) {
  beobachter.observe(abschnitt);
}

// Die erste Meldung kommt sofort, ohne dass jemand
// gescrollt hat: der Observer berichtet auch über das,
// was schon sichtbar ist.
console.log("Beobachtung gestartet.");

Ein IntersectionObserver dreht die Sache um. Du sagst dem Browser einmal, was dich interessiert, und er meldet sich, wenn sich etwas ändert.

Er bekommt zwei Dinge: eine Funktion, die bei Änderungen läuft, und ein Objekt mit Einstellungen. Danach sagst du ihm mit observe(element), worauf er achten soll.

Die Funktion bekommt eine Liste von Einträgen, nicht einen einzelnen. Jeder Eintrag hat zwei Felder, die du brauchst: target ist das Element, und isIntersecting sagt, ob es gerade sichtbar ist.

Der Gewinn ist nicht bloß weniger Code. Der Browser weiß ohnehin, wo alles liegt, und muss dafür nichts zusätzlich ausrechnen. Deine Funktion läuft nur, wenn sich wirklich etwas geändert hat.

Scroll im zweiten Beispiel denselben Weg. Dort steht danach Meldungen insgesamt: 8, und zwar gleich, ob du gerast oder gezogen hast: vier Meldungen beim Start und vier für die vier Abschnitte, die unterwegs die Kante überqueren. Das ist der ganze Unterschied in zwei Zahlen. Die eine hängt davon ab, wie jemand scrollt, die andere davon, was sich wirklich geändert hat.

Die zwei Einstellungen

threshold und rootMargin
const fenster = document.getElementById("fenster");
const abschnitte = document.querySelectorAll(".abschnitt");

// threshold 0.5: meldet erst, wenn die Hälfte zu sehen ist.
const halb = new IntersectionObserver(
  (eintraege) => {
    for (const eintrag of eintraege) {
      eintrag.target.classList.toggle("halb", eintrag.isIntersecting);
    }
  },
  { root: fenster, threshold: 0.5 }
);

// rootMargin vergrößert den beobachteten Bereich nach
// unten. So lädt man Bilder, bevor sie zu sehen sind.
const vorher = new IntersectionObserver(
  (eintraege) => {
    for (const eintrag of eintraege) {
      eintrag.target.classList.toggle("vorgemerkt", eintrag.isIntersecting);
    }
  },
  { root: fenster, rootMargin: "0px 0px 120px 0px" }
);

for (const abschnitt of abschnitte) {
  halb.observe(abschnitt);
  vorher.observe(abschnitt);
}

root ist der Bereich, gegen den gemessen wird. Ohne Angabe ist das das Browserfenster. In den Beispielen dieser Lektion ist es der scrollbare Kasten, damit du den Effekt siehst, ohne die ganze Seite zu scrollen.

threshold sagt, wie viel vom Element zu sehen sein muss. 0 heißt „ein Pixel genügt”, 0.5 heißt „die Hälfte”, 1 heißt „vollständig”. Du kannst auch eine Liste angeben und bekommst dann bei jeder dieser Schwellen eine Meldung.

rootMargin vergrößert oder verkleinert den beobachteten Bereich, angegeben wie ein CSS-Abstand. 0px 0px 120px 0px beobachtet 120 Pixel weiter nach unten, als tatsächlich zu sehen ist. Genau so lädt man Bilder, bevor sie ins Bild kommen: Sie sind dann schon da, wenn der Nutzer sie erreicht.

Beides kannst du im dritten Beispiel in der Eingabezeile der Console nachfragen: halb.thresholds antwortet mit [0.5] und vorher.rootMargin mit 0px 0px 120px 0px. Nützlicher ist der Unterschied in Farben, und der braucht einen Hinweis: Beim Laden markieren beide Observer dieselben zwei Abschnitte. Der grüne Grund kommt nur an den ersten, weil nur der zur Hälfte zu sehen ist; der orange Rahmen liegt auf den ersten zwei, und die wären ohne rootMargin genauso dran. Der Vorsprung zeigt sich erst beim Scrollen, und er ist groß: Der dritte Abschnitt bekommt seinen orangen Rahmen nach 40 gescrollten Pixeln, seinen grünen Grund nach 240. Zweihundert Pixel Vorsprung, und genau die sind bei einem Bild die Zeit, in der es geladen werden kann, bevor jemand hinsieht. Scroll langsam, sonst passiert beides im selben Augenblick.

Der Punkt, an dem alle stolpern

Der Observer meldet sich sofort nach observe, ohne dass jemand gescrollt hat.

Das ist Absicht: Er berichtet über den aktuellen Zustand, und dazu gehört auch das, was schon sichtbar ist. Wer darin eine Animation startet, sieht sie beim Laden für alles gleichzeitig loslaufen.

Im zweiten Beispiel siehst du es, ohne etwas zu tun: Noch bevor du scrollst, steht über dem Kasten Meldungen insgesamt: 4, und zwei Abschnitte sind schon grün. Vier Meldungen für vier beobachtete Abschnitte, zwei davon mit isIntersecting: true und zwei mit false. Wer nur auf die Farbe schaut, hält das für „zwei Meldungen”.

Wenn dich nur die Änderung interessiert, prüfst du isIntersecting und tust bei false schlicht nichts. Und wenn etwas nur einmal passieren soll, hörst du danach auf zu beobachten.

Aufräumen, wie überall in diesem Kurs

observer.unobserve(element) beendet die Beobachtung eines einzelnen Elements. Das ist der übliche Weg für alles, was genau einmal passieren soll: Bild geladen, Animation gestartet, fertig.

observer.disconnect() beendet alles auf einmal. Das gehört in die Abbaumethode einer Komponente, genau wie das removeEventListener aus Lektion 18.6.

Ein Observer, der weiterläuft, hält seine Elemente fest, und die Elemente halten alles, was an ihnen hängt. Es fällt nicht sofort auf, sondern nach zwanzig Minuten Benutzung, und dann sucht niemand mehr hier.

Zum Mitnehmen

Ein Scroll-Listener feuert Dutzende Male pro Sekunde. Ein IntersectionObserver meldet sich nur, wenn sich wirklich etwas geändert hat.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe im Editor, direkt im Browser geprüft

    Öffnet sich mit dem Basis Konto.