JSON Toolkit

Warum große JSON-Bäume ruckeln: virtuelles Scrollen und Aufklapp-Strategien

Fazit: Ruckeln beginnt beim Anlegen von DOM-Knoten, nicht beim Scrollen. Behandelt Aufklappen zuerst, virtuelle Listen, Suchhervorhebung und Worker-Grenzen, mit Messwerten.

Experte~10 Min. LesezeitVeröffentlicht 2026-10-31Aktualisiert 2026-10-31
Virtuelles Scrollen und Aufklapp-Strategie für einen JSON-Viewer

Kurz gesagt: Zerlegen Sie „langsam“ in Parsen, Knoten-Erzeugung, Layout und Paint sowie Interaktion und messen Sie jede Stufe. Implementieren Sie in dieser Reihenfolge – standardmäßig zuklappen, eine virtuelle Liste ergänzen, Berechnung in einen Worker verlagern und Hervorhebung auf sichtbare Zeilen beschränken. Direkt zu einer virtuellen Liste zu springen, ohne zuzuklappen, verkleinert nur die sichtbare Menge; die Neuberechnung des ganzen Baums kostet weiterhin.

Das Ruckeln kommt meist von den Hunderttausenden DOM-Knoten, die Sie für diese 5 MB angelegt haben, nicht von den 5 MB selbst.

1. „Langsam“ in vier Stufen zerlegen

Sagt ein Nutzer „es friert beim Öffnen ein“, kann das Problem in vier völlig verschiedenen Stufen liegen, jede mit anderer Abhilfe:

Stufe Typischer Auslöser Wahrnehmbare Schwelle Dominante Kosten
Parsen und Objekte bauen Datei öffnen, Text einfügen Jede Aufgabe über 50 ms Strings scannen, Objektgraphen bauen
DOM-Knoten anlegen Den ganzen Baum auf einmal rendern Zehntausende Knoten Elemente, Textknoten, Style-Objekte allokieren
Layout und Paint Erste Anzeige, Scrollen Jeder Frame über 16,7 ms Geometrie, Reflow, Repaint
Interaktionsantwort Aufklappen, Zuklappen, Suchen Über 100 ms wirkt verzögert Neu-Abflachen und Neu-Rendern

Der Browser-Hauptthread tut eines nach dem anderen. Belegt eine Stufe ihn länger als 50 ms, verlieren Scrollen und Tippen Frames; mehrere solche Aufgaben hintereinander und die Seite wirkt tot. Die erste Optimierung ist also nicht requestAnimationFrame; sie ist zu messen, welche Stufe tatsächlich Zeit verbraucht.

2. Knotenzahl ist der echte Feind

Baum-Viewer multiplizieren Knotenzahlen konstruktionsbedingt. Eine einzelne JSON-Zeile wird meist zu „Einrückung + Schlüssel + Doppelpunkt + Wert + Komma + Chevron“, eine konservative Schätzung sind also 3 bis 6 DOM-Knoten pro Zeile:

Dateigröße Ungefähre Zeilen DOM-Knoten der Baumansicht (Schätzung) Wie einmaliges Rendern aussieht
100 KB 2.500 10.000 Meist verträglich
1 MB 25.000 100.000 Deutlich ruckelig, womöglich Sekunden leerer Bildschirm
5 MB 125.000 500.000 Sehr wahrscheinlich nicht ansprechbar
20 MB 500.000 Über 2 Millionen Speicher und Layout brechen beide zusammen

Jedes Element ist mehr als ein paar Dutzend Bytes Objekt: Es trägt Style-Regeln, eine Layout-Box und Paint-Anweisungen. Einmal angelegt kostet ein Knoten weiterhin Layout- und Style-Arbeit, selbst wenn er später von einem zugeklappten Elternteil verdeckt wird, solange er nicht tatsächlich entfernt wurde. „Alles rendern, dann verstecken“ ist keine Optimierung; es verschiebt die Kosten nur auf den nächsten Reflow.

3. Erst zuklappen, dann virtualisieren

Virtuelles Scrollen löst „zu viele sichtbare Zeilen“. Zuklappen löst „wir sollten überhaupt nicht so viele Zeilen sehen müssen“. Die Reihenfolge zählt: Bei einem Millionenzeilen-Dokument, das standardmäßig aufgeklappt ist, muss selbst eine perfekte virtuelle Liste den ganzen Baum neu berechnen, wenn der Nutzer „alles zuklappen“ klickt.

