- Beim Aufbau einer Datenbank-GUI für MongoDB und PostgreSQL mussten BSON- und JSONB-Typen, verschachtelte Spalten, Suche, Bearbeitung, Fixierung und Dragging unterstützt werden; dafür wurden über etwa ein Jahr hinweg Virtualisierung auf beiden Achsen und die Struktur des State-Managements optimiert.
- Es wurde eine Shadow Table erstellt, die unabhängig vom Originaldokument Anzeige-Strings, Typen, abgeflachte Pfade, Spaltenreihenfolge und Suchergebnisse vorab berechnet; gerendert werden nur die sichtbaren Zeilen und Spalten mit einer DOM-Struktur konstanter Größe.
- Im Scroll-Pfad kamen passive Event Listener,
requestAnimationFrame, Puffer und Hysterese sowie Geschwindigkeitsverfolgung zum Einsatz; statt Layout-Eigenschaften wurdentransformundopacitygenutzt, um Arbeit auf dem Main Thread zu reduzieren. - Zell-Icons wurden durch gemeinsam genutzte SVG-Hintergrundbilder ersetzt, der Editor wurde nur bei Bedarf gemountet, und DOM-Pooling anhand von Zeilen- und Spaltenpositionen eliminierte die Erzeugung neuer Nodes während des Scrollens.
- Canvas bietet zwar eine höhere 60-fps-Leistungsobergrenze als DOM, ist aber bei Text, Auswahl, Barrierefreiheit und Funktionserweiterungen im Nachteil; gewählt wurde daher ein DOM-basiertes Design, das echte Textauswahl und schnelle Entwicklung beibehält.
Ziel und anfängliche Einschränkungen
- Der Start erfolgte mit einem einfachen zweidimensionalen Array und verschachtelten Schleifen, entwickelte sich aber zu rund einem Jahr sporadischer Optimierungsarbeit.
- Die Tabelle einer Datenbank-GUI sollte nicht nur anzeigen, sondern vielfältige Zustände und Interaktionen unterstützen.
- Sie versteht alle BSON-Typen von MongoDB sowie JSONB etwa aus PostgreSQL und zeigt typspezifische farbige Icons an.
- Sie unterscheidet Typen, bei denen sich Abfrageergebnisse unterscheiden, etwa der String
"123"und die Ganzzahl123. - Verschachtelte Dokumente werden zu echten Unterspalten erweitert, und die vollständigen verschachtelten Pfade werden durchsucht, um Treffer innerhalb von Zellen hervorzuheben.
- Erforderlich waren Spalten-Neuanordnung, Größenänderung und Fixierung, Bearbeitung innerhalb von Zellen sowie das Ziehen von Werten, Zeilen und Spalten in einen visuellen Query Builder.
- Der Zustand solcher Funktionen existiert nicht im Originaldokument und muss auch nach dem Scrollen erhalten bleiben, daher war eine separate Rendering-Struktur nötig.
Schritt 1: Alles direkt rendern
- Der Ansatz, Zeilen und Felder verschachtelt zu durchlaufen und alle Zellen zu erzeugen, funktioniert bei 100 Zeilen, bricht aber bei großen Datenmengen zusammen.
- 10.000 Zeilen × 30 Spalten erzeugen etwa 300.000 DOM-Nodes, die von der Change Detection des Frameworks wiederholt durchlaufen werden.
- Wenn ein DOM-Node inklusive interner Browserstrukturen etwa 1 KB verbraucht, werden schon vor den eigentlichen Daten mehrere hundert MB benötigt.
- Das Frame-Budget für 60 fps beträgt 16,7 ms; Style, Layout und Paint teilen sich diese Zeit ebenfalls.
- In der realen Implementierung scheiterte bereits der Versuch, 1.000 Zeilen mit etwa 20 Spalten zu rendern.
- Um nur einen Teil zu rendern, müssen die aktuell sichtbaren Zeilen und Spalten, ihre Reihenfolge, die Erweiterung verschachtelter Felder und Suchergebnisse separat verfolgt werden.
Schritt 2: Anzeigezustand mit einer Shadow Table trennen
- Originaldokumente sind verschachtelt und haben uneinheitliche Typen, was sie als Rendering-Eingabe ungeeignet macht.
- MongoDB hat BSON-Werte wie ObjectId, Decimal128, Zeitstempel und Binärdaten.
- SQL-Daten enthalten JSONB und Zeitstempel mit Zeitzonen; pro Zelle muss das Format bestimmt werden.
- Wird das Format in der Rendering-Schleife bestimmt, entstehen in jedem Frame dieselben Kosten; außerdem fehlen den Originaldaten Tabellenzustände wie Spaltenreihenfolge, Erweiterungsstatus und Suchergebnisse.
- Die Shadow Table dient als Referenzpunkt für den Zustand, den die tatsächliche Tabelle anzeigen soll, ohne das Originaldokument zu verändern.
- Sie wird beim Laden einmal aufgebaut und bei Zustandsänderungen aktualisiert, während des Scrollens aber nicht verändert.
- Für jede Zelle werden gekürzte Anzeige-Strings, der festgelegte Typ und der abgeflachte Pfad vorab berechnet.
- Anzeige-Strings werden begrenzt, damit ein 16-MB-Dokument im Rendering-Zustand nicht unverändert einen 16-MB-String erzeugt.
- Der Typ bestimmt Icon, Editor und Suchmethode.
- Abgeflachte Pfade wie
"address.geo.lat"werden als Schlüssel verwendet, damit nicht jedes Mal der Baum durchlaufen werden muss.
- Wird ein verschachteltes Objekt erweitert, werden seine Unterpfade zu echten Spalten hochgestuft; Sortierung, Suchergebnisse, Spaltenreihenfolge und Erweiterungsstatus werden in derselben Struktur gespeichert.
- In diesem Schritt sinkt die Zahl der DOM-Nodes noch nicht, aber die Grundlage ist gelegt, um Spaltenreihenfolge und -breite später schnell zu berechnen.
Schritt 3: Vertikale Virtualisierung und Phantom-Scrollbereich
- Vertikale Virtualisierung rendert nur die Zeilen im Viewport plus einen kleinen Puffer und bildet die restliche Höhe als künstlichen Bereich ab.
- Der Phantom-Bereich ist ein innerer Container mit der Höhe
rowCount × rowHeight.- Bei 1 Million Zeilen × 40 px entsteht ein fast leerer
divmit 40 Millionen px Höhe. - Der Browser stellt anhand dieser Höhe native Scrollbars und Scrollverhalten bereit.
- Bei 1 Million Zeilen × 40 px entsteht ein fast leerer
- Der sichtbare Bereich wird so berechnet:
firstRow = floor(scrollTop / rowHeight)lastRow = floor((scrollTop + viewportHeight) / rowHeight)- Auf beiden Seiten werden Pufferzeilen hinzugefügt, damit nicht bei jeder kleinen Bewegung neu gerendert wird.
- Sichtbare Zeilen werden in einem Slab-Container an der Position
firstRow × rowHeightplatziert;transformist dabei vorteilhafter alstop. - Der Nutzer sieht 1 Million Zeilen, im DOM existieren aber nur etwa 40 Zeilen.
- Bei 300 Spalten entstehen allerdings selbst mit nur 40 Zeilen noch 12.000 Zellen, daher müssen auch Spalten virtualisiert werden.
Schritt 4: Horizontale Virtualisierung mit variabler Breite
- Collections in Dokumentdatenbanken können hunderte Felder enthalten, daher ist auch Spaltenvirtualisierung nötig.
- Da Spaltenbreiten nicht konstant sind, werden statt Division durch einen festen Wert Präfixsummen und binäre Suche verwendet.
- Mit Präfixsummen der Form
position[n] = width[0] + ... + width[n-1]erhält man die x-Koordinate jeder Spalte per einmaligem Array-Zugriff. - Die zum Scroll-Offset
xpassende Spalte wird per binärer Suche im Präfixsummen-Array gefunden. - Selbst bei 1.000 Spalten ist die Suche in Mikrosekunden erledigt.
- Mit Präfixsummen der Form
- Präfixsummen werden nur neu aufgebaut, wenn sich Breiten tatsächlich ändern, etwa durch Größenänderung, Ausblenden oder Neuordnung von Spalten; während des Scrollens werden sie nicht erzeugt.
- Links und rechts wird ein Puffer von etwa 200 px angelegt, damit die nächste Spalte schon gerendert ist, bevor sie auf dem Bildschirm erscheint.
- Nach Virtualisierung beider Achsen bleibt die gerenderte Fläche unabhängig von der Datengröße bei etwa 40 Zeilen × 12 Spalten.
- Auch bei Collections mit 500 Spalten existierten gleichzeitig nur rund 12 Spalten, sodass die Ladezeit nicht zunahm.
- Die Rendering-Ziele wurden reduziert, doch es blieb das Problem, bei hunderten Scroll-Events pro Sekunde den Bereich jeweils neu zu berechnen.
Schritt 5: Frame-Budget im Scroll-Pfad verwalten
- Scroll-Verarbeitung teilt sich das 16,7-ms-Frame-Budget mit Style, Layout und Paint und muss daher nur minimale Arbeit leisten.
- Passive Event Listener werden außerhalb der Change Detection des Frameworks registriert.
- Dem Browser wird mitgeteilt, dass
preventDefaultnicht aufgerufen wird, sodass der Compositor Pixel bewegen kann, ohne auf JavaScript zu warten. - Das Scroll-Event selbst löst keine Rendering-Prüfung des Frameworks aus.
- Dem Browser wird mitgeteilt, dass
- Mehrere Events werden auf einmal pro Frame zusammengeführt.
- Nur die neueste Scroll-Position wird gespeichert, und genau ein
requestAnimationFrame-Callback wird geplant. - Auch 12 Events in einem Frame werden mit nur einer Bereichsberechnung verarbeitet.
- Nur die neueste Scroll-Position wird gespeichert, und genau ein
- Aus dem Handsontable-Quellcode wurde der Fast-Draw-Abbruch übernommen.
- Liegt der neue sichtbare Bereich innerhalb des bestehenden Rendering-Puffers, genügen zwei Integer-Vergleiche, danach wird sofort zurückgegeben.
- An Puffergrenzen wird Hysterese angewendet.
- Nähert sich der sichtbare Bereich bis auf etwa 40 px dem Pufferrand, wird neu aufgebaut.
- Ein etwa 200-px-Puffer wird um die neue Position herum neu platziert, um leere Ränder oder wiederholtes Neuaufbauen an der Grenze zu vermeiden.
- Ein Geschwindigkeitsdetektor erfasst px/ms zwischen Events.
- In der Implementierung gilt mehr als 10 px/ms als schneller Flick, woraufhin das Rendering angehalten wird.
- Der native Scroll läuft über den Phantom-Bereich weiter; stabilisiert sich die Geschwindigkeit, wird der Slab wieder gefüllt.
- Auch nach Reduzierung der JavaScript-Arbeit blieben Frame-Einbrüche; Layout und Paint mit Styles, Zebra-Streifen und Icons wurden zum nächsten Engpass.
Schritt 6: Kosten von Layout-Eigenschaften entfernen
- Der Main Thread des Browsers ist für Style, Layout und Paint zuständig, während der Compositor bereits gezeichnete Layer auf der GPU bewegt.
- Eigenschaften, deren Animation der Compositor verarbeiten kann, sind
transformundopacity;top,left,width,height,background-colorusw. wecken den Main Thread. - Als für die Scroll-Synchronisierung von Zeilennummern und fixierten Spalten-Panels
topaktualisiert wurde, kam es 60-mal pro Sekunde zu erzwungenem Layout.- Dies wurde durch
translate3dersetzt, wodurch dieselbe Darstellung erhalten blieb, aber die Main-Thread-Kosten entfielen.
- Dies wurde durch
- Die zuvor zeilenweise implementierten Zebra-Streifen wurden durch ein einzelnes
repeating-linear-gradientfür den gesamten Body ersetzt.- Die Zeilenhöhe wird per CSS-Variable übergeben.
- Der Browser rastert nur eine Kachel in der Größe von zwei Zeilen und kopiert sie wiederholt aus der GPU-Textur.
- Zeilenweise Class Bindings entfallen, und Body sowie fixierte Panels teilen sich dieselbe Kachel, wodurch Farbabweichungen vermieden werden.
- Spaltentrennlinien werden nicht über den gesamten 40-Millionen-px-Phantom-Bereich gezeichnet, sondern nur über die Höhe des aktuell gerenderten Slabs.
- Normales Scrollen wurde flüssig, doch an den Umschaltpunkten des Fensters, wenn neue Zellen erzeugt wurden, verursachte unnötiger DOM pro Zelle weiterhin Kosten.
Schritt 7: Icons und Editoren verschlanken
- Typ-Icons in einem Datenbank-Grid sind keine Dekoration, sondern unterscheiden die Bedeutung von Werten wie ObjectId, String, Integer oder JSONB.
- Die erste Implementierung fügte pro Zelle ein Font-Icon-Element hinzu, doch zusätzliche DOM-Nodes und der Textrendering-Pfad für Glyphen verursachten hohe Kosten.
- Die Icons wurden in das
background-imageder Zelle verschoben und als SVG Data URI codiert.- Alle Zellen desselben Typs referenzieren denselben URI-String.
- Der Browser rastert typspezifische Icons nur einmal und kopiert sie wiederholt aus der gecachten GPU-Textur.
- Wiederholte Dekoration kann ohne separate DOM-Nodes angezeigt werden.
- Für die Zellbearbeitung wurde ein hybrider Rendering-Ansatz verwendet.
- Normale Zellen verwenden nur einfachen Text und ein
span. - Erst beim Doppelklick wird eine schwere, typbewusste Editor-Komponente wie ein Portal über der betreffenden Zelle gemountet.
- Normale Zellen verwenden nur einfachen Text und ein
- Werden für alle Zellen Framework-Komponenten verwendet, summieren sich die Kosten der Instanzerzeugung; die Performance kann leiden, wenn eine Grid-Bibliothek mit Zell-Renderer-Komponenten gekoppelt wird.
- Die Zellen selbst wurden leichter, doch beim Erscheinen einer neuen Spalte blieb das Problem, dass das Framework auch die übrigen inhaltlich unveränderten Zellen neu erzeugte.
Schritt 8: Positionsbasierte DOM-Wiederverwendung
- Tracking-Kriterien wie Angulars
trackByoderkeyin React und Vue entscheiden beim Verschieben des Fensters, ob bestehende Elemente wiederverwendet oder zerstört und neu erstellt werden. - Werden Zeilen nicht an neue Daten neu gebunden, sondern jedes Mal ersetzt, müssen Komponenten, DOM-Nodes und Event Listener ständig neu erzeugt werden.
- Zeilen und Spalten werden nicht anhand des Datenwerts, sondern anhand ihrer Position auf dem Bildschirm verfolgt.
- Dieselben etwa 40 Zeilen- und Spaltenelemente bleiben erhalten; nur ihr Inhalt wird durch neue Werte ersetzt.
- Das Grid verhält sich wie ein Objektpool und allokiert während des Scrollens keinen neuen DOM.
- An diesem Punkt waren das Scrollen auf beiden Achsen und die Kosten des Fenster-Neuaufbaus gelöst, aber Feinschliffprobleme wie ein Wackeln in einem Frame beim Ablegen von Spalten oder halbe-Pixel-Abweichungen der Zeilennummern blieben.
Schritt 9: Feinschliff der Interaktionen
- Während des Ziehens von Spalten werden bestehende Spalten per
transformso verschoben, als hätten sie bereits die neue Reihenfolge; beim Ablegen werden Neuordnung und Zurücksetzen vontransformim selben Rendering-Pass durchgeführt.- Der letzte Drag-Frame und der erste neu geordnete Frame werden pixelgenau identisch gemacht, sodass der Übergang unsichtbar ist.
- Tooltips werden nicht pro Zelle gebunden, sondern über einen einzigen delegierten Hover-Listener am Container verarbeitet.
- Nur für die Zelle unter dem Cursor wird der Tooltip berechnet.
- Teure String-Erzeugung, etwa Pretty-Printing von JSONB-Werten in SQL, wird ebenfalls bis zum tatsächlichen Hover-Zeitpunkt aufgeschoben.
- Der 1-px-Rahmen der Zeilennummernspalte verschob Datenzeilen und Text-Baseline um einen halben Pixel und erzeugte beim Scrollen ein Wackeln.
- Dies wurde korrigiert, indem beide Spalten dasselbe Box Model verwenden.
- Zebra-Streifen werden anhand des absoluten Zeilenindex bestimmt, nicht anhand der wiederverwendeten DOM-Position, damit Farben beim Wechsel des Virtualisierungsfensters nicht flackern.
- Die Funktionen, die den Aufbau eines eigenen Grids rechtfertigten, bleiben ebenfalls erhalten.
- Verschachtelte Dokumente bleiben nicht als JSON-Strings, sondern werden zu echten Unterspalten erweitert.
- Treffer in verschachtelten Pfaden werden innerhalb der Zelle hervorgehoben.
- Typisierte Werte werden direkt aus dem Grid in einen visuellen Query Builder gezogen.
- Der Aufwand, solche Funktionen in eine allgemeine Grid-Bibliothek einzubauen, war größer als die Kosten, den Renderer selbst zu besitzen.
Schritt 10: Quellcode vorhandener High-Performance-Grids lesen
- Beim Lernen von Frontend-Performance war das Lesen von Quellcode anderer Projekte die wertvollste Gewohnheit; undokumentierte Optimierungen fanden sich in öffentlichen Repositories.
- In AG Grid, einem untersuchten DOM-Grid, wurden folgende Techniken eingesetzt:
- DOM-Arbeit wird mit explizitem Zeitbudget pro Frame und priorisierter Aufgabenwarteschlange in Zeitscheiben aufgeteilt.
- Der Spalten-Viewport wird gehasht, sodass unverändertes Scrollen mit einem einzigen String-Vergleich abgeschlossen ist.
- Zeilen werden in Scrollrichtung erzeugt, damit Inhalte auf der Seite, zu der der Nutzer unterwegs ist, zuerst erscheinen.
- Neue Zellen werden zuerst erzeugt, die Zerstörung alter Zellen wird auf später verschoben, damit neue Inhalte vor verschwindenden Inhalten gezeichnet werden.
- Da Zell-Event-Listener als gemessener Engpass identifiziert wurden, wird Event Delegation auf Container-Ebene verwendet.
- Canvas-basierte Grids umgehen das DOM und zeichnen pro Frame hunderte Texte neu, ohne Layout- und Style-Neuberechnung.
- Sie können selbst bei extremer Interaktion 60 fps halten und haben eine höhere Leistungsobergrenze als DOM-Grids.
- Außerhalb des Pixelrasters gerasterter Text kann unscharf wirken.
- Auswahl funktioniert nur zellenweise, und Ellipsen erscheinen nicht, sofern man sie nicht selbst zeichnet.
- Für jede neue Zellfunktion müssen Zeichenlogik und Hit-Testing-Code ergänzt werden.
- Letztlich fiel die Wahl auf DOM, um scharfen Text, echte Textauswahl, Barrierefreiheit und schnelle Feature-Entwicklung zu erhalten.
- Auch wenn damit nicht die Geschmeidigkeit von Canvas erreicht wird, ist es ein bewusster Kompromiss unter Berücksichtigung der benötigten User Experience und Entwicklungskosten.
Noch keine Kommentare.