ListView JavaScript de Alto Rendimiento en 2026: Virtualización y Técnicas de Optimización

Guía completa sobre la implementación de listas virtualizadas de alto rendimiento en JavaScript. Técnicas de virtualización, optimización del renderizado y mejores prácticas para manejar miles de elementos sin pérdida de rendimiento.

ListView JavaScript de Alto Rendimiento con Virtualización

La gestión eficiente de grandes listas de datos representa uno de los desafíos más comunes en el desarrollo frontend moderno. Cuando una aplicación necesita mostrar miles o incluso millones de elementos, los enfoques tradicionales de renderizado alcanzan rápidamente sus límites, provocando ralentizaciones significativas y una experiencia de usuario degradada.

La virtualización de listas constituye la solución de referencia para resolver este problema. Esta técnica consiste en renderizar únicamente los elementos visibles en el área de visualización (viewport), mientras se simula la presencia del conjunto completo de datos. El navegador manipula así solo una fracción del DOM, reduciendo drásticamente la carga de memoria y el tiempo de renderizado.

La virtualización puede reducir el número de elementos DOM de varios miles a unas pocas decenas, mejorando el rendimiento de renderizado en más del 90% en algunos casos.

Comprendiendo los Fundamentos de la Virtualización

El principio de la virtualización se basa en un concepto simple: ¿por qué renderizar 10,000 elementos si el usuario solo ve 20 a la vez? La virtualización calcula dinámicamente qué elementos deben ser visibles según la posición de desplazamiento, y luego renderiza únicamente esos elementos específicos.

Este enfoque requiere varios cálculos esenciales:

  • La altura total estimada de la lista completa
  • El índice del primer elemento visible
  • El índice del último elemento visible
  • El desplazamiento (offset) para posicionar correctamente los elementos
typescript
interface VirtualizedListConfig {
  itemHeight: number;
  containerHeight: number;
  overscan: number;
  totalItems: number;
}

function calculateVisibleRange(
  scrollTop: number,
  config: VirtualizedListConfig
): { startIndex: number; endIndex: number; offsetY: number } {
  const { itemHeight, containerHeight, overscan, totalItems } = config;
  
  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const endIndex = Math.min(totalItems - 1, startIndex + visibleCount + overscan * 2);
  const offsetY = startIndex * itemHeight;
  
  return { startIndex, endIndex, offsetY };
}

Implementación de una Lista Virtualizada con NestJS y WebSocket

En el contexto de una aplicación full-stack, la virtualización del lado del cliente debe acompañarse frecuentemente de una gestión inteligente de datos del lado del servidor. NestJS ofrece una arquitectura robusta para implementar este tipo de funcionalidad.

typescript
import { Injectable } from '@nestjs/common';
import { WebSocketGateway, WebSocketServer, SubscribeMessage } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

interface PaginatedRequest {
  startIndex: number;
  endIndex: number;
  searchQuery?: string;
}

@Injectable()
@WebSocketGateway({ cors: true })
export class VirtualizedListGateway {
  @WebSocketServer()
  server: Server;

  private dataCache: Map<string, any[]> = new Map();

  @SubscribeMessage('requestItems')
  async handleItemRequest(
    client: Socket,
    payload: PaginatedRequest
  ): Promise<void> {
    const { startIndex, endIndex, searchQuery } = payload;
    
    const cacheKey = `${searchQuery || 'all'}_${startIndex}_${endIndex}`;
    
    if (this.dataCache.has(cacheKey)) {
      client.emit('itemsResponse', {
        items: this.dataCache.get(cacheKey),
        startIndex,
        endIndex,
        cached: true
      });
      return;
    }

    const items = await this.fetchItemsFromDatabase(startIndex, endIndex, searchQuery);
    
    this.dataCache.set(cacheKey, items);
    
    client.emit('itemsResponse', {
      items,
      startIndex,
      endIndex,
      cached: false
    });
  }

  private async fetchItemsFromDatabase(
    start: number,
    end: number,
    query?: string
  ): Promise<any[]> {
    // Implementación de la consulta a base de datos
    return [];
  }
}

Optimización del Renderizado con React y useDeferredValue

React 19 introduce mejoras significativas para la gestión de actualizaciones de prioridad. El hook useDeferredValue permite diferir actualizaciones menos críticas, evitando bloqueos de la interfaz durante el desplazamiento rápido.

typescript
import React, { useDeferredValue, useMemo, useCallback, useRef, useEffect, useState } from 'react';

interface VirtualizedListProps<T> {
  items: T[];
  itemHeight: number;
  containerHeight: number;
  renderItem: (item: T, index: number) => React.ReactNode;
  overscan?: number;
}

