3 puntos por GN⁺ 3 시간 전 | Aún no hay comentarios. | Compartir por WhatsApp
  • Al crear una GUI de base de datos para MongoDB y PostgreSQL, fue necesario soportar tipos BSON y JSONB, columnas anidadas, búsqueda, edición, fijación y arrastre; para lograrlo, se optimizó durante cerca de un año la virtualización en ambos ejes y la estructura de gestión de estado
  • Se creó una tabla sombra que precalcula por separado del documento original las cadenas de visualización, tipos, rutas aplanadas, orden de columnas y resultados de búsqueda, y solo renderiza en el DOM de tamaño fijo las filas y columnas visibles en pantalla
  • En la ruta de scroll se aplicaron listeners pasivos, requestAnimationFrame, buffers e histéresis, además de seguimiento de velocidad, y se usaron transform y opacity en lugar de propiedades de layout para reducir el trabajo del hilo principal
  • Los íconos por celda se cambiaron a imágenes de fondo SVG compartidas y el editor se montó solo cuando hacía falta; además, con pooling de DOM siguiendo filas y columnas por posición, se eliminó la creación de nodos durante el scroll
  • Aunque Canvas ofrece un límite de rendimiento de 60fps mayor que el DOM, perjudica texto, selección, accesibilidad y expansión funcional, por lo que se eligió un diseño basado en DOM para mantener la selección real de texto y un desarrollo más ágil

Objetivos y restricciones iniciales

  • Se empezó con un arreglo bidimensional simple y bucles anidados, pero eso terminó derivando en casi un año de optimizaciones intermitentes
  • La tabla de una GUI de base de datos no era solo de visualización, sino que debía soportar múltiples estados e interacciones
    • Debía entender todos los tipos BSON de MongoDB y JSONB de PostgreSQL, entre otros, y mostrar íconos con colores según el tipo
    • Debía distinguir tipos como la cadena "123" y el entero 123, ya que producen resultados distintos en las consultas
    • Debía expandir documentos anidados como subcolumnas reales y buscar en toda la ruta anidada, resaltando las coincidencias dentro de la celda
    • Hacían falta funciones como reordenar, redimensionar y fijar columnas, editar dentro de la celda y arrastrar valores, filas y columnas a un constructor visual de consultas
  • Ese estado de funciones no existe en el documento original y debe mantenerse incluso después de hacer scroll, así que se necesitaba una estructura de renderizado separada

Paso 1: renderizar directamente todos los elementos

  • El enfoque de recorrer filas y campos de forma anidada para crear todas las celdas funciona con 100 filas, pero colapsa con datos grandes
    • 10,000 filas × 30 columnas crean unos 300,000 nodos DOM, y la detección de cambios del framework vuelve a recorrerlos repetidamente
    • Si se asume que un nodo DOM consume alrededor de 1KB incluyendo estructuras internas del navegador, se necesitan cientos de MB antes incluso de los datos reales
    • El presupuesto por frame a 60fps es de 16.7ms, y estilo, layout y pintura también comparten ese tiempo
  • En la implementación real, se fracasó al intentar renderizar 1,000 filas con unas 20 columnas
  • Para renderizar solo una parte, había que rastrear por separado las filas y columnas visibles, su orden, la expansión de campos anidados y los resultados de búsqueda

