Por qué los árboles JSON grandes se entrecortan: desplazamiento virtual y estrategias de plegado
En resumen: El tartamudeo empieza cuando se crean los nodos del DOM, no cuando haces scroll. Cubre el renderizado con plegado previo, las listas virtuales, el resaltado de búsqueda y los límites de los Workers, con métricas.

Conclusión: divide "lento" en análisis, creación de nodos, layout y pintado, e interacción, y mide cada etapa. Implementa en este orden: plegar por defecto, añadir una lista virtual, mover el cómputo a un Worker y limitar el resaltado a las filas visibles. Saltar directamente a una lista virtual sin plegar solo reduce el conjunto visible; el recálculo del árbol completo sigue costándote.
El tartamudeo normalmente viene de los cientos de miles de nodos del DOM que creaste para esos 5 MB, no de los 5 MB en sí.
1. Divide "lento" en cuatro etapas
Cuando un usuario dice "se congela al abrir", el problema puede vivir en cuatro etapas completamente distintas, cada una con un remedio diferente:
| Etapa | Disparador típico | Umbral perceptible | Coste dominante |
|---|---|---|---|
| Analizar y construir objetos | Abrir un archivo, pegar texto | Cualquier tarea de más de 50 ms | Escanear cadenas, construir el grafo de objetos |
| Crear nodos del DOM | Renderizar todo el árbol de golpe | Decenas de miles de nodos | Asignar elementos, nodos de texto, objetos de estilo |
| Layout y pintado | Primera visualización, scroll | Cualquier frame de más de 16,7 ms | Geometría, reflujo, repintado |
| Respuesta a la interacción | Expandir, plegar, buscar | Más de 100 ms se siente lento | Volver a aplanar y volver a renderizar |
El hilo principal del navegador hace una cosa a la vez. Si cualquier etapa lo ocupa más de 50 ms, el scroll y la escritura pierden frames; varias tareas así seguidas y la página parece muerta. La primera optimización, por tanto, no es requestAnimationFrame; es medir qué etapa se está llevando realmente el tiempo.
2. El número de nodos es el verdadero enemigo
Los visores de árbol multiplican el número de nodos por diseño. Una sola línea JSON normalmente se convierte en "sangría + clave + dos puntos + valor + coma + cheurón", así que una estimación prudente es de 3 a 6 nodos del DOM por línea:
| Tamaño del archivo | Líneas aproximadas | Nodos del DOM de la vista de árbol (estimación) | Cómo se ve el renderizado de golpe |
|---|---|---|---|
| 100 KB | 2.500 | 10.000 | Normalmente tolerable |
| 1 MB | 25.000 | 100.000 | Claramente entrecortado, posiblemente segundos de pantalla en blanco |
| 5 MB | 125.000 | 500.000 | Muy probablemente sin respuesta |
| 20 MB | 500.000 | Más de 2 millones | Colapsan tanto la memoria como el layout |
Cada elemento es más que unas pocas docenas de bytes de objeto: lleva reglas de estilo, una caja de layout e instrucciones de pintado. Una vez creado, un nodo sigue costando trabajo de layout y estilo aunque después quede oculto por un padre plegado, siempre que no se haya eliminado de verdad. "Renderizarlo todo y luego ocultarlo" no es una optimización; solo pospone el coste al siguiente reflujo.
3. Primero plegar, después virtualizar
El desplazamiento virtual resuelve "demasiadas filas visibles". El plegado resuelve "no deberíamos necesitar ver tantas filas en absoluto". El orden importa: para un documento de un millón de líneas expandido por defecto, incluso una lista virtual perfecta debe recalcular el árbol entero cuando el usuario pulsa "plegar todo".
Una estrategia de plegado viable:
- Expandir solo los dos primeros niveles por defecto. La raíz y sus campos inmediatos son visibles; los niveles más profundos muestran un resumen como
items: Array(12000); - Poner el tipo y el recuento en el resumen. Quien comprueba "si los nombres de campo son correctos" rara vez necesita diez mil registros;
- Cachear las filas por subárbol. Cada nodo expandido posee su array de filas aplanadas; plegarlo lo descarta, y expandirlo recalcula solo ese subárbol;
- Nunca reescanear el árbol completo en cada toggle. Si la expansión ocurre en la fila 3.000, reemplaza solo ese rango y mantén intactas las filas de alrededor.
/**
* Inserta las filas de un subárbol después de su padre en lugar de volver a aplanar todo el árbol.
* @param rows Array actual de filas visibles
* @param parentIndex Índice de la fila padre
* @param childRows Filas aplanadas del subárbol
* @returns Un nuevo array de filas visibles
*/
function replaceSubtreeRows(
rows: TreeRow[],
parentIndex: number,
childRows: TreeRow[],
): TreeRow[] {
const nextRows = rows.slice();
nextRows.splice(parentIndex + 1, 0, ...childRows);
return nextRows;
}
El patrón que hay que evitar es "una expansión, un relayout completo". Volver a recorrer el grafo de objetos completo en cada clic hace que el coste crezca de forma lineal con el documento. Con caché por subárbol, una sola expansión cuesta solo lo que cuesta ese subárbol.
4. Listas virtuales: renderizar solo el viewport
Una vez que el plegado ha reducido el número de filas, el resto puede seguir siendo decenas de miles de filas. Una lista virtual fija la altura del contenedor en "total de filas × altura fija de fila" y crea elementos solo para el viewport más un pequeño búfer por arriba y por abajo.
const ROW_HEIGHT = 24;
const OVERSCAN = 8;
/**
* Calcula el rango de filas que el viewport debe renderizar.
* @param rows Todas las filas visibles
* @param scrollTop Desplazamiento actual del contenedor
* @param viewportHeight Altura visible del contenedor
* @returns Filas a renderizar, el índice inicial y el desplazamiento vertical
*/
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,
};
}
Restricciones no negociables:
- La altura de fila debe ser fija. Solo así
scrollTop / ROW_HEIGHTte da el índice directamente. Permitir filas con salto de línea obliga a medir y cachear cada altura, lo que añade un orden de magnitud en complejidad y en temblor visual; - Desplaza con
transformo posicionamiento absoluto. No empujes el contenido hacia abajo con un elemento espaciador, o cada scroll mutará el layout; - Usa claves estables. Clavea las filas por ruta, no por índice de array, o expandir una fila remontará todas las que van después;
- Haz aritmética solo en el manejador de scroll. Dilo el renderizado a
requestAnimationFramey protégete contra la programación duplicada; - Mantén el búfer pequeño. Cinco a diez filas por lado es suficiente; duplicar el búfer duplica los nodos del DOM y el trabajo de estilo.
Con un viewport de 800 píxeles y filas de 24 píxeles, se ven unas 34 filas, e incluso con el búfer renderizas alrededor de cincuenta. El documento subyacente puede contener cien mil filas mientras el DOM mantiene de forma consistente unos cientos de elementos, manteniendo plano el coste de layout.
5. Plegar, expandir y anclaje del scroll
Una vez que el índice de fila se convierte en la única coordenada, algunos detalles de interacción empiezan a romperse:
- Conserva el ancla visual al plegar por encima del viewport. Las filas de abajo se desplazan hacia arriba; si
scrollTopno se ajusta en el mismo commit, el contenido parece saltar. Registra la ruta de la fila ancla y restaura la posición del scroll usando su nuevo índice. - Expande los ancestros antes de saltar. Cuando una búsqueda encuentra un campo profundo, primero expande todos los padres, luego calcula el índice de la fila objetivo, luego haz scroll y resalta. Omite el primer paso y la fila objetivo ni siquiera estará en el array visible.
- Controla la navegación por teclado desde las filas visibles. Arriba y abajo mueven el índice de fila; izquierda y derecha expanden y pliegan. Nunca recorras el árbol completo dentro de un manejador de teclas.
- Agrupa las actualizaciones en un solo frame. Cuando expandes varios niveles seguidos, fusiona los cambios del array y renderiza una vez, o cada nivel disparará un reflujo completo.
6. Búsqueda y resaltado: calcular una vez, mostrar poco
La búsqueda es la trampa de rendimiento más comúnmente ignorada en un visor de árbol. Si cada pulsación recorre todo el grafo de objetos y reconstruye la lista de resultados, un documento de cien mil nodos se congelará al instante.
Un enfoque por capas:
- Aplica debounce de 150-250 ms a la entrada y etiqueta cada búsqueda con un número de secuencia para descartar resultados obsoletos;
- Escanea en un Worker. Recorrer un grafo de objetos es cálculo puro sin dependencia del DOM, así que no debería bloquear la escritura;
- Guarda solo rutas e índices de fila. No copies cada nodo coincidente ni preconstruyas HTML resaltado;
- Resalta solo las filas visibles. El viewport contiene unas pocas docenas de filas; aplica estilos por ruta coincidente en tiempo de renderizado;
- Reutiliza "expandir ancestros y luego localizar el índice" para los saltos, el mismo camino que se usa al pulsar un cheurón.
Un detalle de seguridad: las claves y los valores de un visor de árbol son datos del usuario. Construir HTML resaltado a partir de ellos requiere escapado, o una cadena que contenga marcado se convierte en XSS. Un visor de JSON no necesita texto enriquecido, así que los nodos de texto más nombres de clase son a la vez más seguros y más rápidos.
7. Resaltado de sintaxis: CodeMirror y Monaco son herramientas distintas
Una vista de árbol y un editor de texto son modelos de renderizado diferentes, así que "cuál es más rápido" no es una comparación directa:
| Dimensión | CodeMirror 6 | Monaco Editor |
|---|---|---|
| Posicionamiento | Núcleo de editor modular | Editor IDE completo |
| Renderizado | Basado en viewport | Basado en viewport, pero los modelos y las decoraciones pesan más |
| Análisis | Análisis incremental de Lezer sobre rangos modificados | Tokenizador Monarch, línea a línea |
| Archivos grandes | Depende de las extensiones; ligero por defecto | Completo en funcionalidades, mayor coste de memoria y arranque |
| Plegado y búsqueda | Extensiones separadas, cargadas bajo demanda | Incorporados, con sobrecoste permanente |
| Trampa típica | Demasiadas extensiones restauran el coste | Minimapa, sticky scroll y decoraciones apiladas |
Dos conclusiones prácticas:
- Una vista de árbol no necesita un tokenizador completo. Da a claves, cadenas, números y booleanos un color cada uno con nodos de texto simples y clases CSS; eso cuesta un orden de magnitud menos que ejecutar resaltado de sintaxis;
- No montes una vista de árbol y un editor a la vez. Mantener dos modelos para un mismo documento duplica la memoria; destruye la vista antigua en lugar de ocultarla.
Si un editor realmente pertenece dentro del visor, CodeMirror 6 encaja en "solo lectura, archivo grande, resaltado local", mientras que Monaco encaja en autocompletado, diagnósticos y edición multicursor. Mide el tiempo del primer pintado y la memoria residente antes de leer la lista de funcionalidades. Para una comparación más profunda de editores y herramientas CLI, consulta Editores, herramientas de línea de comandos y automatización.
8. Límites de los Workers: saca el análisis y la construcción de filas del hilo principal
La otra mitad del tartamudeo de renderizado es el análisis en el hilo principal. Para JSON de más de 1 MB, el análisis, el formateo y la construcción de filas planas pertenecen a un Web Worker; el hilo principal solo recibe filas y pinta el viewport.
Los Workers no son gratis: postMessage usa clonación estructurada, así que enviar un grafo de objetos de 50 MB de vuelta al hilo principal construye una segunda copia y duplica el pico. El límite correcto es analizar y aplanar dentro del Worker, devolver solo las filas que el renderizador necesita:
worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
rows = event.data.rows;
requestAnimationFrame(() => renderViewport());
};
worker.postMessage({ text });
Si los datos de fila siguen siendo grandes, comprime las filas en arrays paralelos (ruta, tipo, offset del valor), colócalos en un ArrayBuffer y transfiere la propiedad para que el hilo principal no pague una copia. El coste es escribir tu propio serializador, algo que solo vale la pena con millones de filas.
La regla para el límite es simple: el hilo principal solo debería hacer el trabajo que debe tocar el DOM. Análisis, búsqueda, ordenación, formateo y diff pueden moverse todos. Renderizado, scroll, entrada y foco se quedan en el hilo principal, y cada frame debe caber en el presupuesto de trabajo.
9. Métricas y orden de implementación
La optimización no puede detenerse en "se siente más rápido". El siguiente conjunto puede ir directamente a una lista de comprobación de aceptación:
| Métrica | Objetivo | Cómo medirla |
|---|---|---|
| Primer frame visible | Menos de 300 ms | performance.now() desde la apertura hasta el primer pintado |
| Tareas largas | Ninguna de más de 50 ms | PerformanceObserver observando longtask |
| Tiempo de frame al hacer scroll | Estable por debajo de 16,7 ms | Grabación en DevTools Performance |
| Elementos del DOM en el viewport | Menos de 2.000 | document.querySelectorAll('*').length |
| Respuesta a expandir / plegar | Menos de 50 ms | Marcas de tiempo alrededor de la interacción |
| Búsqueda en más de 100.000 filas | Menos de 300 ms, dentro de un Worker | Tiempo en el Worker y devolver la duración |
| Heap residente | Menos de 3 veces el tamaño del texto | Instantánea del heap en DevTools Memory |
Mide con un archivo real en lugar de objetos pequeños generados de forma uniforme; la forma de los datos importa, y las cadenas largas, el anidamiento profundo y los arrays muy anchos producen curvas distintas. Para el balance del grafo de objetos en sí, consulta El balance de memoria de JSON.
Implementa en este orden:
- Medir una línea base: tiempo del primer renderizado, número de nodos, tiempo de frame al hacer scroll;
- Plegar por defecto: expandir dos niveles, mostrar tipo y recuento en los resúmenes;
- Lista virtual: altura de fila fija, claves estables, viewport más un pequeño búfer;
- Mover el cómputo: análisis, búsqueda y aplanado a un Worker;
- Restringir el resaltado: aplicar estilo solo a las filas visibles, evitar repintar todo el árbol;
- Añadir barandillas: por encima de un umbral, ofrecer "mostrar las primeras N filas" o exigir un filtro de búsqueda.
Todos los pasos comparten un objetivo: reducir los nodos y objetos que deben existir en el mismo momento, en lugar de pedirle al navegador que trabaje más duro sobre ellos. El trasfondo completo de la estrategia por capas está en Análisis, renderizado y streaming de JSON grande.
¿El desplazamiento virtual puede arreglar el tartamudeo en tiempo de análisis?
No. El desplazamiento virtual solo reduce los nodos del DOM. Si el análisis sigue ocurriendo en el hilo principal, la página sigue tartamudeando. Mueve el análisis a un Worker para documentos de más de 1 MB y trata los dos problemas por separado.
Todo está expandido y sigue tartamudeando. ¿Qué va mal?
O la lista virtual no está realmente activa, o cada expansión reescanea el árbol completo. Comprueba si las filas fuera de pantalla siguen creando DOM, y si la expansión recalcula solo el subárbol afectado.
¿CodeMirror o Monaco para un visor de JSON?
Prefiere CodeMirror 6 para solo lectura, archivos grandes y resaltado local; su carga modular bajo demanda es más ligera. Elige Monaco cuando necesites autocompletado, diagnósticos y edición multicursor. Con cualquiera de los dos, evita montar a la vez una vista de árbol y un editor para el mismo documento.
¿Por qué la posición del scroll es incorrecta tras un salto de búsqueda?
Normalmente porque la vista hizo scroll antes de expandir. Expande primero todos los ancestros, luego fija la posición del scroll a partir del nuevo índice de fila, y resalta al final. Clavea las filas por ruta en lugar de por índice para que expandir no desplace todos los índices.
FAQ
¿Cómo averiguo de qué etapa viene el tartamudeo?
Divide "lento" en análisis y construcción de objetos, creación de nodos del DOM, layout y pintado, y respuesta a la interacción, y cronometra cada etapa con performance.now() desde la apertura hasta el primer pintado. Luego observa longtask con PerformanceObserver: cualquier tarea de más de 50 ms hace caer frames, y una grabación en DevTools Performance muestra si el tiempo de frame al hacer scroll se mantiene por debajo de 16,7 ms.
¿Por qué renderizar todo el árbol de golpe deja la pantalla en blanco?
Un documento de 5 MB tiene unas 125.000 líneas, y una vista de árbol normalmente convierte cada línea en 3 a 6 nodos del DOM: aproximadamente 500.000 elementos a la vez. Cada elemento también lleva reglas de estilo, una caja de layout e instrucciones de pintado, así que varias tareas de más de 50 ms seguidas en el hilo principal hacen que la página parezca muerta. Los nodos ocultos por un padre plegado siguen costando trabajo de layout, así que "renderizarlo todo y luego ocultarlo" no es una optimización.
¿Debería devolver un grafo de objetos de 50 MB desde el Worker?
No. postMessage usa clonación estructurada, así que el receptor construye una segunda copia y el pico se duplica. El límite correcto es analizar y aplanar dentro del Worker y devolver solo las filas que el renderizador necesita; con millones de filas puedes empaquetar arrays paralelos (ruta, tipo, offset del valor) en un ArrayBuffer y transferir la propiedad.