Eine praktikable Aufklapp-Strategie:

  • Standardmäßig nur die ersten zwei Ebenen aufklappen. Wurzel und ihre unmittelbaren Felder sind sichtbar; tiefere Ebenen zeigen eine Zusammenfassung wie items: Array(12000);
  • Typ und Anzahl in die Zusammenfassung legen. Nutzer, die prüfen „stimmen die Feldnamen“, brauchen selten zehntausend Datensätze;
  • Zeilen pro Teilbaum cachen. Jeder aufgeklappte Knoten besitzt sein abgeflachtes Zeilenarray; Zuklappen verwirft es, Aufklappen berechnet nur diesen Teilbaum neu;
  • Niemals bei jedem Umschalten den ganzen Baum neu scannen. Erfolgt das Aufklappen in Zeile 3.000, ersetzen Sie nur diesen Bereich und lassen die umgebenden Zeilen intakt.
/**
 * Insert a subtree's rows after its parent instead of re-flattening the whole tree.
 * @param rows Current visible row array
 * @param parentIndex Index of the parent row
 * @param childRows Flattened rows of the subtree
 * @returns A new visible row array
 */
function replaceSubtreeRows(
  rows: TreeRow[],
  parentIndex: number,
  childRows: TreeRow[],
): TreeRow[] {
  const nextRows = rows.slice();
  nextRows.splice(parentIndex + 1, 0, ...childRows);
  return nextRows;
}

Das Muster, das Sie vermeiden sollten, ist „ein Aufklappen, ein vollständiges Neu-Layout“. Den kompletten Objektgraphen bei jedem Klick neu zu durchlaufen lässt die Kosten linear mit dem Dokument wachsen. Mit Caching pro Teilbaum kostet eine einzelne Aufklappung nur so viel wie dieser Teilbaum.

4. Virtuelle Listen: nur den Viewport rendern

Hat Zuklappen die Zeilenzahl reduziert, können trotzdem Zehntausende Zeilen übrig bleiben. Eine virtuelle Liste setzt die Containerhöhe auf „Gesamtzeilen x feste Zeilenhöhe“ und legt Elemente nur für den Viewport plus einen kleinen Puffer darüber und darunter an.

const ROW_HEIGHT = 24;
const OVERSCAN = 8;

/**
 * Compute the row range that the viewport should render.
 * @param rows All visible rows
 * @param scrollTop Current scroll offset of the container
 * @param viewportHeight Visible height of the container
 * @returns Rows to render, the start index, and the vertical offset
 */
function getViewportRows(
  rows: TreeRow[],
  scrollTop: number,
  viewportHeight: number,
): { visibleRows: TreeRow[]; startIndex: number; offsetTop: number } {
  const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
  const end = Math.min(
    rows.length,
    Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN,
  );

  const visibleNodes = rows.slice(start, end);
  return {
    visibleRows: visibleNodes,
    startIndex: start,
    offsetTop: start * ROW_HEIGHT,
  };
}

Unverhandelbare Einschränkungen:

  • Die Zeilenhöhe muss fest sein. Nur dann liefert scrollTop / ROW_HEIGHT direkt den Index. Umbrochene Zeilen zuzulassen zwingt Sie, jede Höhe zu messen und zu cachen, was eine Größenordnung an Komplexität und Flackern hinzufügt;
  • Mit transform oder absoluter Positionierung versetzen. Schieben Sie Inhalte nicht mit einem Spacer-Element nach unten, sonst verändert jedes Scrollen das Layout;
  • Stabile Keys verwenden. Schlüsseln Sie Zeilen nach Pfad, nicht nach Array-Index, sonst remountet das Aufklappen einer Zeile jede Zeile danach;
  • Nur im Scroll-Handler rechnen. Verzögern Sie das Rendern auf requestAnimationFrame und schützen Sie gegen doppelte Planung;
  • Den Puffer klein halten. Fünf bis zehn Zeilen pro Seite genügen; den Puffer zu verdoppeln verdoppelt DOM-Knoten und Style-Arbeit.

