JSON Toolkit

Parser, rendre et diffuser de grands JSON

L’essentiel : Le parsing n'est souvent pas le goulot d'étranglement ; le rendu et les copies le sont. Optimisez selon les quatre couches « parse → parcours → rendu → transport » pour que votre effort ne soit pas gaspillé.

Intermédiaire~13 min de lecturePublié le 2026-10-30Mis à jour le 2026-10-31
Illustration de l'article Parser, rendre et diffuser de grands JSON

L'essentiel : mesurez séparément le parsing, le parcours, le rendu et le transport d'abord, puis travaillez dans cet ordre — réduisez les copies, développez à la demande avec le défilement virtuel, déplacez le travail dans un Worker, passez au flux, et seulement ensuite changez le format. Le flux ne vaut le coup que lorsque le fichier ne tient pas en mémoire ou qu'il faut un petit sous-ensemble de ses champs.

La conclusion « JSON.parse est lent » est fausse huit fois sur dix. Ce qui est réellement lent, c'est ce que vous faites après le parsing.

1. Mesurez d'abord, optimisez ensuite

Pour un même fichier JSON de 10 Mo, le temps passé dans différentes étapes diffère de plusieurs ordres de grandeur :

Étape Durée typique Coût principal
JSON.parse (natif) De quelques dizaines à un peu plus de cent millisecondes Un seul balayage linéaire plus construction d'objets
Copie profonde (structuredClone / récursive) Possiblement 1 à 3 fois le parsing Reconstruction de tout le graphe d'objets
Comparaison profonde / diff Possiblement le double encore Parcours récursif plus comparaison de chaînes
Rendu dans un arbre DOM Fige dès qu'il y a des dizaines de milliers de nœuds Layout et reflow
Concaténation de chaînes puis sérialisation Proportionnel à la taille de sortie Double la mémoire

Donc mesurez avant d'agir : dans le navigateur, enveloppez chaque étape avec performance.now(), ou utilisez console.time ; dans Node, utilisez process.memoryUsage() pour surveiller la croissance du tas. Pas de référence, pas d'optimisation.

2. Couche parsing : trois stratégies

2.1 Parsing complet (le choix par défaut)

const data = JSON.parse(text);

L'implémentation native fait le balayage et la construction d'objets en C++, donc une bibliothèque pure JS peut difficilement la battre. Utilisez-la tant que ces trois conditions tiennent :

  • Le texte tient entièrement en mémoire (généralement < 100 Mo) ;
  • La plupart des champs sont réellement nécessaires après le parsing ;
  • Le degré de non-fiabilité des données est gérable.

2.2 Parsing incrémental en flux

Avec un parseur de style SAX comme stream-json, vous pouvez extraire les champs cibles sans construire d'arbre d'objets complet :

import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';

const pipeline = createReadStream('huge.json')
  .pipe(parser())
  .pipe(pick({ filter: 'items' }))
  .pipe(streamArray());

for await (const { value } of pipeline) {
  // Process one element at a time; memory is independent of element count
  handleItem(value);
}

Coûts :

  • La complexité du code augmente nettement et la gestion d'erreurs devient plus confuse ;
  • Seul l'accès séquentiel est possible, sans accès aléatoire ;
  • C'est peu convivial pour les besoins d'agrégation globale.

Règle de décision : le flux ne vaut le coup que lorsque le fichier est trop gros pour tenir, ou qu'il faut seulement un très petit sous-ensemble de ses champs.

2.3 Changer le format

Si les données sont « un lot d'enregistrements » plutôt que « un document unique », vous auriez dû utiliser JSON Lines dès le départ. Un format délimité par lignes transforme le « flux » en « lecture ligne par ligne », ce qui abaisse d'un ordre de grandeur la complexité d'implémentation.

3. Couche parcours : une copie de moins gagne la moitié du combat

// Anti-pattern: deep copying first "for safety"
const safeCopy = structuredClone(bigData);   // Memory doubles immediately

// Good pattern: pass references directly in read-only scenarios
function renderSummary(data: OrderDocument): Summary {
  return { total: data.items.length, amount: data.total };
}

Copies cachées courantes :

  • JSON.parse(JSON.stringify(x)) utilisé comme « copie profonde » — c'est le type le plus coûteux ;
  • { ...data } ne copie qu'un niveau, mais pour un grand objet il copie quand même le tableau de références de toutes les clés de premier niveau ;
  • Array.prototype.map crée un nouveau tableau, ce qui coûte aussi cher quand il y a beaucoup d'éléments ;
  • Les proxies de réactivité de framework (le reactive de Vue) proxifient récursivement les grands objets, et la surcharge peut dépasser le parsing lui-même.

Conseil pratique : gardez les « données brutes » comme un objet immuable ordinaire et ne les enveloppez pas dans la réactivité ; calculez et mettez en cache séparément les données dérivées dont vous avez besoin pour l'affichage.

4. Couche rendu : ne fourrez pas tout l'arbre dans le DOM

Un fichier JSON de 5 Mo a environ 30 000 à 100 000 nœuds ; les rendre tous comme éléments DOM fera cesser de répondre le navigateur.

Trois approches viables :

4.1 Développer à la demande

Ne rendez que les enfants directs du nœud racine, et ne calculez le niveau suivant que lorsque le nœud est développé. C'est l'approche standard des visualiseurs d'arbre :

type TreeNode = { key: string; value: unknown; expanded: boolean };

