- En construisant une interface graphique de base de données pour MongoDB et PostgreSQL, il fallait prendre en charge les types BSON et JSONB, les colonnes imbriquées, la recherche, l’édition, l’épinglage et le glisser-déposer. Pour cela, environ un an a été consacré à l’optimisation de la virtualisation sur deux axes et de l’architecture de gestion d’état.
- Une table fantôme a été créée pour précalculer séparément du document source les chaînes d’affichage, les types, les chemins aplatis, l’ordre des colonnes et les résultats de recherche, puis seuls les lignes et colonnes visibles à l’écran sont rendues avec un DOM de taille constante.
- Sur le chemin critique du scroll, des écouteurs d’événements passifs,
requestAnimationFrame, des buffers, de l’hystérésis et un suivi de vitesse ont été appliqués, ettransformetopacityont été utilisés à la place des propriétés de layout pour réduire la charge sur le thread principal. - Les icônes par cellule ont été remplacées par des images de fond SVG partagées, les éditeurs n’étaient montés qu’en cas de besoin, et le pooling du DOM basé sur la position des lignes et colonnes a supprimé la création de nœuds pendant le scroll.
- Canvas offre un plafond de performance à 60 fps plus élevé que le DOM, mais il est désavantagé pour le texte, la sélection, l’accessibilité et l’extension fonctionnelle. Un design basé sur le DOM a donc été retenu pour préserver la vraie sélection de texte et une vélocité de développement élevée.
Objectifs et contraintes initiales
- Le projet a commencé avec un simple tableau bidimensionnel et des boucles imbriquées, mais a fini par devenir un travail d’optimisation intermittent étalé sur près d’un an.
- Le tableau d’une interface graphique de base de données n’est pas seulement en lecture : il doit prendre en charge de nombreux états et interactions.
- Il devait comprendre tous les types BSON de MongoDB ainsi que le JSONB de PostgreSQL et autres, et afficher des icônes colorées selon le type.
- Il distinguait les types qui changent le résultat des requêtes, comme la chaîne
"123"et l’entier123. - Il développait les documents imbriqués en véritables sous-colonnes, recherchait dans l’ensemble du chemin imbriqué et mettait en évidence les correspondances à l’intérieur des cellules.
- Il fallait des fonctions de réorganisation, redimensionnement et épinglage des colonnes, d’édition dans les cellules, et de glisser-déposer de valeurs, lignes et colonnes vers un visual query builder.
- L’état de ces fonctions n’existe pas dans les documents source et doit être conservé après le scroll, ce qui imposait une structure de rendu séparée.
Étape 1 : rendre directement tous les éléments
- La méthode consistant à parcourir les lignes et champs en boucles imbriquées pour créer toutes les cellules fonctionne avec 100 lignes, mais s’effondre à grande échelle.
- 10 000 lignes × 30 colonnes créent environ 300 000 nœuds DOM, que la détection de changements du framework parcourt encore et encore.
- Si l’on considère qu’un seul nœud DOM consomme environ 1 Ko avec les structures internes du navigateur, il faut déjà des centaines de Mo avant même les vraies données.
- Le budget par frame à 60 fps est de 16,7 ms, partagé avec le style, le layout et le paint.
- Dans l’implémentation réelle, la tentative de rendre 1 000 lignes avec environ 20 colonnes a échoué.
- Pour ne rendre qu’une partie, il fallait suivre séparément les lignes et colonnes visibles, leur ordre, le développement des champs imbriqués et les résultats de recherche.
Étape 2 : séparer l’état d’affichage avec une table fantôme
- Les documents source sont imbriqués et de types variés, donc difficiles à utiliser directement comme entrée de rendu.
- MongoDB contient des valeurs BSON comme ObjectId, Decimal128, timestamp et binaire.
- Les données SQL incluent du JSONB et des timestamps avec fuseau horaire, et chaque cellule doit décider de son format.
- Si le format est déterminé dans la boucle de rendu, le même coût se répète à chaque frame, tandis que les données source ne contiennent pas non plus l’état du tableau comme l’ordre des colonnes, l’état de développement ou les résultats de recherche.
- La table fantôme sert de point de référence pour l’état réellement affiché par le tableau sans modifier les documents source.
- Elle est construite une fois au chargement, mise à jour quand l’état change, et ne change pas pendant le scroll.
- Pour chaque cellule, elle précalcule la chaîne d’affichage tronquée, le type déterminé et le chemin aplati.
- Les chaînes d’affichage sont limitées pour éviter qu’un document de 16 Mo ne génère tel quel une chaîne de 16 Mo dans l’état de rendu.
- Le type détermine l’icône, l’éditeur et le mode de recherche.
- Des chemins aplatis comme
"address.geo.lat"sont utilisés comme clés afin d’éviter de reparcourir l’arbre à chaque fois.
- Quand un objet imbriqué est développé, ses sous-chemins sont promus en vraies colonnes, et l’ordre des colonnes, l’état de développement ainsi que les résultats de tri et de recherche sont stockés dans la même structure.
- À ce stade, le nombre de nœuds DOM ne diminue pas encore, mais les bases sont posées pour calculer rapidement plus tard l’ordre et la largeur des colonnes.
Étape 3 : virtualisation verticale et zone de scroll fantôme
- La virtualisation verticale ne rend que les lignes du viewport et une petite marge de buffer, en transformant le reste de la hauteur en zone factice.
- La zone fantôme (phantom) est un conteneur interne d’une hauteur de
rowCount × rowHeight.- Avec 1 million de lignes × 40 px, cela crée un
divde 40 millions de px de haut, presque vide en contenu. - Le navigateur s’appuie sur cette hauteur pour fournir une barre de scroll native et le comportement de défilement.
- Avec 1 million de lignes × 40 px, cela crée un
- La plage affichée est calculée ainsi :
firstRow = floor(scrollTop / rowHeight)lastRow = floor((scrollTop + viewportHeight) / rowHeight)- Des lignes de buffer sont ajoutées des deux côtés pour éviter de re-rendre au moindre déplacement.
- Les lignes affichées sont placées dans un conteneur slab à la position
firstRow × rowHeight, ettransformest préférable àtop. - L’utilisateur voit 1 million de lignes, mais le DOM n’en contient qu’environ 40.
- Mais avec 300 colonnes, même 40 lignes produisent encore 12 000 cellules, il fallait donc aussi virtualiser les colonnes.
Étape 4 : virtualisation horizontale avec largeurs variables
- Les collections de bases documentaires peuvent contenir des centaines de champs : une virtualisation des colonnes était donc également nécessaire.
- Comme les largeurs de colonne ne sont pas constantes, il faut utiliser des sommes cumulées et une recherche binaire au lieu d’une simple division par valeur fixe.
- Une somme cumulée de la forme
position[n] = width[0] + ... + width[n-1]permet d’obtenir la coordonnée x de chaque colonne par un simple accès tableau. - La colonne correspondant à l’offset de scroll
xest trouvée par recherche binaire dans le tableau des sommes cumulées. - Même avec 1 000 colonnes, la recherche se termine en quelques microsecondes.
- Une somme cumulée de la forme
- Les sommes cumulées ne sont reconstruites que lorsque les largeurs changent réellement, par exemple lors du redimensionnement, masquage ou réordonnancement des colonnes, jamais pendant le scroll.
- Un buffer d’environ 200 px est gardé à gauche et à droite afin de pré-rendre les colonnes avant qu’elles n’entrent à l’écran.
- Après virtualisation sur deux axes, la zone rendue reste autour de 40 lignes × 12 colonnes, quelle que soit la taille des données.
- Même sur une collection de 500 colonnes, seulement une douzaine de colonnes existent à un instant donné, ce qui n’a pas augmenté le temps de chargement.
- Le volume à rendre a diminué, mais il restait le problème de recalculer la plage à chaque événement de scroll, parfois des centaines de fois par seconde.
Étape 5 : gérer le budget de frame sur le chemin du scroll
- Le traitement du scroll partage le budget de 16,7 ms par frame avec le style, le layout et le paint : il faut donc y faire le minimum.
- Des écouteurs d’événements passifs sont enregistrés en dehors de la détection de changements du framework.
- Cela indique au navigateur qu’il n’y aura pas d’appel à
preventDefault, ce qui permet au compositeur de déplacer les pixels sans attendre JavaScript. - L’événement de scroll lui-même ne déclenche pas les vérifications de rendu du framework.
- Cela indique au navigateur qu’il n’y aura pas d’appel à
- Plusieurs événements sont regroupés en une seule opération par frame.
- Seule la dernière position de scroll est enregistrée et un unique callback
requestAnimationFrameest planifié. - Même 12 événements sur une frame sont traités avec un seul calcul de plage.
- Seule la dernière position de scroll est enregistrée et un unique callback
- Une sortie anticipée de rendu rapide, inspirée du code source de Handsontable, est appliquée.
- Si la nouvelle plage affichée reste dans le buffer déjà rendu, deux comparaisons d’entiers suffisent avant de retourner immédiatement.
- De l’hystérésis est appliquée aux limites du buffer.
- Une reconstruction est déclenchée lorsque la plage affichée s’approche à environ 40 px du bord du buffer.
- Le buffer d’environ 200 px est recentré sur la nouvelle position afin d’éviter l’apparition de bords vides ou des reconstructions répétées à la frontière.
- Un détecteur de vitesse a été ajouté pour suivre les px/ms entre événements.
- Dans l’implémentation, au-delà de 10 px/ms, le mouvement est considéré comme un flick rapide et le rendu est suspendu.
- Le scroll natif est alors laissé se déplacer sur la zone fantôme, puis le slab est rempli à nouveau quand la vitesse se stabilise.
- Même après réduction du travail JavaScript, des baisses de frame subsistaient : le layout et le paint, avec styles, rayures et icônes, devenaient le nouveau goulot d’étranglement.
Étape 6 : supprimer le coût des propriétés de layout
- Le thread principal du navigateur gère le style, le layout et le paint, tandis que le compositeur déplace sur le GPU des calques déjà dessinés.
- Les propriétés dont l’animation peut être gérée par le compositeur sont
transformetopacity, tandis quetop,left,width,height,background-color, etc. réveillent le thread principal. - Lorsqu’on mettait à jour
toppour synchroniser le scroll des numéros de ligne et du panneau de colonnes épinglées, cela provoquait 60 layouts forcés par seconde.- Le remplacement par
translate3da conservé le même rendu visuel tout en supprimant ce coût sur le thread principal.
- Le remplacement par
- Les rayures, auparavant implémentées avec un fond par ligne, ont été remplacées par un unique
repeating-linear-gradientsur tout le corps du tableau.- La hauteur de ligne est transmise via une variable CSS.
- Le navigateur n’a plus qu’à rasteriser une tuile de deux lignes et la recopier depuis une texture GPU.
- Les liaisons de classes par ligne disparaissent et le corps comme le panneau figé partagent la même tuile, ce qui évite aussi les décalages de couleur.
- Les séparateurs de colonnes ne sont dessinés que sur la hauteur du slab actuellement rendu, et non sur toute la zone fantôme de 40 millions de px.
- Le scroll normal était devenu fluide, mais aux moments où la fenêtre changeait pour créer de nouvelles cellules, du DOM inutile s’accumulait encore cellule par cellule.
Étape 7 : alléger les icônes et les éditeurs
- Dans une grille de base de données, les icônes de type ne sont pas décoratives : elles servent à distinguer le sens de valeurs comme ObjectId, chaîne, entier ou JSONB.
- La première implémentation ajoutait un élément d’icône de police dans chaque cellule, mais ces nœuds DOM supplémentaires et le pipeline de rendu de glyphes coûtaient cher.
- Les icônes ont été déplacées vers le
background-imagede la cellule elle-même et encodées en URI de données SVG.- Toutes les cellules d’un même type référencent la même chaîne URI.
- Le navigateur ne rasterise alors l’icône d’un type qu’une seule fois et la recopie depuis une texture GPU mise en cache.
- Cela permet d’afficher des décorations répétées sans nœud DOM séparé.
- Pour l’édition des cellules, une stratégie de rendu hybride a été adoptée.
- En temps normal, une cellule n’utilise que du texte simple et un seul
span. - Ce n’est qu’au double-clic qu’un composant d’édition lourd, conscient du type, est monté au-dessus de cette cellule comme un portail.
- En temps normal, une cellule n’utilise que du texte simple et un seul
- Si chaque cellule utilisait un composant du framework, le coût de création d’instances s’accumulerait, et combiner une bibliothèque de grille avec des composants de rendu de cellule peut dégrader les performances.
- Les cellules elles-mêmes étaient devenues légères, mais à l’apparition d’une nouvelle colonne, le framework recréait encore les autres cellules pourtant inchangées.
Étape 8 : réutilisation du DOM basée sur la position
- Des mécanismes de suivi comme
trackBydans Angular oukeydans React et Vue déterminent si un élément existant doit être réutilisé ou détruit puis recréé quand la fenêtre se déplace. - Si les lignes ne sont pas rebondées à de nouvelles données mais remplacées à chaque fois, il faut sans cesse recréer composants, nœuds DOM et écouteurs d’événements.
- Les lignes et colonnes sont suivies non pas par leur valeur de données, mais par leur position à l’écran.
- Les mêmes quelque 40 éléments de ligne et de colonne sont conservés, et seul leur contenu est remplacé par les nouvelles valeurs.
- La grille fonctionne comme un pool d’objets et n’alloue plus de nouveau DOM pendant le scroll.
- À ce stade, le coût du scroll sur deux axes et des reconstructions de fenêtre était résolu, mais il restait des problèmes de finition comme un tremblement d’une frame au dépôt d’une colonne ou un demi-pixel de décalage sur les numéros de ligne.
Étape 9 : finaliser les interactions de détail
- Pendant le glisser de colonne,
transformdéplace les colonnes existantes comme si elles étaient déjà dans le nouvel ordre, puis le réordonnancement et la réinitialisation detransformsont traités dans le même passage de rendu au moment du dépôt.- La dernière frame de glisser et la première frame réordonnée sont rendues identiques pixel par pixel, de façon à rendre la transition invisible.
- Les infobulles ne sont pas liées cellule par cellule : un seul écouteur de survol délégué sur le conteneur s’en charge.
- Le calcul de l’infobulle n’est fait que pour la cellule sous le curseur.
- Même la génération coûteuse de chaînes, comme l’affichage joliment formaté d’une valeur JSONB SQL, est reportée au moment réel du hover.
- La bordure de 1 px de la colonne des numéros de ligne décalait d’un demi-pixel les lignes de données et la ligne de base du texte, provoquant un tremblement au scroll.
- Le problème a été corrigé en faisant utiliser aux deux colonnes le même box model.
- Les rayures sont déterminées selon l’index absolu de ligne plutôt que selon la position DOM réutilisée, ce qui évite tout scintillement de couleur quand la fenêtre de virtualisation change.
- Les fonctionnalités qui justifiaient la construction d’une grille maison ont aussi été conservées.
- Les documents imbriqués sont développés en vraies sous-colonnes au lieu d’être laissés comme chaînes JSON.
- Les correspondances trouvées dans les chemins imbriqués sont mises en évidence à l’intérieur des cellules.
- Les valeurs typées sont glissées directement depuis la grille vers un visual query builder.
- Le coût d’ajouter ce type de fonctions à une bibliothèque de grille générique était plus élevé que celui de posséder directement le renderer.
Étape 10 : lire le code source des grilles haute performance existantes
- Pour apprendre la performance frontend, l’habitude la plus précieuse a été de lire le code source d’autres projets, où des optimisations non documentées restaient visibles dans les dépôts publics.
- AG-Grid, la grille DOM étudiée, utilisait notamment les techniques suivantes :
- découpage temporel du travail DOM avec un budget explicite par frame et une file de tâches priorisées ;
- hash du viewport de colonnes pour réduire un scroll inchangé à une seule comparaison de chaîne ;
- création des lignes dans le sens du scroll pour afficher d’abord le contenu vers lequel l’utilisateur se dirige ;
- création des nouvelles cellules en premier et destruction des anciennes plus tard afin que le nouveau contenu soit dessiné avant l’ancien qui disparaît ;
- délégation d’événements au niveau du conteneur, car les écouteurs par cellule étaient un goulot d’étranglement mesuré.
- Les grilles basées sur Canvas contournent le DOM et redessinent à chaque frame des centaines de textes sans recalcul de layout ni de style.
- Elles peuvent garder 60 fps même sous des manipulations extrêmes, avec un plafond de performance supérieur aux grilles DOM.
- Le texte rasterisé hors de la grille de pixels peut devenir flou.
- La sélection ne fonctionne qu’au niveau de la cellule, et même l’ellipse de troncature n’apparaît pas si elle n’est pas dessinée explicitement.
- Chaque nouvelle fonctionnalité de cellule exige d’ajouter du code de dessin et du code de hit testing.
- Au final, le choix s’est porté sur le DOM afin de préserver un texte net, une vraie sélection de texte, l’accessibilité et un développement rapide des fonctionnalités.
- Même sans atteindre la fluidité d’un Canvas, c’est un compromis explicite en fonction de l’expérience utilisateur nécessaire et du coût de développement.
Aucun commentaire pour le moment.