12k
All articles

So implementieren Sie Infinite Scroll in Vanilla JavaScript

Implementieren Sie Infinite Scroll in Vanilla JavaScript mit Intersection Observer, Sentinel-Element, Pagination, Lade-Guards und Zugänglichkeits-Fallbacks.

OpenReplay Team
OpenReplay Team
So implementieren Sie Infinite Scroll in Vanilla JavaScript

Implementieren Sie Infinite Scroll in Vanilla JavaScript mit der Intersection Observer API: Platzieren Sie ein Sentinel-Element am Ende Ihrer Liste, beobachten Sie es und laden Sie jedes Mal die nächste Datenseite nach, wenn es in den Viewport gelangt.

Wer so etwas schon einmal gebaut hat, kennt den Fehlerfall: Man zieht die Scrollbar ein bisschen zu kräftig und dieselben zehn Einträge landen dreifach in der Liste. Die Grundmechanik zum Laufen zu bringen dauert etwa zehn Minuten – sie so robust zu machen, dass sie einen echten Nutzer übersteht, kostet den restlichen Nachmittag. Dieser Ansatz ersetzt das alte Verfahren aus scroll-Event plus getBoundingClientRect, das bei jedem Scroll-Tick Positionsberechnungen anstellt. Dieser Leitfaden baut einen vollständigen, ausführbaren Feed mit echtem fetch, Pagination und DOM-Append und behandelt anschließend die vier Produktions-Stolperfallen (doppelte Requests, fehlendes Ende, Fehlerbehandlung und Prefetch-Timing) sowie die Accessibility-Fallbacks, die eine Demo von auslieferbarem Code unterscheiden.

Die wichtigsten Erkenntnisse

  • Verwenden Sie IntersectionObserver statt Scroll-Events: Ein Scroll-Listener feuert kontinuierlich im Main Thread und erzwingt manuelle Positionsberechnungen, während der Observer einen Callback nur dann ausführt, wenn das Ziel tatsächlich den Viewport überschreitet.
  • IntersectionObserver ist seit März 2019 in allen modernen Browsern Baseline, Infinite Scroll benötigt heute also keinen Polyfill.
  • Sichern Sie jeden Fetch mit einem Boolean-Flag ab, damit ein schnelles Scrollen nicht mehrere überlappende Requests auslöst, bevor der erste abgeschlossen ist.
  • Stoppen Sie, wenn die API eine kurze oder leere Seite zurückgibt. Rufen Sie observer.disconnect() auf und blenden Sie das Sentinel aus – andernfalls fordert der Observer weiterhin Seiten an, die nicht mehr existieren.
  • Kombinieren Sie Infinite Scroll mit einem sichtbaren „Mehr laden“-Button: Er ist Fallback für Tastatur, Screenreader und deaktiviertes JavaScript in einem.

Warum ist IntersectionObserver besser als Scroll-Events?

Verwenden Sie IntersectionObserver anstelle eines scroll-Listeners, weil er Sichtbarkeit asynchron über einen Callback meldet, der nur dann feuert, wenn Ihr Ziel den Viewport überschreitet – statt in jedem Scroll-Frame zu laufen. Das alte Muster hängt einen scroll-Handler an und ruft bei jedem Tick getBoundingClientRect() auf, um zu berechnen, ob das Listenende in der Nähe ist. Das sind layoutlesende Berechnungen im Main Thread, die weitaus häufiger ausgeführt werden als nötig, und sie sind eine bekannte Ursache für Scroll-Jank.

scroll + getBoundingClientRect()IntersectionObserver
FeuertIn jedem Scroll-FrameNur wenn das Ziel den Viewport überschreitet
PositionsberechnungManuell, in Ihrem CodeVom Browser übernommen
ThreadingSynchron im Main ThreadAsynchron ausgeliefert
Polyfill nötigNein (Baseline)

Ein Polyfill ist nicht erforderlich. MDN kennzeichnet die API als „Baseline Widely available“ mit Unterstützung in allen wichtigen Browsern zurück bis März 2019. Älteren Ratschlägen, die einen Polyfill empfehlen (und sich auf Chrome-51-Support beziehen), ist somit überholt. Eine Ausnahme: Greifen Sie nicht standardmäßig zu trackVisibility, denn MDN führt diese Eigenschaft zur Verdeckungserkennung weiterhin als experimentell mit eingeschränkter Verfügbarkeit.