/**
 * Returns only the currently visible nodes, avoiding building the whole tree at once.
 * @param rootNode Root node
 * @param expandedPaths Set of paths that have been expanded
 * @returns List of visible nodes
 */
function collectVisibleNodes(rootNode: TreeNode, expandedPaths: Set<string>): TreeNode[] {
  const visibleNodes: TreeNode[] = [];

  /**
   * Recursively collects the child nodes of expanded branches.
   * @param currentNode Current node
   * @param currentPath Current path
   */
  const walk = (currentNode: TreeNode, currentPath: string): void => {
    visibleNodes.push(currentNode);
    if (!expandedPaths.has(currentPath) || typeof currentNode.value !== 'object' || currentNode.value === null) {
      return;
    }
    for (const [childKey, childValue] of Object.entries(currentNode.value)) {
      walk({ key: childKey, value: childValue, expanded: false }, `${currentPath}/${childKey}`);
    }
  };

  walk(rootNode, '');
  return visibleNodes;
}

4.2 Défilement virtuel

Une fois les nœuds développés au-delà de quelques centaines, ne rendez que les quelques dizaines à l'intérieur de la fenêtre et réservez l'espace du reste avec des hauteurs fictives. C'est le plus efficace sur les vues de type liste (tableaux, journaux).

4.3 N'afficher que des résumés

Présentez d'abord « structure + type + nombre » (par exemple items: Array(12000)) ; ce n'est que lorsque l'utilisateur ouvre un élément que vous récupérez la valeur concrète. Cette astuce convient le mieux au scénario « je veux juste confirmer que les champs sont corrects ».

5. Thread principal : déplacez le travail dans un Worker quand il le faut

Le parsing, le formatage et le diff de tout ce qui dépasse 1 Mo devraient aller dans un Web Worker :

// json.worker.ts
self.onmessage = (event: MessageEvent<{ text: string }>) => {
  try {
    const parsed = JSON.parse(event.data.text);
    self.postMessage({ ok: true, data: parsed });
  } catch (parseError) {
    self.postMessage({ ok: false, message: (parseError as Error).message });
  }
};
/**
 * Parses JSON inside a Worker so the main thread is not blocked.
 * @param text JSON text
 * @returns Parse result
 */
function parseInWorker(text: string): Promise<unknown> {
  return new Promise((resolvePromise, rejectPromise) => {
    const worker = new Worker(new URL('./json.worker.ts', import.meta.url), { type: 'module' });
    worker.onmessage = (event) => {
      worker.terminate();
      if (event.data.ok) {
        resolvePromise(event.data.data);
      } else {
        rejectPromise(new Error(event.data.message));
      }
    };
    worker.postMessage({ text });
  });
}

Attention au coût de communication des Workers : postMessage utilise le clonage structuré par défaut, et cloner un objet de 10 Mo une fois prend aussi des dizaines de millisecondes. Si tout ce qu'il faut est « le parser et le remettre au thread principal », faites le gros travail à l'intérieur du Worker (parse + transformation + validation) et renvoyez un résumé de résultat.

6. Couche transport : optimisation de taille

Technique Gain typique Coût
gzip / brotli La sortie compressée fait souvent 10 % à 25 % de la taille originale Nécessite un support serveur
Suppression de l'indentation et des espaces Réduction de 20 % à 40 % Non lisible par l'humain
Abréviation des noms de champs Gains notables avec une forte imbrication Lisibilité réduite ; nécessite une table de correspondance
Nombres au lieu de chaînes d'énumération Faible Nécessite un dictionnaire
Pagination / champs à la demande Le plus grand Refonte de l'API

Pour les pages destinées aux humains, la bonne combinaison est : l'API envoie du JSON compact et compressé, et l'interface l'embellit seulement pour l'affichage. Beaucoup de sites d'outils envoient par défaut du texte embelli au frontend, expédiant 30 % d'octets en plus pour rien.

7. Comment mesurer

/**
 * Measures the median duration of a synchronous routine so a single jittery run does not skew the result.
 * @param runOnce Function under test
 * @param repeatCount Number of repetitions
 * @returns Median duration in milliseconds
 */
function measureMedian(runOnce: () => void, repeatCount = 9): number {
  const durations: number[] = [];
  for (let roundIndex = 0; roundIndex < repeatCount; roundIndex += 1) {
    const startedAt = performance.now();
    runOnce();
    durations.push(performance.now() - startedAt);
  }
  durations.sort((firstValue, secondValue) => firstValue - secondValue);
  return durations[Math.floor(durations.length / 2)];
}

Trois règles de discipline :

  1. Utilisez un échantillon fixe et ne générez jamais de données au hasard, sinon les chiffres ne sont pas comparables ;
  2. Exécutez au moins 9 fois et prenez la médiane ; la première exécution inclut le préchauffage JIT et doit être écartée ;
  3. Notez l'environnement d'exécution (version Node, CPU), sinon les comparaisons entre machines n'ont aucun sens.

8. Liste de vérification

  • [ ] Des chiffres de référence existent pour pouvoir comparer avant et après ;
  • [ ] Le parsing de tout ce qui dépasse 1 Mo s'exécute dans un Worker ;
  • [ ] Les vues arborescentes se développent à la demande et les listes utilisent le défilement virtuel ;
  • [ ] Aucune copie profonde ni enveloppe réactive inutile ;
  • [ ] JSON Lines est utilisé quand les données sont « un lot d'enregistrements » ;
  • [ ] L'API envoie du JSON compact et le formatage ne sert qu'à l'affichage.