x-data
Alpine
Alpine.js · Performance · Virtual Scroll · DOM-Optimierung
Virtuelles Scrollen mit Alpine.js
10.000 Einträge ohne Performance-Probleme

10.000 DOM-Elemente gleichzeitig zu rendern lässt jeden Browser ins Stocken geraten. Virtuelles Scrollen löst das Problem: Nur die sichtbaren Elemente existieren im DOM – alle anderen werden durch Platzhalter mit korrekter Höhe simuliert. Das Ergebnis ist eine flüssige Scrollbar ohne Performance-Einbußen, realisierbar mit Alpine.js und dem nativen IntersectionObserver.

16 Min. Lesezeit IntersectionObserver · Windowing · Sentinel-Elemente · x-for Alpine.js 3.x · Vanilla JS

1. Das DOM-Performance-Problem bei großen Listen

Der Browser rendert eine Liste mit 10.000 Einträgen nicht in einem Schritt – er legt für jedes Element ein DOM-Node an, berechnet Layout und Style, und hält alle Elemente im Speicher, auch wenn sie außerhalb des sichtbaren Bereichs liegen. Bei einfachen Textelementen ist das noch tolerierbar, aber sobald jedes Element Bilder, Buttons und mehrere verschachtelte Divs enthält, summiert sich der Overhead drastisch. Layouts mit großen Produktlisten, Bestellhistorien oder Log-Viewern erreichen schnell den Punkt, wo das Scrollen ruckelt und der Browser-Thread blockiert.

Das Kernproblem heißt Layout Thrashing: Der Browser muss für jedes sichtbare Element die Position kennen, und wenn Elemente außerhalb des sichtbaren Bereichs Teil des gleichen Layout-Kontexts sind, müssen sie alle berechnet werden. 10.000 Elements mit je einer Höhe von 60px erzeugen einen scrollbaren Bereich von 600.000px – der Browser muss diese gesamte Höhe kennen und alle Zwischenpositionen beim Scrollen neu berechnen. Virtuelles Scrollen entkoppelt die tatsächliche DOM-Größe von der wahrgenommenen Größe der Liste.

Ein pragmatischer Messwert: Eine Produktliste mit 50 sichtbaren Einträgen hat denselben Scroll-Komfort wie eine mit 10.000 Einträgen – wenn virtuelles Scrollen korrekt implementiert ist. Der Speicherbedarf wächst nicht mehr linear mit der Listenlänge, sondern bleibt konstant auf der Fenstergröße. Gerade für mobile Geräte mit wenig RAM und langsamem JS-Thread macht das den Unterschied zwischen einer nutzbaren und einer nicht nutzbaren Oberfläche.

2. Das Windowing-Konzept: Nur Sichtbares rendern

Windowing – auch Virtualisierung genannt – ist das Prinzip, nur die im Viewport sichtbaren Listenelemente tatsächlich im DOM zu halten. Alle unsichtbaren Elemente werden durch einen einzelnen Platzhalter ersetzt, dessen Höhe der Summe der nicht-gerenderten Elemente entspricht. Der Benutzer sieht eine normale Scrollbar, weil der Container die korrekte Gesamthöhe hat – aber tatsächlich existieren zu jedem Zeitpunkt nur 20–40 DOM-Elemente, unabhängig von der Gesamtlänge der Liste.

Der Algorithmus berechnet laufend zwei Indizes: startIndex – das erste zu rendernde Element – und endIndex – das letzte. Alles vor startIndex wird durch einen Top-Spacer mit Höhe startIndex * itemHeight ersetzt, alles nach endIndex durch einen Bottom-Spacer mit der Höhe der verbleibenden Elemente. Das sichtbare Fenster ist typischerweise etwas größer als der Viewport – man rendert ein Overscan von 3–5 Elementen über und unter dem sichtbaren Bereich, um beim Scrollen keine Lücken zu zeigen.


// Virtual scroll state model — core algorithm
function virtualScroll() {
  return {
    allItems: [],         // Full dataset — never rendered all at once
    itemHeight: 64,       // Fixed row height in pixels
    overscan: 5,          // Extra items above/below viewport
    scrollTop: 0,
    containerHeight: 600, // Visible area height

    get startIndex() {
      return Math.max(0, Math.floor(this.scrollTop / this.itemHeight) - this.overscan);
    },
    get endIndex() {
      const visible = Math.ceil(this.containerHeight / this.itemHeight);
      return Math.min(this.allItems.length - 1, this.startIndex + visible + this.overscan * 2);
    },
    get visibleItems() {
      return this.allItems.slice(this.startIndex, this.endIndex + 1);
    },
    get topSpacerHeight() {
      return this.startIndex * this.itemHeight;
    },
    get bottomSpacerHeight() {
      return Math.max(0, (this.allItems.length - this.endIndex - 1) * this.itemHeight);
    },
    get totalHeight() {
      return this.allItems.length * this.itemHeight;
    }
  };
}