Was ist das Sentinel-Muster?

Das Sentinel-Muster platziert ein einzelnes Markerelement am Ende der Liste; sobald der Observer meldet, dass das Sentinel in den Viewport gelangt ist, laden Sie die nächste Seite und hängen sie an. Das Sentinel ist lediglich ein leeres Element hinter Ihrem letzten Eintrag, und Sie müssen es niemals neu auswählen, denn das Anhängen neuer Einträge schiebt es immer weiter nach unten.

Die drei beweglichen Teile:

  1. Observer erzeugen: new IntersectionObserver(callback, options).
  2. Beobachtung starten: observer.observe(sentinel).
  3. Im Callback entry.isIntersecting prüfen und die nächste Seite laden, wenn der Wert true ist.

Iterieren Sie über das entries-Array, statt entries[0] zu lesen. Die Referenz zum IntersectionObserver()-Konstruktor warnt davor, eine bestimmte Anzahl von Einträgen anzunehmen, denn ein einzelner Durchlauf Ihres Callbacks kann mehrere Überschreitungen gleichzeitig transportieren.

Ein vollständiges Infinite-Scroll-Beispiel in Vanilla JavaScript

Nachfolgend eine vollständige, funktionsfähige Implementierung gegen JSONPlaceholder, eine kostenlose Mock-REST-API, die auf JSON Server mit LowDB im Hintergrund läuft. Ihr /posts-Endpunkt enthält 100 Datensätze und akzeptiert die Query-Parameter _page und _limit, wobei der angeforderte Ausschnitt als einfaches Array zurückgegeben wird. Damit steht ein endlicher Datensatz bereit – praktisch, um zu demonstrieren, was passiert, wenn die Daten ausgehen.

Das Markup: eine Liste, ein Fallback-Button, ein Sentinel und eine Statuszeile als Live-Region.

<main>
  <ul id="list" aria-label="Posts"></ul>
  <button id="load-more" type="button">Load more</button>
  <div id="sentinel" aria-hidden="true"></div>
  <p id="status" role="status" aria-live="polite"></p>
</main>

Das Skript verbindet den Observer mit dem Sentinel und lädt pro Intersection eine Seite:

const LIMIT = 10;
let page = 1;
let loading = false;   // guard against overlapping requests
let done = false;      // stop at end of data

const list = document.getElementById("list");
const sentinel = document.getElementById("sentinel");
const loadMoreBtn = document.getElementById("load-more");
const status = document.getElementById("status");

async function fetchPosts(page) {
  const url = `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${LIMIT}`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

function render(posts) {
  const frag = document.createDocumentFragment();
  for (const post of posts) {
    const li = document.createElement("li");
    li.innerHTML = `<h2>${post.title}</h2><p>${post.body}</p>`;
    frag.appendChild(li);
  }
  list.appendChild(frag);
}

async function loadNextPage() {
  if (loading || done) return;
  loading = true;
  status.textContent = "Loading…";
  try {
    const posts = await fetchPosts(page);
    render(posts);
    page += 1;
    if (posts.length < LIMIT) {   // short/empty page = no more data
      done = true;
      observer.disconnect();
      loadMoreBtn.hidden = true;
      status.textContent = "You've reached the end.";
    } else {
      status.textContent = "";
    }
  } catch (err) {
    status.textContent = "Could not load posts. Tap Load more to retry.";
    console.error(err);
  } finally {
    loading = false;
  }
}

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) loadNextPage();
    }
  },
  { root: null, rootMargin: "200px", threshold: 0 }
);

observer.observe(sentinel);
loadMoreBtn.addEventListener("click", loadNextPage);
document.addEventListener("DOMContentLoaded", loadNextPage);

Jeder Request läuft über loadNextPage, sodass der Observer-Callback, der Button-Klick und das initiale Laden bei DOMContentLoaded dieselbe Guard- und Stop-Logik nutzen.

Die vier Stolperfallen, die eine Demo von auslieferbarem Code trennen