Paso 2: separar el estado de visualización con una tabla sombra

  • Los documentos originales son anidados y sus tipos varían, por lo que no sirven fácilmente como entrada de renderizado
    • MongoDB tiene valores BSON como ObjectId, Decimal128, timestamp y binary
    • Los datos SQL incluyen JSONB y timestamps con zona horaria, por lo que hay que decidir el formato celda por celda
  • Si se decide el formato dentro del bucle de renderizado, el mismo costo se repite en cada frame, y además los datos originales no contienen estado de tabla como orden de columnas, expansión o resultados de búsqueda
  • La tabla sombra funciona como punto de referencia del estado que la tabla realmente debe mostrar, sin modificar los documentos originales
    • Se construye una vez al cargar y se actualiza cuando cambia el estado, pero no durante el scroll
    • Para cada celda, precalcula la cadena de visualización truncada, el tipo definitivo y la ruta aplanada
    • Se limita la cadena mostrada para evitar que un documento de 16MB genere otra cadena de 16MB en el estado de renderizado
    • El tipo determina el ícono, el editor y la forma de búsqueda
    • Se usan como claves rutas aplanadas como "address.geo.lat", evitando recorrer el árbol cada vez
  • Al expandir objetos anidados, las subrutas se promueven a columnas reales, y resultados de ordenamiento, búsqueda, orden de columnas y estado de expansión también se guardan en la misma estructura
  • En esta etapa no disminuye el número de nodos DOM, pero sí se sientan las bases para calcular luego con rapidez el orden y ancho de las columnas

Paso 3: virtualización vertical y área fantasma de scroll

  • La virtualización vertical renderiza solo las filas del viewport y un pequeño buffer, y convierte el resto de la altura en un área falsa
  • El área fantasma (phantom) es un contenedor interno con altura rowCount × rowHeight
    • Con 1 millón de filas × 40px se crea un div de 40 millones de px de alto casi sin contenido
    • El navegador usa esa altura para ofrecer barra de scroll nativa y comportamiento de scroll
  • El rango visible se calcula así
    • firstRow = floor(scrollTop / rowHeight)
    • lastRow = floor((scrollTop + viewportHeight) / rowHeight)
    • Se añaden filas buffer a ambos lados para evitar rerenderizar con cada pequeño movimiento
  • Las filas visibles se colocan en un contenedor slab ubicado en firstRow × rowHeight, y conviene usar transform en lugar de top
  • El usuario ve 1 millón de filas, pero en el DOM existen solo unas 40
  • Sin embargo, si hay 300 columnas, incluso con 40 filas se generan 12,000 celdas, así que también hay que virtualizar columnas

Paso 4: virtualización horizontal con anchos variables

  • Como una colección de base de datos documental puede tener cientos de campos, también se necesita virtualización de columnas
  • Como el ancho de las columnas no es uniforme, en lugar de dividir por un valor fijo se usan sumas acumuladas y búsqueda binaria
    • Con una suma acumulada de la forma position[n] = width[0] + ... + width[n-1] se obtiene la coordenada x de cada columna con una sola lectura del arreglo
    • La columna correspondiente al offset horizontal x se encuentra con búsqueda binaria sobre el arreglo de acumulados
    • Incluso con 1,000 columnas, la búsqueda termina en microsegundos
  • Los acumulados se reconstruyen solo cuando cambia de verdad el ancho, por ejemplo al redimensionar, ocultar o reordenar columnas; no durante el scroll
  • Se deja un buffer lateral de unos 200px para renderizar por adelantado antes de que la siguiente columna entre en pantalla
  • Tras la virtualización en ambos ejes, el área renderizada se mantiene cerca de 40 filas × 12 columnas sin importar el tamaño de los datos
    • Incluso en colecciones de 500 columnas, solo existen unas 12 al mismo tiempo, por lo que el tiempo de carga no aumentó
  • Se redujo el trabajo de renderizado, pero quedaba el problema de recalcular rangos en cada evento de scroll, que puede ocurrir cientos de veces por segundo