3. IntersectionObserver und Sentinel-Elemente als Scroll-Trigger

Der klassische Ansatz für virtuelles Scrollen verwendet den scroll-Event des Containers. Das Problem: Der Scroll-Event feuert extrem häufig – bei 60fps-Scrollen sind das 60 Events pro Sekunde, jeder mit einem DOM-Layout-Zugriff über scrollTop. Selbst mit Debouncing entstehen sichtbare Verzögerungen. Der modernere Ansatz verwendet den IntersectionObserver mit Sentinel-Elementen: unsichtbare Marker-Divs am Anfang und Ende der sichtbaren Elemente, die der Observer beobachtet.

Wenn ein Sentinel-Element in den Viewport eintritt, bedeutet das: Der Nutzer hat bis zur Grenze des aktuell gerenderten Bereichs gescrollt, und neue Elemente müssen geladen werden. Das ist konzeptuell sauber und performant: Der Observer läuft auf einem separaten Thread und benötigt keinen Zugriff auf scrollTop. Die Granularität ist geringer als beim Scroll-Event, aber für Infinite-Scroll und page-boundary-basiertes Nachladen von Blöcken ist es die sauberere Lösung.

4. Platzhalter-Technik: Korrekte Scrollbar-Höhe simulieren

Die Scrollbar-Höhe eines Elements ist proportional zum Verhältnis von Viewport-Höhe zu Gesamthöhe des Inhalts. Damit die Scrollbar korrekt die gesamte Listenlänge repräsentiert, muss der Container-Inhalt die Gesamthöhe aller Elemente haben – auch wenn nur ein Bruchteil davon gerendert ist. Das wird über zwei Spacer-Divs mit berechneten Höhen erreicht: Ein Top-Spacer vor den sichtbaren Elementen und ein Bottom-Spacer dahinter, beide mit expliziter Pixel-Höhe.

Der kritische Moment ist das Aktualisieren dieser Spacer beim Scrollen. Da Alpine reaktiv ist, genügt das Aktualisieren von scrollTop – Alpine berechnet topSpacerHeight, bottomSpacerHeight und visibleItems als computed properties automatisch neu und rendert das Template. Das setzt allerdings voraus, dass der Scroll-Event die Alpine-Daten korrekt aktualisiert. Mit @scroll.passive="scrollTop = $event.target.scrollTop" auf dem Container-Element geschieht das effizient ohne das Scrolling zu blockieren.


<!-- Virtual scroll container with Alpine -->
<div
  x-data="virtualScroll()"
  x-init="
    // Generate 10000 demo items
    allItems = Array.from({ length: 10000 }, (_, i) => ({
      id: i + 1,
      name: 'Eintrag #' + (i + 1),
      sku: 'SKU-' + String(i + 1).padStart(5, '0'),
      price: (Math.random() * 200 + 5).toFixed(2)
    }));
    // Measure container height after render
    $nextTick(() => {
      containerHeight = $el.clientHeight;
    });
  "
  class="overflow-y-auto border border-slate-200 rounded-xl"
  style="height: 600px;"
  @scroll.passive="scrollTop = $event.target.scrollTop"
>
  <!-- Inner container with total virtual height -->
  <div :style="'height:' + totalHeight + 'px; position: relative;'">
    <!-- Top spacer -->
    <div :style="'height:' + topSpacerHeight + 'px;'"></div>

    <!-- Only visible items rendered -->
    <template x-for="item in visibleItems" :key="item.id">
      <div class="flex items-center px-4 border-b border-slate-100" :style="'height:' + itemHeight + 'px;'">
        <span class="w-16 text-slate-400 text-xs font-mono" x-text="item.id"></span>
        <span class="flex-1 font-medium text-slate-800" x-text="item.name"></span>
        <span class="w-32 text-slate-500 text-sm font-mono" x-text="item.sku"></span>
        <span class="w-20 text-right font-semibold text-teal-700" x-text="'€ ' + item.price"></span>
      </div>
    </template>

    <!-- Bottom spacer — no explicit div needed, inner height covers it -->
  </div>