Die meisten Tutorials hören bei „es hängt Daten an“ auf. Diese vier Korrekturen sind es, die den Code echte Nutzer überstehen lassen.

SymptomUrsacheLösung
Doppelte Requests bei schnellem ScrollenKein Request-GuardBoolean-Flag if (loading) return;
Liste hört nie auf, fordert leere Seiten erneut anKeine Ende-Erkennungif (posts.length < LIMIT) observer.disconnect()
Fehler verschwinden stillschweigendKein Fehlerpfad für fetchres.ok prüfen, try/catch, Retry anzeigen
Sichtbare Pause am ListenenderootMargin: "0px"rootMargin: "200px", um früh vorzuladen

Gegen doppeltes Nachladen absichern. Eine schnelle Scrollbewegung kann den Callback mehrfach auslösen, bevor das erste await aufgelöst ist. Das loading-Flag lässt jeden zusätzlichen Aufruf früh zurückkehren, bis der laufende Request im finally-Block abgeschlossen ist. Alternativ können Sie das Sentinel während des Requests mit unobserve aus der Beobachtung nehmen und anschließend wieder beobachten. Verwechseln Sie dabei nur nicht unobserve (ein Ziel) mit disconnect (alle Ziele).

Am Ende der Daten stoppen. Bei einer endlichen Quelle fordern Sie bei fortgesetztem Nachladen Seiten an, die nicht existieren. Erkennen Sie eine Seite, die kürzer als LIMIT ist – dasselbe Ende-der-Daten-Signal, das auch Prismatics Tutorial zur Schleife über paginierte APIs nutzt, das seine Schleife verlässt, sobald ein Request ein leeres Array zurückgibt. Rufen Sie dann disconnect() auf und blenden Sie Sentinel und Button aus.

threshold: 0 in Kombination mit rootMargin bevorzugen. Die Einstellung rootMargin: "200px" startet den nächsten Fetch etwa 200 Pixel bevor der Nutzer das Ende erreicht und beseitigt damit den sichtbaren Stillstand. Kombinieren Sie das mit threshold: 0, nicht mit 1.0: Ein Sentinel, das höher als der Viewport ist, wird möglicherweise nie zu 100 % sichtbar, sodass ein Schwellwert für vollständige Sichtbarkeit stillschweigend nie feuert.

Infinite-Scroll-Bugs hängen von Timing und Scroll-Geschwindigkeit ab, weshalb sorgfältiges lokales Scrollen sie selten reproduziert. Echte Sessions mit einem Tool wie Session Replay zu beobachten, ist eine Möglichkeit, genau jene Fehlerklasse sichtbar zu machen, die bei einem schnellen Test unentdeckt bleibt: doppelte Requests bei einer schnellen Scrollbewegung oder eine Liste, die nie aufhört.

Accessibility und der „Mehr laden“-Fallback

Kombinieren Sie Infinite Scroll immer mit einem sichtbaren „Mehr laden“-Button: Er ist der Fallback für Tastatur und Screenreader, der Fallback ohne JavaScript und oft die einzige Möglichkeit für Nutzer, den Datenstrom zu unterbrechen, um den Footer zu erreichen. Endlos automatisch nachladende Inhalte schließen Nutzer assistiver Technologien ein, begraben Footer-Links hinter immer weiter wachsenden Inhalten und brechen die Scroll-Wiederherstellung des Zurück-Buttons, wenn der Nutzer an eine Position zurückkehrt, die im DOM nicht mehr existiert.

Drei konkrete Schritte, alle im obigen Code enthalten:

  • Den Ladezustand über eine Live-Region ankündigen: <p role="status" aria-live="polite"> lässt Screenreader „Loading…“ und „You’ve reached the end.“ vorlesen.
  • Den Button als echtes, fokussierbares Steuerelement beibehalten, damit er funktioniert, wenn der Observer nie feuert oder JavaScript deaktiviert ist.
  • Das Sentinel mit aria-hidden="true" auszeichnen. Es ist ein Mechanismus, kein Inhalt, und sollte nicht in den Accessibility-Tree gelangen.