Bei einem 800-Pixel-Viewport und 24-Pixel-Zeilen sind etwa 34 Zeilen sichtbar, und selbst mit Puffer rendern Sie etwa fünfzig. Das zugrunde liegende Dokument kann hunderttausend Zeilen halten, während das DOM konstant ein paar hundert Elemente hält und die Layoutkosten flach bleiben.

5. Aufklappen, Zuklappen und Scroll-Verankerung

Sobald der Zeilenindex die einzige Koordinate wird, brechen einige Interaktionsdetails:

  1. Den visuellen Anker beim Zuklappen oberhalb des Viewports bewahren. Zeilen darunter rücken nach oben; wird scrollTop nicht im selben Commit angepasst, scheint der Inhalt zu springen. Den Pfad der Ankerzeile festhalten und die Scrollposition über ihren neuen Index wiederherstellen.
  2. Vorfahren aufklappen, bevor gesprungen wird. Trifft eine Suche ein tiefes Feld, zuerst jeden Elternteil aufklappen, dann den Zielzeilenindex berechnen, dann scrollen und hervorheben. Den ersten Schritt überspringen und die Zielzeile ist nicht einmal im sichtbaren Array.
  3. Tastatur-Navigation aus sichtbaren Zeilen treiben. Hoch und runter bewegen den Zeilenindex; links und rechts klappen auf und zu. Niemals den ganzen Baum in einem Key-Handler durchlaufen.
  4. Updates in einen Frame bündeln. Beim Aufklappen mehrerer Ebenen hintereinander die Array-Änderungen zusammenführen und einmal rendern, sonst löst jede Ebene einen vollständigen Reflow aus.

6. Suchen und Hervorheben: einmal rechnen, wenig anzeigen

Suchen ist die am häufigsten übersehene Performancefalle in einem Baum-Viewer. Durchläuft jeder Tastendruck den ganzen Objektgraphen und baut die Ergebnisliste neu, friert ein Dokument mit hunderttausend Knoten sofort ein.

Ein geschichteter Ansatz:

  • Eingabe um 150–250 ms entprellen und jede Suche mit einer Sequenznummer taggen, damit veraltete Ergebnisse verworfen werden;
  • In einem Worker scannen. Einen Objektgraphen zu durchlaufen ist reine Berechnung ohne DOM-Abhängigkeit, sollte das Tippen also nicht blockieren;
  • Nur Pfade und Zeilenindizes speichern. Nicht jeden Treffer kopieren oder hervorgehobenes HTML vorab bauen;
  • Nur sichtbare Zeilen hervorheben. Der Viewport hält ein paar Dutzend Zeilen; wenden Sie Styles beim Rendern nach getroffenem Pfad an;
  • „Vorfahren aufklappen, dann Index lokalisieren“ für Sprünge wiederverwenden, derselbe Weg wie ein Klick auf ein Chevron.

Ein Sicherheitsdetail: Schlüssel und Werte in einem Baum-Viewer sind Nutzerdaten. Hervorgehobenes HTML daraus zu bauen erfordert Escaping, sonst wird ein String mit Markup zu XSS. Ein JSON-Viewer braucht keinen Rich Text, Textknoten plus Klassennamen sind also sowohl sicherer als auch schneller.

7. Syntaxhervorhebung: CodeMirror und Monaco sind verschiedene Werkzeuge

Eine Baumansicht und ein Texteditor sind verschiedene Rendering-Modelle, „welcher schneller ist“ ist also kein direkter Vergleich:

Dimension CodeMirror 6 Monaco Editor
Positionierung Modularer Editor-Kern Vollwertiger IDE-Editor
Rendering Viewport-basiert Viewport-basiert, aber Modelle und Dekorationen sind schwerer
Parsen Lezer-inkrementelles Parsen über geänderte Bereiche Monarch-Tokenizer, zeilenweise
Große Dateien Hängt von Erweiterungen ab; standardmäßig leicht Funktionsvollständig, höherer Speicher- und Startaufwand
Folding und Suche Separate Erweiterungen, bei Bedarf geladen Eingebaut, mit dauerhaftem Overhead
Typische Falle Zu viele Erweiterungen stellen die Kosten wieder her Minimap, Sticky Scroll und gestapelte Dekorationen