</div>

5. Das sichtbare Fenster: startIndex und endIndex berechnen

Die Berechnung von startIndex und endIndex ist das Herzstück des Windowing-Algorithmus. startIndex = Math.floor(scrollTop / itemHeight) - overscan berechnet das erste zu rendernde Element: Wie viele vollständige Zeilenhöhen passen in den aktuellen Scroll-Offset? Das Ergebnis minus dem Overscan ist der erste sichtbare Index. Der Overscan von 5–10 Elementen stellt sicher, dass beim Scrollen keine weißen Bereiche sichtbar werden, bevor Alpine das DOM aktualisiert hat.

endIndex = startIndex + Math.ceil(containerHeight / itemHeight) + overscan * 2 addiert die Anzahl sichtbarer Zeilen plus doppelten Overscan auf den Startindex. Beide Indizes müssen mit Math.max(0, …) und Math.min(allItems.length - 1, …) eingegrenzt werden, um Array-Overflows zu verhindern. Bei Fixed-Height-Items ist die Berechnung O(1) und extrem schnell. Bei variablen Höhen muss eine Position-Map aufgebaut werden – dazu mehr in Abschnitt 7.

6. Alpine-Integration: x-for mit berechneter Teilmenge

Alpine's x-for iteriert über visibleItems – die berechnete Teilmenge des Gesamt-Arrays. Das ist direkt möglich, weil visibleItems als Alpine computed getter definiert ist und bei jeder Änderung von scrollTop, startIndex oder allItems automatisch neu berechnet wird. Alpine's Diffing-Algorithmus aktualisiert dann nur die DOM-Elemente, die sich tatsächlich geändert haben – bei kontinuierlichem Scrollen sind das meist nur 1–2 neue Elemente die ein- und ausgeblendet werden.

Der :key-Binding auf item.id ist für virtuelles Scrollen besonders wichtig. Ohne korrekte Keys würde Alpine bei jedem Scroll-Schritt alle sichtbaren Elemente neu rendern, weil es die Identität der Items nicht verfolgen kann. Mit :key="item.id" erkennt Alpine dass sich z.B. nur das erste und letzte Element der sichtbaren Menge geändert haben, und patcht nur diese beiden DOM-Knoten. Das reduziert den Render-Overhead bei schnellem Scrollen drastisch.

7. Variable Zeilenhöhen: ResizeObserver und dynamisches Messen

Die einfachste Form des virtuellen Scrollens setzt feste, gleichmäßige Zeilenhöhen voraus. In der Praxis haben Listen oft variable Höhen: Produkt-Kacheln mit unterschiedlicher Bildgröße, Kommentare mit variablem Text, Bestellzeilen mit mehreren Produkten. Für variable Höhen muss der Algorithmus eine Position-Map aufbauen: Ein Array das für jeden Index die kumulative Höhe aller vorherigen Elemente speichert.

Das Aufbauen dieser Map ist initial teuer – bei 10.000 Elementen müssen 10.000 Höhen bekannt sein, bevor irgendetwas gerendert wird. Die pragmatische Lösung: Alle Items mit einer Schätzungshöhe initialisieren (z.B. 60px), die ersten sichtbaren Elemente rendern, und ihre tatsächliche Höhe mit ResizeObserver oder nach dem Rendern mit getBoundingClientRect() messen. Die Position-Map wird dann dynamisch korrigiert, was kleine Sprünge in der Scrollbar-Position erzeugen kann – ein bekannter Trade-off bei virtual scroll mit variablen Höhen, der in nahezu allen Bibliotheken wie TanStack Virtual so gehandhabt wird.


// Scroll-to-index: programmatic navigation in virtual scroll
function virtualScrollWithJump() {
  return {
    // ... base properties from previous example

    scrollToIndex(index) {
      const targetScrollTop = index * this.itemHeight;
      this.$el.scrollTop = targetScrollTop;
      this.scrollTop = targetScrollTop;
    },

    scrollToItem(id) {
      const index = this.allItems.findIndex(item => item.id === id);
      if (index !== -1) this.scrollToIndex(index);
    },

    // Infinite load: append new items when nearing bottom
    get isNearBottom() {
      return this.scrollTop + this.containerHeight >= this.totalHeight - this.itemHeight * 10;
    },

    async loadMore() {
      if (this.isLoading || !this.hasMore) return;
      this.isLoading = true;
      const newItems = await fetch('/api/items?offset=' + this.allItems.length + '&limit=50').then(r => r.json());
      this.allItems = [...this.allItems, ...newItems.items];
      this.hasMore = newItems.hasMore;
      this.isLoading = false;
    },

    isLoading: false,
    hasMore: true
  };
}