Wenn der Footer eines Feeds wirklich wichtig ist (Kontaktlinks, Rechtliches, Pagination für Deep Links), sollten Sie prüfen, ob ein „Mehr laden“-Button allein das bessere Muster ist, und automatisches Nachladen für Inhalte reservieren, bei denen ein endloser Strom der eigentliche Zweck ist.

Infinite Scroll in Vanilla JavaScript lässt sich auf eine tragfähige Idee reduzieren: ein Sentinel beobachten, bei Intersection nachladen und die Randfälle behandeln. Nehmen Sie die vollständige Datei von oben, richten Sie fetchPosts auf Ihren eigenen paginierten Endpunkt aus und stellen Sie sicher, dass sowohl der Guard als auch der Stop-am-Ende-Pfad feuern, bevor Sie ausliefern. Diese zwei Zeilen machen aus einer funktionierenden Demo Code, dem Sie in der Produktion vertrauen können.

FAQs

Was ist der Unterschied zwischen unobserve und disconnect bei einem IntersectionObserver?

Rufen Sie unobserve auf, wenn der Observer ein bestimmtes Element fallen lassen und mit den übrigen weiterarbeiten soll, und disconnect, wenn er alles loslassen soll, was er gerade beobachtet. Für Infinite Scroll bedeutet das: unobserve, um das einzelne Sentinel zu pausieren, während ein Request läuft, und disconnect, sobald die Daten ausgehen und der Observer keine weitere Aufgabe hat.

Wann sollte ich Pagination oder einen Mehr-laden-Button anstelle von Infinite Scroll verwenden?

Entscheiden Sie sich für Pagination oder einen Mehr-laden-Button, wenn der Footer wichtig ist, etwa bei Kontaktlinks, Rechtstexten oder Deep-Link-Pagination, denn endloses automatisches Nachladen schiebt Footer-Inhalte dauerhaft außer Reichweite und schließt Tastatur- und Screenreader-Nutzer ein. Infinite Scroll passt zu offenen Inhalten, bei denen ein endloser Strom der Zweck ist, etwa Social Feeds. Wenn Nutzer einen Haltepunkt brauchen oder das Ende erreichen müssen, ist ein explizites Steuerelement das bessere Muster.

Warum löst ein threshold von 1.0 Infinite Scroll manchmal nicht aus?

Ein threshold von 1.0 verlangt, dass das beobachtete Element zu 100 Prozent sichtbar ist, bevor der Callback feuert. Ein Sentinel, das höher als der Viewport ist, kann daher niemals vollständig ins Blickfeld gelangen, und der Callback läuft stillschweigend nie. Verwenden Sie stattdessen threshold 0 in Kombination mit einem rootMargin-Puffer: Der Callback feuert dann, sobald irgendein Teil des Sentinels die erweiterte Root-Grenze überschreitet – der zuverlässigere Standard für Infinite Scroll.

Muss ich im IntersectionObserver-Callback mehrere Einträge behandeln?

Ja. Die Konstruktor-Referenz von MDN weist darauf hin, sich nicht auf eine bestimmte Länge des entries-Arrays zu verlassen, da ein einzelner Durchlauf Ihres Callbacks mehr als eine Überschreitung transportieren kann. Bei einem einzelnen Sentinel funktioniert entries[0] in der Praxis oft, aber über alle Einträge zu iterieren und jeweils isIntersecting zu prüfen, ist der korrekte Ansatz und verhindert verpasste oder falsch zugeordnete Intersections, wenn mehrere Ziele gleichzeitig melden.

Bricht Infinite Scroll den Zurück-Button des Browsers?

Ja, Infinite Scroll kann die Scroll-Wiederherstellung des Zurück-Buttons brechen, weil der Browser versucht, den Nutzer zu einer Scroll-Position zurückzubringen, die im DOM nicht mehr existiert, nachdem dynamisch geladene Inhalte bei der Navigation verworfen wurden. Der Nutzer landet an der falschen Stelle oder am Anfang der Liste. Als Gegenmaßnahmen kommen das Persistieren des geladenen Zustands, das manuelle Wiederherstellen der Scroll-Position oder ein Mehr-laden-Button infrage, sodass die Navigation auf einen stabilen, reproduzierbaren Zustand abbildet.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.