3 Punkte von GN⁺ 2 시간 전 | Noch keine Kommentare. | Auf WhatsApp teilen
  • 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 wurden transform und opacity genutzt, 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 Ganzzahl 123.
    • 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 div mit 40 Millionen px Höhe.
    • Der Browser stellt anhand dieser Höhe native Scrollbars und Scrollverhalten bereit.
  • 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 × rowHeight platziert; transform ist dabei vorteilhafter als top.
  • 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 x passende Spalte wird per binärer Suche im Präfixsummen-Array gefunden.
    • Selbst bei 1.000 Spalten ist die Suche in Mikrosekunden erledigt.
  • 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 preventDefault nicht 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.
  • 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.
  • 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 transform und opacity; top, left, width, height, background-color usw. wecken den Main Thread.
  • Als für die Scroll-Synchronisierung von Zeilennummern und fixierten Spalten-Panels top aktualisiert wurde, kam es 60-mal pro Sekunde zu erzwungenem Layout.
    • Dies wurde durch translate3d ersetzt, wodurch dieselbe Darstellung erhalten blieb, aber die Main-Thread-Kosten entfielen.
  • Die zuvor zeilenweise implementierten Zebra-Streifen wurden durch ein einzelnes repeating-linear-gradient fü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-image der 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.
  • 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 trackBy oder key in 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 transform so verschoben, als hätten sie bereits die neue Reihenfolge; beim Ablegen werden Neuordnung und Zurücksetzen von transform im 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.

Noch keine Kommentare.