Paso 5: gestionar el presupuesto por frame en la ruta de scroll

  • El procesamiento del scroll comparte el presupuesto de 16.7ms por frame con estilo, layout y pintura, así que debe hacer el mínimo trabajo posible
  • Se registraron listeners pasivos fuera de la detección de cambios del framework
    • Así se le indica al navegador que no se llamará a preventDefault, permitiendo que el compositor mueva píxeles sin esperar a JavaScript
    • El evento de scroll en sí no dispara una revisión de renderizado del framework
  • Múltiples eventos se consolidan en uno por frame
    • Solo se guarda la posición de scroll más reciente y se agenda un único callback de requestAnimationFrame
    • Incluso 12 eventos en un frame se resuelven con un solo cálculo de rango
  • Se aplicó el fast draw exit tomado del código de Handsontable
    • Si el nuevo rango visible sigue dentro del buffer ya renderizado, se hacen solo dos comparaciones enteras y se retorna de inmediato
  • Se aplicó histéresis en los bordes del buffer
    • Si el rango visible se acerca a unos 40px del borde del buffer, se reconstruye
    • Un buffer de unos 200px se recentra en la nueva posición para evitar bordes vacíos o reconstrucciones repetidas en el límite
  • Se añadió un detector de velocidad que sigue los px/ms entre eventos
    • En la implementación, si supera 10px/ms se considera un flick rápido y se detiene el renderizado
    • Se deja que el scroll nativo se mueva sobre el área fantasma y el slab se rellena otra vez cuando la velocidad se estabiliza
  • Incluso tras reducir el trabajo de JavaScript, seguían las caídas de frames, y layout y pintura —incluyendo estilo, bandas e íconos— se volvieron el siguiente cuello de botella

Paso 6: eliminar el costo de las propiedades de layout

  • El hilo principal del navegador se encarga de estilo, layout y pintura, mientras el compositor mueve en la GPU capas ya dibujadas
  • Las propiedades que pueden animarse en el compositor son transform y opacity; top, left, width, height, background-color y otras activan el hilo principal
  • Al actualizar top para sincronizar el scroll de los números de fila y el panel de columnas fijas, se forzaba layout 60 veces por segundo
    • Se reemplazó por translate3d, manteniendo la misma vista pero eliminando ese costo del hilo principal
  • Las bandas alternadas, antes implementadas con fondo por fila, se cambiaron por un único repeating-linear-gradient en todo el cuerpo
    • La altura de fila se pasa como variable CSS
    • El navegador rasteriza solo un tile del tamaño de dos filas y lo repite desde una textura GPU cacheada
    • Desaparecen los bindings de clase por fila y tanto el cuerpo como el panel fijo comparten el mismo tile, evitando desajustes de color
  • Los separadores de columnas se dibujan solo sobre la altura del slab actualmente renderizado, no sobre toda el área fantasma de 40 millones de px
  • El scroll normal ya era fluido, pero al cambiar de ventana y crear nuevas celdas seguía habiendo costo por DOM innecesario acumulado celda por celda

Paso 7: aligerar íconos y editores

  • Los íconos de tipo en un grid de base de datos no son decorativos: distinguen el significado de valores como ObjectId, string, entero o JSONB
  • La primera implementación añadía un elemento de font icon por celda, pero esos nodos DOM extra y la ruta de renderizado de texto de los glifos generaban un costo importante
  • Los íconos se movieron al background-image de la propia celda y se codificaron como SVG data URI
    • Todas las celdas del mismo tipo referencian la misma cadena URI
    • El navegador rasteriza una sola vez el ícono de cada tipo y luego lo repite desde una textura GPU cacheada
    • Así se pueden mostrar decoraciones repetidas sin nodos DOM adicionales
  • Para la edición de celdas se aplicó un renderizado híbrido
    • En estado normal, la celda usa solo texto plano y un span
    • Solo al hacer doble clic se monta sobre esa celda, como un portal, un componente editor pesado con reconocimiento de tipos
  • Si se usan componentes del framework para todas las celdas, se acumula el costo de crear instancias, y el rendimiento puede degradarse al acoplar una librería de grid con componentes renderizadores por celda
  • Aunque la celda en sí se volvió más ligera, seguía quedando el problema de que al aparecer una nueva columna el framework recreaba también otras celdas cuyo contenido no había cambiado

