JSON Toolkit

Análisis, renderizado y streaming de JSON grande

En resumen: A menudo el análisis no es el cuello de botella; lo son el renderizado y las copias. Optimiza a lo largo de las cuatro capas "analizar → recorrer → renderizar → transportar" para no desperdiciar esfuerzo.

Intermedio~13 min de lecturaPublicado el 2026-10-30Actualizado el 2026-10-31
Portada de Análisis, renderizado y streaming de JSON grande

Conclusión: mide primero por separado el análisis, el recorrido, el renderizado y el transporte, y luego trabaja en este orden: recorta copias, expande bajo demanda con desplazamiento virtual, mueve el trabajo a un Worker, cambia a streaming y solo entonces cambia el formato. El streaming merece la pena solo cuando el archivo no cabe en memoria o cuando necesitas un subconjunto pequeño de sus campos.

La conclusión "JSON.parse es lento" está equivocada ocho veces de cada diez. Lo que realmente es lento es lo que haces después de analizar.

1. Medir primero, luego optimizar

Para el mismo archivo JSON de 10 MB, el tiempo dedicado a las distintas etapas difiere en órdenes de magnitud:

Etapa Duración típica Coste principal
JSON.parse (nativo) De decenas de milisegundos a algo más de cien Un único escaneo lineal más la construcción de objetos
Copia profunda (structuredClone / recursiva) Posiblemente 1-3 veces el análisis Reconstruir el grafo de objetos entero
Comparación profunda / diff Posiblemente el doble otra vez Recorrido recursivo más comparación de cadenas
Renderizado en un árbol del DOM Se congela en cuanto hay decenas de miles de nodos Layout y reflujo
Concatenación de cadenas y luego serialización Proporcional al tamaño de salida Duplica la memoria

Así que mide antes de actuar: en el navegador, envuelve cada etapa con performance.now(), o usa console.time; en Node, usa process.memoryUsage() para vigilar el crecimiento del heap. Sin línea base, no hay optimización.

2. Capa de análisis: tres estrategias

2.1 Análisis completo (la opción por defecto)

const data = JSON.parse(text);

La implementación nativa hace el escaneo y la construcción de objetos en C++, así que una biblioteca en JS puro difícilmente la supera. Úsala mientras se cumplan estas tres condiciones:

  • El texto cabe entero en memoria (normalmente < 100 MB);
  • La mayoría de los campos se necesitan realmente después de analizar;
  • El grado de falta de confianza en los datos es manejable.

2.2 Análisis incremental en streaming

Con un analizador de estilo SAX como stream-json, puedes extraer los campos objetivo sin construir un árbol de objetos completo:

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) {
  // Procesa un elemento a la vez; la memoria es independiente del número de elementos
  handleItem(value);
}

Costes:

  • La complejidad del código aumenta notablemente y el manejo de errores se vuelve más engorroso;
  • Solo es posible el acceso secuencial, sin acceso aleatorio;
  • No es amigable con requisitos que necesitan agregación global.

Regla de decisión: el streaming solo compensa cuando el archivo es demasiado grande para caber, o cuando necesitas únicamente un subconjunto muy pequeño de sus campos.

2.3 Cambiar el formato

Si los datos son "un lote de registros" en lugar de "un único documento", deberías haber usado JSON Lines desde el principio. Un formato delimitado por líneas convierte el "streaming" en "lectura línea a línea", bajando la complejidad de implementación en un orden de magnitud.

3. Capa de recorrido: una copia menos gana la mitad de la batalla

// Antipatrón: copiar en profundidad primero "por seguridad"
const safeCopy = structuredClone(bigData);   // La memoria se duplica de inmediato

// Buen patrón: pasar referencias directamente en escenarios de solo lectura
function renderSummary(data: OrderDocument): Summary {
  return { total: data.items.length, amount: data.total };
}

Copias ocultas comunes:

  • JSON.parse(JSON.stringify(x)) usado como "copia profunda": es el tipo más caro;
  • { ...data } copia solo un nivel, pero para un objeto grande sigue copiando el array de referencias de todas las claves de nivel superior;
  • Array.prototype.map crea un array nuevo, lo que también resulta costoso cuando hay muchos elementos;
  • Los proxies de reactividad de frameworks (reactive de Vue) proxifican objetos grandes de forma recursiva, y el sobrecoste puede ser mayor que el propio análisis.

Consejo práctico: mantén los "datos en bruto" como un objeto plano inmutable y no los envuelvas en reactividad; calcula y cachea por separado los datos derivados que necesitas mostrar.