Zwei praktische Schlussfolgerungen:

  • Eine Baumansicht braucht keinen vollständigen Tokenizer. Geben Sie Schlüsseln, Strings, Zahlen und Booleans je eine Farbe mit einfachen Textknoten und CSS-Klassen; das kostet eine Größenordnung weniger als Syntaxhervorhebung laufen zu lassen;
  • Nicht gleichzeitig Baumansicht und Editor mounten. Zwei Modelle für ein Dokument zu pflegen verdoppelt den Speicher; die alte Ansicht zerstören, statt sie zu verstecken.

Gehört ein Editor wirklich in den Viewer, passt CodeMirror 6 zu „schreibgeschützt, große Datei, lokale Hervorhebung“, während Monaco zu Vervollständigung, Diagnose und Multi-Cursor-Editing passt. Messen Sie First-Paint-Zeit und residenten Speicher, bevor Sie die Funktionsliste lesen. Zu einem tieferen Vergleich von Editoren und CLI-Werkzeugen siehe Editoren, Kommandozeilenwerkzeuge und Automatisierung.

8. Worker-Grenzen: Parsen und Zeilenbau vom Hauptthread weg

Die andere Hälfte des Rendering-Ruckelns ist das Parsen auf dem Hauptthread. Für JSON über 1 MB gehören Parsen, Formatieren und der Aufbau flacher Zeilen in einen Web Worker; der Hauptthread nimmt nur Zeilen und malt den Viewport.

Worker sind nicht gratis: postMessage verwendet strukturiertes Klonen, einen 50-MB-Objektgraphen an den Hauptthread zurückzusenden baut eine zweite Kopie und verdoppelt die Spitze. Die richtige Grenze ist im Worker parsen und abflachen, nur die vom Renderer benötigten Zeilen zurückgeben:

worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
  rows = event.data.rows;
  requestAnimationFrame(() => renderViewport());
};

worker.postMessage({ text });

Sind die Zeilendaten noch groß, komprimieren Sie Zeilen in parallele Arrays (Pfad, Typ, Wert-Offset), legen Sie sie in einen ArrayBuffer und übertragen Sie den Besitz, damit der Hauptthread keine Kopie zahlt. Der Preis ist ein eigener Serializer, der sich erst bei Millionen Zeilen lohnt.

Die Regel für die Grenze ist einfach: Der Hauptthread sollte nur Arbeit tun, die das DOM berühren muss. Parsen, Suchen, Sortieren, Formatieren und Diffen können alle wegziehen. Rendern, Scrollen, Eingabe und Fokus bleiben auf dem Hauptthread, und jeder Frame muss ins Arbeitsbudget passen.

9. Metriken und Implementierungsreihenfolge

Optimierung darf nicht bei „es fühlt sich schneller an“ aufhören. Der folgende Satz kann direkt in eine Abnahme-Checkliste:

Metrik Ziel Wie messen
Erster sichtbarer Frame Unter 300 ms performance.now() vom Öffnen bis zum ersten Paint
Lange Aufgaben Keine über 50 ms PerformanceObserver auf longtask
Scroll-Frame-Zeit Stabil unter 16,7 ms DevTools-Performance-Aufzeichnung
DOM-Elemente im Viewport Unter 2.000 document.querySelectorAll('*').length
Aufklapp-/Zuklapp-Antwort Unter 50 ms Zeitstempel um die Interaktion
Suche über 100.000 Zeilen Unter 300 ms, im Worker Zeit im Worker messen und zurückgeben
Residenter Heap Unter dem Dreifachen der Textgröße DevTools-Memory-Heap-Snapshot

Messen Sie mit einer echten Datei statt mit gleichförmig generierten kleinen Objekten; die Datenform zählt, und lange Strings, tiefe Verschachtelung und sehr breite Arrays erzeugen alle unterschiedliche Kurven. Zur Objektgraph-Bilanz selbst siehe Die JSON-Speicherbilanz.