Paso 8: reutilización del DOM basada en posición

  • Criterios de seguimiento como trackBy en Angular o key en React y Vue determinan si al moverse la ventana se reutilizan elementos existentes o se destruyen y crean de nuevo
  • Si las filas no se vuelven a enlazar con nuevos datos y se reemplazan cada vez, hay que recrear continuamente componentes, nodos DOM y listeners
  • Se rastrearon filas y columnas por su posición en pantalla, no por el valor de los datos
    • Se conservan los mismos ~40 elementos de fila y de columna, y solo se reemplaza su contenido con valores nuevos
    • El grid funciona como un object pool y no asigna nuevo DOM durante el scroll
  • En esta etapa se resolvió el costo de scroll bidimensional y reconstrucción de ventana, pero seguían detalles de acabado como vibración de un frame al soltar una columna o errores de medio píxel en los números de fila

Paso 9: pulir las interacciones de detalle

  • Durante el arrastre de columnas, se usa transform para mover las columnas existentes como si ya estuvieran en el nuevo orden, y al soltar se procesan el reordenamiento y el reinicio de transform en el mismo pase de renderizado
    • Así, el último frame del arrastre y el primero tras reordenar son idénticos píxel a píxel, de modo que la transición resulta invisible
  • Los tooltips no se enlazan por celda, sino que se manejan con un único listener delegado de hover en el contenedor
    • Solo se calcula el tooltip para la celda bajo el cursor
    • Incluso la generación costosa de cadenas, como el pretty print de un valor JSONB en SQL, se difiere al momento real del hover
  • El borde de 1px de la columna de números de fila hacía que la línea base del texto y las filas de datos se desalinearan medio píxel, provocando vibración al hacer scroll
    • Se corrigió para que ambas columnas usaran el mismo modelo de caja
  • Las bandas alternadas se determinan por el índice absoluto de la fila, no por la posición reutilizada del DOM, evitando parpadeos de color cuando cambia la ventana virtualizada
  • También se mantuvieron las funciones que justificaron construir un grid propio
    • Los documentos anidados no se dejan como cadenas JSON, sino que se expanden en subcolumnas reales
    • Las coincidencias en rutas anidadas se resaltan dentro de la celda
    • Los valores tipados pueden arrastrarse directamente desde el grid al constructor visual de consultas
  • El costo de añadir esas funciones a una librería de grid genérica era mayor que el de controlar directamente el renderer

Paso 10: leer el código fuente de grids de alto rendimiento existentes

  • Al aprender rendimiento frontend, una de las prácticas de mayor valor fue leer código fuente de otros proyectos, donde quedaban optimizaciones no documentadas en repositorios públicos
  • En AG-Grid, un grid DOM estudiado durante la investigación, se aplicaban técnicas como estas
    • Dividir en el tiempo el trabajo del DOM con un presupuesto explícito por frame y una cola priorizada de tareas
    • Hashear el viewport de columnas para resolver un scroll sin cambios con una sola comparación de strings
    • Crear filas siguiendo la dirección del scroll, para mostrar primero el contenido hacia donde va el usuario
    • Crear primero las nuevas celdas y dejar para después la destrucción de las antiguas, de modo que el contenido nuevo se pinte antes que el que desaparece
    • Usar delegación de eventos a nivel de contenedor porque los listeners por celda eran un cuello de botella medido
  • Los grids basados en Canvas evitan el DOM y redibujan cientos de textos por frame sin recalcular layout ni estilos
    • Pueden mantener 60fps incluso bajo manipulación intensa, por lo que su techo de rendimiento es mayor que el de un grid DOM
    • El texto rasterizado fuera de la cuadrícula de píxeles puede verse borroso
    • La selección funciona solo a nivel de celda y los puntos suspensivos tampoco aparecen si no se dibujan manualmente
    • Cada nueva función de celda exige añadir código de dibujo y de hit testing
  • Al final se eligió DOM para conservar texto nítido, selección real de texto, accesibilidad y desarrollo rápido de funciones
  • Aunque no se alcance la suavidad de Canvas, es un trade-off explícito considerando la experiencia de usuario necesaria y el costo de desarrollo

Aún no hay comentarios.

Aún no hay comentarios.