4. Capa de renderizado: no metas todo el árbol en el DOM

Un archivo JSON de 5 MB tiene aproximadamente entre 30.000 y 100.000 nodos; renderizarlos todos como elementos del DOM hará que el navegador deje de responder.

Tres enfoques viables:

4.1 Expandir bajo demanda

Renderiza solo los hijos directos del nodo raíz y calcula el siguiente nivel solo cuando se expande el nodo. Este es el enfoque estándar de los visores de árbol:

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

/**
 * Devuelve solo los nodos visibles actualmente, evitando construir todo el árbol de golpe.
 * @param rootNode Nodo raíz
 * @param expandedPaths Conjunto de rutas que se han expandido
 * @returns Lista de nodos visibles
 */
function collectVisibleNodes(rootNode: TreeNode, expandedPaths: Set<string>): TreeNode[] {
  const visibleNodes: TreeNode[] = [];

  /**
   * Recoge de forma recursiva los nodos hijos de las ramas expandidas.
   * @param currentNode Nodo actual
   * @param currentPath Ruta actual
   */
  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 Desplazamiento virtual

Una vez que los nodos expandidos superan unos cientos, renderiza solo las pocas docenas dentro del viewport y reserva espacio para el resto con alturas de marcador de posición. Es más eficaz en vistas de tipo lista (tablas, logs).

4.3 Mostrar solo resúmenes

Presenta primero "estructura + tipo + recuento" (por ejemplo items: Array(12000)); solo cuando el usuario abre un elemento obtienes el valor concreto. Este truco funciona mejor para el escenario "solo quiero confirmar que los campos son correctos".

5. Hilo principal: mover el trabajo a un Worker cuando toca

El análisis, el formateo y el diff de cualquier cosa de más de 1 MB deberían ir a 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 });
  }
};
/**
 * Analiza JSON dentro de un Worker para que el hilo principal no se bloquee.
 * @param text Texto JSON
 * @returns Resultado del análisis
 */
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 });
  });
}

Ten en cuenta el coste de comunicación de los Workers: postMessage usa clonación estructurada por defecto, y clonar una vez un objeto de 10 MB también tarda decenas de milisegundos. Si lo único que necesitas es "analizarlo y pasarlo al hilo principal", haz el trabajo pesado dentro del Worker (analizar + transformar + validar) y devuelve un resumen del resultado.

6. Capa de transporte: optimización del tamaño

Técnica Ganancia típica Coste
gzip / brotli La salida comprimida suele ser el 10-25 % del tamaño original Requiere soporte del servidor
Quitar sangría y espacios en blanco Reducción del 20-40 % Deja de ser legible
Abreviar nombres de campo Ganancias notables con anidamiento profundo Menor legibilidad; requiere una tabla de mapeo
Números en lugar de cadenas de enum Pequeña Requiere un diccionario
Paginación / campos bajo demanda La mayor Rediseño de la API

Para páginas destinadas a humanos, la combinación correcta es: la API envía JSON compacto y comprimido, y la UI lo embellece solo para mostrarlo. Muchos sitios de herramientas envían por defecto texto embellecido de nuevo al frontend, gastando un 30 % más de bytes para nada.

7. Cómo hacer benchmarks

/**
 * Mide la duración mediana de una rutina síncrona para que una ejecución con temblor no sesgue el resultado.
 * @param runOnce Función bajo prueba
 * @param repeatCount Número de repeticiones
 * @returns Duración mediana en milisegundos
 */
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)];
}

Tres reglas de disciplina:

  1. Usa una muestra fija y nunca generes datos aleatoriamente, o las cifras no serán comparables;
  2. Ejecuta al menos 9 veces y toma la mediana; la primera ejecución incluye el calentamiento del JIT y debe descartarse;
  3. Registra el entorno de ejecución (versión de Node, CPU), o las comparaciones entre máquinas no tendrán sentido.

8. Lista de comprobación

  • [ ] Existen cifras de línea base para poder comparar antes y después;
  • [ ] El análisis de cualquier cosa de más de 1 MB se ejecuta en un Worker;
  • [ ] Las vistas de árbol expanden bajo demanda y las listas usan desplazamiento virtual;
  • [ ] No hay copias profundas ni envolturas reactivas innecesarias;
  • [ ] Se usa JSON Lines cuando los datos son "un lote de registros";
  • [ ] La API envía JSON compacto y el formateo ocurre solo para mostrarlo.