8. Suche und Filter bei virtuellen Listen

Suche und Filterung in virtuell scrollenden Listen erfordern besondere Aufmerksamkeit: Das gefilterte Resultat-Array ersetzt das vollständige Array als Basis für den Windowing-Algorithmus. Wenn der Benutzer einen Suchbegriff eingibt, wird filteredItems als computed property neu berechnet, und visibleItems schneidet aus filteredItems das sichtbare Fenster aus. Der Scroll-Offset muss beim Filter-Wechsel auf 0 zurückgesetzt werden – sonst zeigt das Fenster möglicherweise einen leeren Bereich, wenn das gefilterte Ergebnis kürzer als der aktuelle Scroll-Offset ist.

Bei großen Datensätzen sollte die Filter-Funktion nicht bei jedem Tastendruck ausgeführt werden. Ein Debounce von 150–300ms auf dem Suchfeld verhindert, dass bei schnellem Tippen 10.000 Elemente pro Keystroke gefiltert werden. Alpine bietet dafür keinen eingebauten Debounce, aber ein einfaches clearTimeout(this._debounce); this._debounce = setTimeout(() => this.applyFilter(), 200) in der Methode reicht aus. Für besonders große Datensätze empfiehlt sich das Filtern in einem Web Worker, um den Hauptthread nicht zu blockieren.

9. Vergleich: Virtual Scroll vs. Pagination vs. Infinite Scroll

Die drei gängigen Lösungen für große Datensätze haben grundlegend verschiedene UX-Charakteristiken und technische Anforderungen. Pagination teilt Daten in feste Seiten auf – einfach zu implementieren, aber der Nutzer verliert beim Seitenumbruch den Kontext und kann nicht kontinuierlich scrollen. Infinite Scroll lädt neue Daten beim Erreichen des Seitenbodens nach – fühlt sich fließend an, hat aber das Problem der riesigen DOM-Größe nach vielen Ladeoperationen. Virtuelles Scrollen hält die DOM-Größe konstant, erfordert aber bekannte oder messbare Datensatzgröße und feste oder messbare Elementhöhen.

Ansatz DOM-Größe UX-Kontinuität Implementierung
Pagination Minimal (1 Seite) Kontextverlust bei Wechsel Sehr einfach
Infinite Scroll Wächst unbegrenzt Fließend Mittel
Virtual Scroll (fixed height) Konstant (~20–40 DOM-Nodes) Vollständig scrollbar Mittel-komplex
Virtual Scroll (variable height) Konstant Vollständig scrollbar Komplex (Position-Map)
Hybrid: Virtual + Infinite Load Konstant Fließend + komplett Mittel-komplex

Für Magento-Produktlisten mit bekannter Datensatzgröße ist virtuelles Scrollen mit fester Zeilenhöhe die sauberste Lösung: konstante DOM-Größe, vollständige Scroll-Navigation und keine Seitenwechsel. Pagination bleibt die bessere Wahl für Suchmaschinen-Indexierung, weil Produkte auf paginierten URLs auffindbar sind. Der Hybrid-Ansatz – virtuelles Scrollen als Frontend-Fenster über ein Infinite-Load-Backend – kombiniert die Performance-Vorteile beider Welten und eignet sich für große Kataloge ohne vorab bekannte Gesamtgröße.

Mironsoft

Performance-Optimierung für Magento-Shops und Alpine.js-Frontends

Große Datenlisten performant darstellen?

Wir implementieren virtuelles Scrollen, Lazy Loading und Pagination-Strategien für Magento-Produktlisten, Admin-Grids und Bestellhistorien – ohne externe Bibliotheken, direkt mit Alpine.js.

Performance-Analyse

DOM-Größe und Layout-Bottlenecks in Produktlisten und Admin-Grids messen und beheben

Virtual Scroll

Windowing-Implementierung für Produktkataloge mit festen und variablen Elementhöhen

Infinite Load

Backend-Pagination mit IntersectionObserver-Trigger und Alpine-State für flüssiges Nachladen

10. Zusammenfassung