Implementieren Sie in dieser Reihenfolge:

  1. Eine Baseline messen: Erst-Render-Zeit, Knotenzahl, Scroll-Frame-Zeit;
  2. Standardmäßig zuklappen: zwei Ebenen aufklappen, Typ und Anzahl in Zusammenfassungen zeigen;
  3. Virtuelle Liste: feste Zeilenhöhe, stabile Keys, Viewport plus kleiner Puffer;
  4. Berechnung verlagern: Parsen, Suche und Abflachen in einen Worker;
  5. Hervorhebung beschränken: nur sichtbare Zeilen stylen, den ganzen Baum nicht neu bemalen;
  6. Leitplanken ergänzen: über einer Schwelle „die ersten N Zeilen zeigen“ anbieten oder einen Suchfilter verlangen.

Jeder Schritt teilt ein Ziel: die Knoten und Objekte reduzieren, die im selben Moment existieren müssen, statt den Browser zu bitten, härter an ihnen zu arbeiten. Der vollständige Hintergrund zur geschichteten Strategie steht in Große JSON-Dateien parsen, rendern und streamen.

Kann virtuelles Scrollen Ruckeln zur Parse-Zeit beheben?

Nein. Virtuelles Scrollen reduziert nur DOM-Knoten. Geschieht das Parsen weiterhin auf dem Hauptthread, ruckelt die Seite weiterhin. Verlagern Sie das Parsen für Dokumente über 1 MB in einen Worker und behandeln Sie die beiden Probleme getrennt.

Alles ist aufgeklappt und es ruckelt immer noch. Was ist falsch?

Entweder ist die virtuelle Liste nicht wirklich aktiv, oder jede Aufklappung scannt den ganzen Baum neu. Prüfen Sie, ob Zeilen außerhalb des Bildschirms weiterhin DOM erzeugen und ob das Aufklappen nur den betroffenen Teilbaum neu berechnet.

CodeMirror oder Monaco für einen JSON-Viewer?

Bevorzugen Sie CodeMirror 6 für schreibgeschützt, große Dateien und lokale Hervorhebung; das modulare Laden bei Bedarf ist leichter. Wählen Sie Monaco, wenn Sie Vervollständigung, Diagnose und Multi-Cursor-Editing brauchen. Vermeiden Sie bei beiden, Baumansicht und Editor für dasselbe Dokument gleichzeitig zu mounten.

Warum ist die Scrollposition nach einem Suchsprung falsch?

Meist weil die Ansicht gescrollt hat, bevor aufgeklappt wurde. Klappen Sie zuerst alle Vorfahren auf, setzen Sie dann die Scrollposition aus dem neuen Zeilenindex und heben Sie zuletzt hervor. Schlüsseln Sie Zeilen nach Pfad statt nach Index, damit Aufklappen nicht jeden Index verschiebt.

FAQ

Wie finde ich heraus, aus welcher Stufe das Ruckeln kommt?

Zerlegen Sie „langsam“ in Parsen und Objekte bauen, DOM-Knoten-Erzeugung, Layout und Paint sowie Interaktionsantwort, und messen Sie jede Stufe mit performance.now() vom Öffnen bis zum ersten Paint. Beobachten Sie dann longtask mit PerformanceObserver – jede Aufgabe über 50 ms lässt Frames fallen, und eine DevTools-Performance-Aufzeichnung zeigt, ob die Scroll-Frame-Zeit unter 16,7 ms bleibt.

Warum macht das Rendern des ganzen Baums auf einmal den Bildschirm leer?

Ein 5-MB-Dokument hat etwa 125.000 Zeilen, und eine Baumansicht macht aus jeder Zeile meist 3 bis 6 DOM-Knoten – grob 500.000 Elemente auf einmal. Jedes Element trägt außerdem Style-Regeln, eine Layout-Box und Paint-Anweisungen, mehrere Hauptthread-Aufgaben über 50 ms hintereinander lassen die Seite also tot wirken. Von einem zugeklappten Elternteil verdeckte Knoten kosten weiterhin Layoutarbeit, „alles rendern, dann verstecken“ ist also keine Optimierung.

Sollte ich einen 50-MB-Objektgraphen aus dem Worker zurücksenden?

Nein. postMessage verwendet strukturiertes Klonen, die empfangende Seite baut also eine zweite Kopie und die Spitze verdoppelt sich. Die richtige Grenze ist, im Worker zu parsen und abzuflachen und nur die vom Renderer benötigten Zeilen zurückzugeben; bei Millionen Zeilen können Sie parallele Arrays (Pfad, Typ, Wert-Offset) in einen ArrayBuffer packen und den Besitz übertragen.