function VirtualizedList<T>({
  items,
  itemHeight,
  containerHeight,
  renderItem,
  overscan = 3
}: VirtualizedListProps<T>) {
  const [scrollTop, setScrollTop] = useState(0);
  const deferredScrollTop = useDeferredValue(scrollTop);
  const containerRef = useRef<HTMLDivElement>(null);

  const visibleRange = useMemo(() => {
    const startIndex = Math.max(0, Math.floor(deferredScrollTop / itemHeight) - overscan);
    const visibleCount = Math.ceil(containerHeight / itemHeight);
    const endIndex = Math.min(items.length - 1, startIndex + visibleCount + overscan * 2);
    return { startIndex, endIndex };
  }, [deferredScrollTop, itemHeight, containerHeight, items.length, overscan]);

  const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
    setScrollTop(e.currentTarget.scrollTop);
  }, []);

  const visibleItems = useMemo(() => {
    return items.slice(visibleRange.startIndex, visibleRange.endIndex + 1);
  }, [items, visibleRange]);

  const totalHeight = items.length * itemHeight;
  const offsetY = visibleRange.startIndex * itemHeight;

  return (
    <div
      ref={containerRef}
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={handleScroll}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {visibleItems.map((item, index) =>
            renderItem(item, visibleRange.startIndex + index)
          )}
        </div>
      </div>
    </div>
  );
}

Gestión de Alturas Variables

Las listas con elementos de diferentes alturas representan un desafío adicional. Un enfoque eficiente consiste en medir las alturas reales de los elementos y mantener un caché de estas mediciones.

typescript
interface HeightCache {
  heights: Map<number, number>;
  estimatedHeight: number;
  totalHeight: number;
}

class DynamicHeightManager {
  private cache: HeightCache = {
    heights: new Map(),
    estimatedHeight: 50,
    totalHeight: 0
  };

  constructor(private totalItems: number, estimatedHeight: number = 50) {
    this.cache.estimatedHeight = estimatedHeight;
    this.recalculateTotalHeight();
  }

  setItemHeight(index: number, height: number): void {
    const previousHeight = this.cache.heights.get(index) || this.cache.estimatedHeight;
    this.cache.heights.set(index, height);
    this.cache.totalHeight += height - previousHeight;
  }

  getItemOffset(index: number): number {
    let offset = 0;
    for (let i = 0; i < index; i++) {
      offset += this.cache.heights.get(i) || this.cache.estimatedHeight;
    }
    return offset;
  }

  getItemHeight(index: number): number {
    return this.cache.heights.get(index) || this.cache.estimatedHeight;
  }

  getTotalHeight(): number {
    return this.cache.totalHeight;
  }

  findIndexAtOffset(offset: number): number {
    let currentOffset = 0;
    for (let i = 0; i < this.totalItems; i++) {
      const height = this.getItemHeight(i);
      if (currentOffset + height > offset) {
        return i;
      }
      currentOffset += height;
    }
    return this.totalItems - 1;
  }

  private recalculateTotalHeight(): void {
    this.cache.totalHeight = this.totalItems * this.cache.estimatedHeight;
  }
}

Estrategias de Precarga y Almacenamiento en Caché

Para garantizar una experiencia de usuario fluida, la precarga de datos antes de que sean visibles resulta esencial. Esta técnica anticipa las necesidades del usuario cargando los datos con anticipación.

typescript
import { Injectable } from '@nestjs/common';

interface CacheEntry<T> {
  data: T[];
  timestamp: number;
  ttl: number;
}

@Injectable()
export class PrefetchService {
  private cache: Map<string, CacheEntry<any>> = new Map();
  private readonly DEFAULT_TTL = 60000; // 1 minuto

  async prefetchRange<T>(
    fetcher: (start: number, end: number) => Promise<T[]>,
    currentIndex: number,
    bufferSize: number = 50
  ): Promise<void> {
    const prefetchStart = Math.max(0, currentIndex - bufferSize);
    const prefetchEnd = currentIndex + bufferSize;

    const cacheKey = `${prefetchStart}_${prefetchEnd}`;
    
    if (this.isValid(cacheKey)) {
      return;
    }

    const data = await fetcher(prefetchStart, prefetchEnd);
    
    this.cache.set(cacheKey, {
      data,
      timestamp: Date.now(),
      ttl: this.DEFAULT_TTL
    });
  }

  get<T>(start: number, end: number): T[] | null {
    const cacheKey = `${start}_${end}`;
    const entry = this.cache.get(cacheKey);
    
    if (entry && this.isValid(cacheKey)) {
      return entry.data as T[];
    }
    
    return null;
  }

  private isValid(key: string): boolean {
    const entry = this.cache.get(key);
    if (!entry) return false;
    return Date.now() - entry.timestamp < entry.ttl;
  }

  clearExpired(): void {
    const now = Date.now();
    for (const [key, entry] of this.cache.entries()) {
      if (now - entry.timestamp >= entry.ttl) {
        this.cache.delete(key);
      }
    }
  }
}

Optimización del Rendimiento con Web Workers

Los cálculos intensivos relacionados con la virtualización pueden trasladarse a Web Workers para liberar el hilo principal y mantener una interfaz reactiva.

