Pourquoi les grands arbres JSON saccadent : défilement virtuel et stratégies de repli
L’essentiel : La saccade commence à la création des nœuds DOM, pas au défilement. Couvre le rendu avec repli d'abord, les listes virtuelles, le surlignage de recherche et les frontières Worker, avec des métriques.

L'essentiel : découpez « lent » en parsing, création de nœuds, layout et peinture, et interaction, puis mesurez chaque étape. Implémentez dans cet ordre — repli par défaut, ajout d'une liste virtuelle, déplacement du calcul dans un Worker, et limitation du surlignage aux lignes visibles. Sauter directement à une liste virtuelle sans replier ne fait que réduire l'ensemble visible ; le recalcul complet de l'arbre vous coûte encore.
La saccade vient généralement des centaines de milliers de nœuds DOM que vous avez créés pour ces 5 Mo, pas des 5 Mo eux-mêmes.
1. Découper « lent » en quatre étapes
Quand un utilisateur dit « ça fige à l'ouverture », le problème peut vivre dans quatre étapes complètement différentes, chacune avec un remède différent :
| Étape | Déclencheur typique | Seuil perceptible | Coût dominant |
|---|---|---|---|
| Parser et construire les objets | Ouvrir un fichier, coller du texte | Toute tâche au-delà de 50 ms | Balayage de chaînes, construction du graphe d'objets |
| Créer les nœuds DOM | Rendre tout l'arbre d'un coup | Dizaines de milliers de nœuds | Allocation d'éléments, de nœuds de texte, d'objets de style |
| Layout et peinture | Premier affichage, défilement | Toute frame au-delà de 16,7 ms | Géométrie, reflow, repaint |
| Réponse à l'interaction | Développer, replier, rechercher | Au-delà de 100 ms paraît retardé | Ré-aplatissement et re-rendu |
Le thread principal du navigateur fait une chose à la fois. Si une étape l'occupe plus de 50 ms, le défilement et la frappe perdent des frames ; plusieurs tâches de ce type à la suite et la page semble morte. La première optimisation n'est donc pas requestAnimationFrame ; c'est de mesurer quelle étape dépense réellement le temps.
2. Le nombre de nœuds est le véritable ennemi
Les visualiseurs d'arbre multiplient les nombres de nœuds par conception. Une seule ligne JSON devient généralement « indentation + clé + deux-points + valeur + virgule + chevron », donc une estimation conservatrice est de 3 à 6 nœuds DOM par ligne :
| Taille du fichier | Lignes approximatives | Nœuds DOM de la vue arborescente (estimation) | Ce que donne un rendu en une fois |
|---|---|---|---|
| 100 Ko | 2 500 | 10 000 | Généralement tolérable |
| 1 Mo | 25 000 | 100 000 | Nettement saccadé, parfois des secondes d'écran blanc |
| 5 Mo | 125 000 | 500 000 | Très probablement non réactif |
| 20 Mo | 500 000 | Plus de 2 millions | Mémoire et layout s'effondrent tous deux |
Chaque élément coûte plus que quelques dizaines d'octets d'objet : il porte des règles de style, une boîte de layout et des instructions de peinture. Une fois créé, un nœud coûte encore du layout et du style même s'il est ensuite masqué par un parent replié, tant qu'il n'a pas réellement été supprimé. « Tout rendre, puis masquer » n'est pas une optimisation ; cela ne fait que reporter le coût au prochain reflow.
3. Replier d'abord, virtualiser ensuite
Le défilement virtuel résout « trop de lignes visibles ». Le repli résout « nous ne devrions pas avoir besoin de voir autant de lignes du tout ». L'ordre compte : pour un document d'un million de lignes développé par défaut, même une liste virtuelle parfaite doit recalculer tout l'arbre quand l'utilisateur clique sur « tout replier ».
Une stratégie de repli viable :
- Ne développez par défaut que les deux premiers niveaux. La racine et ses champs immédiats sont visibles ; les niveaux plus profonds affichent un résumé comme
items: Array(12000); - Mettez le type et le nombre dans le résumé. Les utilisateurs qui vérifient « les noms de champs sont-ils corrects » ont rarement besoin de dix mille enregistrements ;
- Mettez en cache les lignes par sous-arbre. Chaque nœud développé possède son tableau de lignes aplaties ; le repli le jette, et le développement ne recalcule que ce sous-arbre ;
- Ne rescannez jamais tout l'arbre à chaque bascule. Si le développement se produit à la ligne 3 000, remplacez seulement cette plage et laissez les lignes environnantes intactes.
/**
* 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;
}
Le schéma à éviter est « un développement, un relayout complet ». Reparcourir tout le graphe d'objets à chaque clic fait croître le coût linéairement avec le document. Avec un cache par sous-arbre, un seul développement coûte seulement ce que coûte ce sous-arbre.
4. Listes virtuelles : ne rendre que la fenêtre
Une fois que le repli a réduit le nombre de lignes, le reste peut encore représenter des dizaines de milliers de lignes. Une liste virtuelle fixe la hauteur du conteneur à « nombre total de lignes × hauteur de ligne fixe » et ne crée des éléments que pour la fenêtre plus un petit tampon au-dessus et en dessous.
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,
};
}
Contraintes non négociables :
- La hauteur de ligne doit être fixe. C'est seulement alors que
scrollTop / ROW_HEIGHTdonne directement l'index. Autoriser des lignes qui passent à la ligne oblige à mesurer et mettre en cache chaque hauteur, ce qui ajoute un ordre de grandeur en complexité et en saccades ; - Décalez avec
transformou un positionnement absolu. Ne poussez pas le contenu vers le bas avec un élément espaceur, sinon chaque défilement mute le layout ; - Utilisez des clés stables. Clé les lignes par chemin, pas par index de tableau, sinon développer une ligne remonte toutes les lignes suivantes ;
- Ne faites que du calcul dans le gestionnaire de défilement. Déférez le rendu à
requestAnimationFrameet protégez-vous contre les ordonnancements dupliqués ; - Gardez un petit tampon. Cinq à dix lignes de chaque côté suffisent ; doubler le tampon double les nœuds DOM et le travail de style.
Avec une fenêtre de 800 pixels et des lignes de 24 pixels, environ 34 lignes sont visibles, et même avec le tampon vous rendez environ cinquante. Le document sous-jacent peut contenir cent mille lignes tandis que le DOM en contient régulièrement quelques centaines d'éléments, ce qui garde le coût de layout constant.
5. Replier, développer et ancrage du défilement
Une fois que l'index de ligne devient la seule coordonnée, quelques détails d'interaction se cassent :
- Préservez l'ancre visuelle lors d'un repli au-dessus de la fenêtre. Les lignes en dessous remontent ; si
scrollTopn'est pas ajusté dans la même validation, le contenu semble sauter. Enregistrez le chemin de la ligne d'ancre et restaurez la position de défilement avec son nouvel index. - Développez les ancêtres avant de sauter. Quand une recherche atteint un champ profond, développez d'abord tous les parents, puis calculez l'index de la ligne cible, puis défilez et surlignez. Sautez la première étape et la ligne cible n'est même pas dans le tableau visible.
- Pilotez la navigation clavier depuis les lignes visibles. Haut et bas déplacent l'index de ligne ; gauche et droite développent et replient. Ne parcourez jamais tout l'arbre dans un gestionnaire de touche.
- Regroupez les mises à jour en une seule frame. En développant plusieurs niveaux à la suite, fusionnez les changements de tableau et rendez une fois, sinon chaque niveau déclenche un reflow complet.
6. Recherche et surlignage : calculer une fois, afficher peu
La recherche est le piège de performance le plus fréquemment négligé dans un visualiseur d'arbre. Si chaque frappe parcourt tout le graphe d'objets et reconstruit la liste des résultats, un document de cent mille nœuds fige instantanément.
Une approche par couches :
- Debouncez la saisie de 150 à 250 ms et étiquetez chaque recherche d'un numéro de séquence pour écarter les résultats périmés ;
- Scannez dans un Worker. Parcourir un graphe d'objets est du calcul pur sans dépendance au DOM, donc cela ne doit pas bloquer la frappe ;
- Ne stockez que les chemins et les indices de ligne. Ne copiez pas chaque nœud correspondant et ne pré-construisez pas de HTML surligné ;
- Ne surlignez que les lignes visibles. La fenêtre contient quelques dizaines de lignes ; appliquez les styles par chemin correspondant au rendu ;
- Réutilisez « développer les ancêtres, puis localiser l'index » pour les sauts, le même chemin que le clic sur un chevron.
Un détail de sécurité : les clés et valeurs dans un visualiseur d'arbre sont des données utilisateur. Construire du HTML surligné à partir d'elles exige un échappement, sinon une chaîne contenant du balisage devient un XSS. Un visualiseur JSON n'a pas besoin de texte enrichi, donc des nœuds de texte plus des noms de classes sont à la fois plus sûrs et plus rapides.
7. Coloration syntaxique : CodeMirror et Monaco sont des outils différents
Une vue arborescente et un éditeur de texte sont des modèles de rendu différents, donc « lequel est plus rapide » n'est pas une comparaison directe :
| Dimension | CodeMirror 6 | Monaco Editor |
|---|---|---|
| Positionnement | Cœur d'éditeur modulaire | Éditeur IDE complet |
| Rendu | Basé sur la fenêtre | Basé sur la fenêtre, mais modèles et décorations plus lourds |
| Parsing | Parsing incrémental Lezer sur les plages modifiées | Tokenizer Monarch, ligne par ligne |
| Grands fichiers | Dépend des extensions ; léger par défaut | Complet en fonctionnalités, mémoire et démarrage plus coûteux |
| Pliage et recherche | Extensions séparées, chargées à la demande | Intégrés, avec surcharge permanente |
| Piège typique | Trop d'extensions rétablissent le coût | Minimap, sticky scroll et décorations empilées |
Deux conclusions pratiques :
- Une vue arborescente n'a pas besoin d'un tokenizer complet. Donnez aux clés, chaînes, nombres et booléens une couleur chacun avec de simples nœuds de texte et des classes CSS ; cela coûte un ordre de grandeur de moins que d'exécuter une coloration syntaxique ;
- Ne montez pas une vue arborescente et un éditeur en même temps. Maintenir deux modèles pour un même document double la mémoire ; détruisez l'ancienne vue au lieu de la masquer.
Si un éditeur appartient vraiment au visualiseur, CodeMirror 6 convient à « lecture seule, grand fichier, surlignage local », tandis que Monaco convient à la complétion, aux diagnostics et à l'édition multi-curseur. Mesurez le temps de premier affichage et la mémoire résidente avant de lire la liste des fonctionnalités. Pour une comparaison plus approfondie des éditeurs et des outils CLI, voir Éditeurs, outils en ligne de commande et automatisation.
8. Frontières Worker : sortir parsing et construction de lignes du thread principal
L'autre moitié des saccades de rendu est le parsing sur le thread principal. Pour un JSON de plus de 1 Mo, le parsing, le formatage et la construction des lignes aplaties appartiennent à un Web Worker ; le thread principal ne reçoit que des lignes et peint la fenêtre.
Les Workers ne sont pas gratuits : postMessage utilise le clonage structuré, donc renvoyer un graphe d'objets de 50 Mo au thread principal construit une seconde copie et double le pic. La bonne frontière est parser et aplatir dans le Worker, renvoyer seulement les lignes dont le rendu a besoin :
worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
rows = event.data.rows;
requestAnimationFrame(() => renderViewport());
};
worker.postMessage({ text });
Si les données de lignes sont encore volumineuses, compressez les lignes en tableaux parallèles (chemin, type, offset de valeur), placez-les dans un ArrayBuffer, et transférez la propriété pour que le thread principal ne paie pas de copie. Le coût est d'écrire votre propre sérialiseur, ce qui ne vaut que pour des millions de lignes.
La règle de la frontière est simple : le thread principal ne devrait faire que le travail qui doit toucher le DOM. Le parsing, la recherche, le tri, le formatage et le diff peuvent tous s'en aller. Le rendu, le défilement, la saisie et le focus restent sur le thread principal, et chaque frame doit tenir dans le budget de travail.
9. Métriques et ordre d'implémentation
L'optimisation ne peut pas s'arrêter à « ça semble plus rapide ». L'ensemble suivant peut aller directement dans une liste d'acceptation :
| Métrique | Cible | Comment mesurer |
|---|---|---|
| Première frame visible | Moins de 300 ms | performance.now() de l'ouverture au premier affichage |
| Tâches longues | Aucune au-delà de 50 ms | PerformanceObserver surveillant longtask |
| Temps de frame au défilement | Stable sous 16,7 ms | Enregistrement Performance dans DevTools |
| Éléments DOM dans la fenêtre | Moins de 2 000 | document.querySelectorAll('*').length |
| Réponse développement / repli | Moins de 50 ms | Horodatages autour de l'interaction |
| Recherche sur 100 000 lignes | Moins de 300 ms, dans un Worker | Temps dans le Worker et retour de la durée |
| Tas résident | Moins de 3 fois la taille du texte | Snapshot de tas dans DevTools Memory |
Mesurez avec un vrai fichier plutôt qu'avec de petits objets générés uniformément ; la forme des données compte, et les chaînes longues, l'imbrication profonde et les tableaux très larges produisent tous des courbes différentes. Pour le bilan du graphe d'objets lui-même, voir Le bilan mémoire JSON.
Implémentez dans cet ordre :
- Mesurez une référence : temps de premier rendu, nombre de nœuds, temps de frame au défilement ;
- Repliez par défaut : développez deux niveaux, montrez le type et le nombre dans les résumés ;
- Liste virtuelle : hauteur de ligne fixe, clés stables, fenêtre plus un petit tampon ;
- Déplacez le calcul : parsing, recherche et aplatissement dans un Worker ;
- Limitez le surlignage : ne stylez que les lignes visibles, évitez de repeindre tout l'arbre ;
- Ajoutez des garde-fous : au-delà d'un seuil, proposez « afficher les N premières lignes » ou exigez un filtre de recherche.
Chaque étape partage un objectif : réduire les nœuds et les objets qui doivent exister au même moment, au lieu de demander au navigateur de travailler plus fort dessus. Le contexte complet de la stratégie par couches est dans Parser, rendre et diffuser de grands JSON.
Le défilement virtuel peut-il corriger les saccades au parsing ?
Non. Le défilement virtuel ne réduit que les nœuds DOM. Si le parsing se fait encore sur le thread principal, la page saccade encore. Déplacez le parsing dans un Worker pour les documents de plus de 1 Mo et traitez les deux problèmes séparément.
Tout est développé et ça saccade encore. Qu'est-ce qui ne va pas ?
Soit la liste virtuelle n'est pas réellement active, soit chaque développement rescanne tout l'arbre. Vérifiez si des lignes hors écran créent encore du DOM, et si le développement ne recalcule que le sous-arbre concerné.
CodeMirror ou Monaco pour un visualiseur JSON ?
Préférez CodeMirror 6 pour la lecture seule, les grands fichiers et le surlignage local ; son chargement modulaire à la demande est plus léger. Choisissez Monaco quand il faut de la complétion, des diagnostics et de l'édition multi-curseur. Avec l'un ou l'autre, évitez de monter une vue arborescente et un éditeur pour le même document en même temps.
Pourquoi la position de défilement est-elle fausse après un saut de recherche ?
Généralement parce que la vue a défilé avant le développement. Développez d'abord tous les ancêtres, puis définissez la position de défilement à partir du nouvel index de ligne, et surlignez en dernier. Clé les lignes par chemin plutôt que par index pour que le développement ne décale pas chaque index.
FAQ
Comment savoir de quelle étape vient la saccade ?
Découpez « lent » en parsing et construction d'objets, création de nœuds DOM, layout et peinture, et réponse à l'interaction, et chronométrez chaque étape avec performance.now() de l'ouverture au premier affichage. Surveillez ensuite longtask avec PerformanceObserver — toute tâche au-delà de 50 ms fait perdre des frames, et un enregistrement Performance DevTools montre si le temps de frame au défilement reste sous 16,7 ms.
Pourquoi rendre tout l'arbre d'un coup blanchit-il l'écran ?
Un document de 5 Mo fait environ 125 000 lignes, et une vue arborescente transforme généralement chaque ligne en 3 à 6 nœuds DOM — environ 500 000 éléments d'un coup. Chaque élément porte aussi des règles de style, une boîte de layout et des instructions de peinture, donc plusieurs tâches de thread principal au-delà de 50 ms à la suite font paraître la page morte. Les nœuds masqués par un parent replié continuent de coûter du layout, donc « tout rendre, puis masquer » n'est pas une optimisation.
Faut-il renvoyer un graphe d'objets de 50 Mo depuis le Worker ?
Non. postMessage utilise le clonage structuré, donc le côté récepteur construit une seconde copie et le pic double. La bonne frontière est de parser et aplatir dans le Worker et de renvoyer seulement les lignes dont le rendu a besoin ; à des millions de lignes, vous pouvez empaqueter des tableaux parallèles (chemin, type, offset de valeur) dans un ArrayBuffer et transférer la propriété.