Virtuelles Scrollen mit Alpine.js löst das DOM-Performance-Problem bei großen Listen ohne externe Bibliothek. Der Algorithmus berechnet startIndex und endIndex aus dem aktuellen scrollTop-Wert und der Zeilenhöhe, und x-for rendert nur die visibleItems-Teilmenge. Zwei Spacer-Divs mit berechneten Höhen simulieren die korrekte Scrollbar-Position für die Gesamtliste. Der Scroll-Event aktualisiert scrollTop, Alpine berechnet alle abhängigen Werte reaktiv neu.

Die Implementierung mit festen Zeilenhöhen ist in 50 Zeilen Alpine-Code realisierbar und schlägt jede einfache x-for-Iteration bei Listen über 500 Elementen deutlich in Performance und Scroll-Komfort. Für variable Zeilenhöhen ist eine Position-Map nötig, die durch ResizeObserver dynamisch aktualisiert wird. Suche und Filter ersetzen das Basis-Array durch eine gefilterte Teilmenge und setzen den Scroll-Offset zurück. Der Hybrid-Ansatz mit Infinite Loading ermöglicht virtuelles Scrollen über Datensätze ohne vorab bekannte Größe.

Virtuelles Scrollen mit Alpine.js — Das Wichtigste auf einen Blick

Kern-Algorithmus

startIndex = floor(scrollTop / itemHeight) - overscan. endIndex = startIndex + ceil(containerHeight / itemHeight) + overscan*2. Beide mit Min/Max eingrenzen.

Scrollbar-Simulation

Top-Spacer mit startIndex * itemHeight px. Container-Gesamthöhe allItems.length * itemHeight px. Bottom-Spacer berechnet sich automatisch.

Alpine-Integration

x-for über visibleItems mit :key="item.id". @scroll.passive aktualisiert scrollTop. Computed getters für alle abgeleiteten Werte.

Suche und Filter

Gefilterte Teilmenge als Basis für Windowing. Scroll-Offset bei Filter-Änderung auf 0 zurücksetzen. Debounce 150–300ms auf Sucheingabe.

11. FAQ: Virtuelles Scrollen mit Alpine.js

1Ab welcher Listengröße lohnt sich Virtual Scroll?
Faustregel: ab 300–500 komplexen Elementen. Bei einfachen Textzeilen bis 2000. Bei Elementen mit Bildern und Buttons bereits ab 100–200 Einträgen sinnvoll.
2Funktioniert das mit CSS Grid?
Möglich, aber Spacer müssen grid-column: 1 / -1 haben. Block/Flex-Layout ist einfacher. Implizite Grid-Tracks machen Höhenberechnung komplexer.
3Scroll-to-Top implementieren?
scrollToIndex(0) aufrufen: $el.scrollTop = 0 und scrollTop = 0. Alpine aktualisiert startIndex sofort. Für Animation scrollTo({ top: 0, behavior: 'smooth' }).
4Ausgewählte Elemente markieren?
Set mit ausgewählten IDs als Alpine-Variable. :class mit selectedIds.has(item.id). Auswahl im Daten-Modell speichern, nie im DOM – da nur sichtbare Elemente existieren.
5Dynamisch hinzugefügte Elemente?
Einfach zu allItems pushen. Alpine berechnet totalHeight neu. Bei Einfügen am Anfang scrollTop um die neue Elementhöhe anpassen für stabilen Scroll-Offset.
6Warum @scroll.passive?
Teilt dem Browser mit: kein preventDefault(). Erlaubt Scrolling auf Compositor-Thread ohne JS-Thread zu warten. Messbarer Performance-Vorteil auf Mobile.
7Drag-to-Reorder im Virtual Scroll?
Sehr komplex: Auto-Scroll beim Ziehen an den Rand nötig, Reihenfolge im allItems-Array anpassen. HTML5 Drag & Drop mit Alpine-Events ist für einfache Fälle kombinierbar.
8Virtual Scroll nativ in Alpine.js?
Nein – Alpine hat keine eingebaute Virtual-Scroll-Direktive. Es wird über x-for mit berechneter Teilmenge und @scroll.passive gebaut. Bewusst: Alpine ist ein leichtgewichtiges Framework.
9E2E-Testing von Virtual Scroll?
Playwright: page.evaluate(() => container.scrollTop = 5000) zum Scrollen, dann DOM-Inhalt prüfen. scrollToIndex() per evaluate für gezielte Sprünge aufrufen.
10Unterschied Virtual Scroll vs. Infinite Scroll?
Virtual Scroll: alle Daten bekannt, nur sichtbares gerendert. Infinite Scroll: Daten werden nachgeladen, DOM wächst. Hybrid: Virtual Scroll als Rendering-Technik über Infinite-Load-Daten.