virtualization.worker.tstypescript
self.onmessage = function(e: MessageEvent) {
  const { type, payload } = e.data;

  switch (type) {
    case 'CALCULATE_VISIBLE_RANGE':
      const result = calculateRange(payload);
      self.postMessage({ type: 'RANGE_CALCULATED', result });
      break;

    case 'FILTER_ITEMS':
      const filtered = filterItems(payload.items, payload.query);
      self.postMessage({ type: 'ITEMS_FILTERED', result: filtered });
      break;

    case 'SORT_ITEMS':
      const sorted = sortItems(payload.items, payload.comparator);
      self.postMessage({ type: 'ITEMS_SORTED', result: sorted });
      break;
  }
};

function calculateRange(payload: {
  scrollTop: number;
  itemHeight: number;
  containerHeight: number;
  totalItems: number;
  overscan: number;
}) {
  const { scrollTop, itemHeight, containerHeight, totalItems, overscan } = payload;
  
  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const endIndex = Math.min(totalItems - 1, startIndex + visibleCount + overscan * 2);
  
  return { startIndex, endIndex, offsetY: startIndex * itemHeight };
}

function filterItems<T>(items: T[], query: string): T[] {
  return items.filter(item => 
    JSON.stringify(item).toLowerCase().includes(query.toLowerCase())
  );
}

function sortItems<T>(items: T[], comparatorKey: keyof T): T[] {
  return [...items].sort((a, b) => {
    if (a[comparatorKey] < b[comparatorKey]) return -1;
    if (a[comparatorKey] > b[comparatorKey]) return 1;
    return 0;
  });
}

Integración con Bibliotecas Existentes

Varias bibliotecas probadas facilitan la implementación de la virtualización. React Virtualized y TanStack Virtual ofrecen soluciones robustas y bien documentadas.

typescript
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

interface Item {
  id: string;
  title: string;
  description: string;
}

function TanStackVirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
    overscan: 5,
  });

  return (
    <div
      ref={parentRef}
      style={{ height: '400px', overflow: 'auto' }}
    >
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualItem.size}px`,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            <div className="p-4 border-b">
              <h3>{items[virtualItem.index].title}</h3>
              <p>{items[virtualItem.index].description}</p>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

Medición y Monitoreo del Rendimiento

La medición continua del rendimiento permite identificar cuellos de botella y optimizar en consecuencia. La API Performance del navegador ofrece herramientas valiosas para este monitoreo.

typescript
class PerformanceMonitor {
  private marks: Map<string, number> = new Map();
  private measurements: Map<string, number[]> = new Map();

  startMeasure(name: string): void {
    this.marks.set(name, performance.now());
  }

  endMeasure(name: string): number {
    const start = this.marks.get(name);
    if (!start) return 0;

    const duration = performance.now() - start;
    
    if (!this.measurements.has(name)) {
      this.measurements.set(name, []);
    }
    this.measurements.get(name)!.push(duration);

    return duration;
  }

  getAverageTime(name: string): number {
    const times = this.measurements.get(name);
    if (!times || times.length === 0) return 0;
    return times.reduce((a, b) => a + b, 0) / times.length;
  }

  getReport(): Record<string, { average: number; min: number; max: number; count: number }> {
    const report: Record<string, any> = {};
    
    for (const [name, times] of this.measurements.entries()) {
      report[name] = {
        average: this.getAverageTime(name),
        min: Math.min(...times),
        max: Math.max(...times),
        count: times.length
      };
    }
    
    return report;
  }

  reset(): void {
    this.marks.clear();
    this.measurements.clear();
  }
}

¿Listo para aprobar tus entrevistas de Node.js / NestJS?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Conclusión

La virtualización de listas representa una técnica indispensable para cualquier desarrollador que trabaje con grandes cantidades de datos. Los enfoques presentados en este artículo, desde la implementación básica hasta el uso de Web Workers, ofrecen un abanico de soluciones adaptadas a diferentes contextos.

Los puntos clave a recordar incluyen la importancia del cálculo preciso de los elementos visibles, la gestión inteligente del caché para la precarga, y el uso juicioso de las funcionalidades de React 19 como useDeferredValue. La integración con bibliotecas probadas como TanStack Virtual simplifica considerablemente la implementación mientras garantiza un rendimiento óptimo.

El dominio de estas técnicas de virtualización constituye una ventaja importante para las entrevistas técnicas, donde la capacidad de optimizar el rendimiento de una aplicación se evalúa frecuentemente. Estas habilidades demuestran una comprensión profunda de los mecanismos de renderizado y la gestión de memoria en aplicaciones JavaScript modernas.

Reto diario

¿Sabrías detectar el bug en Node.js / NestJS?

Un fragmento real, un bug oculto, un intento al día. Sin cuenta para probar.

Anthony Fillion-Maillet

Escrito por

Anthony Fillion-Maillet

Fundador de SharpSkill

Desarrollador fullstack desde hace más de 10 años. Dirige SharpSkill y responde por todo lo que se publica aquí.

Actualizado el 7 de septiembre de 2026

Etiquetas

#javascript
#rendimiento
#virtualizacion
#react
#nestjs

Compartir

Artículos relacionados