Documentación/Guías

Listas virtuales

Ordena miles de filas con TanStack Virtual o con useVirtualList de VueUse — dale el borrador al virtualizador, usa el id como clave de las filas y mantén montada la fila arrastrada.

Editar esta página

Esta página la ha traducido una IA y puede contener imprecisiones. Leer el original en inglés

La ordenación se basa en los datos: el borrador items es un array nuevo en cada reordenación, las filas tienen como clave su ítem y las animaciones solo necesitan las filas montadas. Así que cualquier virtualizador que renderice a partir de un array funciona — dale el borrador en lugar de tu array.

Live

Cargando la demo…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import TaskList from './TaskList.vue';
</script>

<template>
  <DnDProvider>
    <TaskList />
  </DnDProvider>
</template>

Las reglas

  1. Dale al virtualizador el borrador items, no tu array de origen.
  2. Usa la clave del ítem como clave de las filas, nunca el índice. Los ejemplos oficiales de TanStack y VueUse usan el índice; cámbialo.
  3. Pasa los ids a makeSortableItem como getter, { id: () => props.item.id }: el virtualizador puede darle otro ítem a un componente de fila.
  4. Guarda los arrays grandes en un shallowRef. La biblioteca nunca recorre tus datos en profundidad.
  5. Mantén el placeholder del mismo tamaño que la fila: oculta su contenido, no lo colapses.
  6. Usa un overscan de al menos 5 filas (VueUse: al menos 1), para que las filas estén montadas antes de que llegue el desplazamiento automático.
  7. No animes height, margin ni padding de las filas, y no pongas will-change en el contenedor desplazable.

VueUse useVirtualList

La demo de arriba. Todas las filas tienen la misma altura, y el cálculo de VueUse sigue siendo O(1):

TaskList.vue
<script setup lang="ts">
import { shallowRef, useTemplateRef } from 'vue';
import { useVirtualList } from '@vueuse/core';
import { makeSortableList } from '@vue-dnd-kit/core';

const ROW = 44;
const tasks = shallowRef(makeTasks(10_000));

// The outer element is the sortable list: `containerProps` owns the scroller's ref.
const listRef = useTemplateRef<HTMLElement>('list');
const { items } = makeSortableList(listRef, { items: tasks });

// Fed with the draft `items`, so the rows move while you drag.
const { list, containerProps, wrapperProps } = useVirtualList(items, { itemHeight: ROW, overscan: 8 });
</script>

<template>
  <div ref="list">
    <div v-bind="containerProps" style="height: 308px">
      <div v-bind="wrapperProps">
        <!-- Keyed by id, not by index as in VueUse's docs. -->
        <Row v-for="{ data } in list" :key="data.id" :task="data" :height="ROW" />
      </div>
    </div>
  </div>
</template>
  • itemHeight es la altura real de la fila, bordes incluidos. No pongas márgenes entre filas: VueUse no los cuenta.
  • La lista ordenable es el elemento exterior, porque containerProps vincula su propia ref al contenedor desplazable. El desplazamiento automático encuentra igualmente ese contenedor: desplaza los contenedores que hay bajo el puntero.
  • VueUse no puede mantener una fila montada: la fila arrastrada se desmonta cuando la desplazas fuera de la vista. No pasa nada, consulta Qué sigue montado.

TanStack Virtual

TanStack mide cada fila, así que las filas pueden ser tan altas como su contenido. También puede mantener montada la fila arrastrada por mucho que te desplaces:

Live

Cargando la demo…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import NoteList from './NoteList.vue';
</script>

<template>
  <DnDProvider>
    <NoteList />
  </DnDProvider>
</template>
NoteList.vue
<script setup lang="ts">
import { computed, shallowRef, useTemplateRef } from 'vue';
import { defaultRangeExtractor, useVirtualizer, type Range } from '@tanstack/vue-virtual';
import { makeSortableList } from '@vue-dnd-kit/core';

const notes = shallowRef(makeNotes(5_000));

// The scroller is the sortable list: the library sorts it and auto-scrolls it.
const scrollRef = useTemplateRef<HTMLElement>('scroll');
const { items, activeIndex, captureLayout } = makeSortableList(scrollRef, { items: notes });

// One computed that reads the draft once: TanStack memoizes the measurements and the range
// on `getItemKey` and `rangeExtractor`, so they must be new functions after every reorder.
const virtualizer = useVirtualizer(
  computed(() => {
    const arr = items.value;
    const pin = activeIndex.value;
    return {
      count: arr.length,
      getScrollElement: () => scrollRef.value,
      estimateSize: () => 92,
      overscan: 6,
      getItemKey: (index: number) => arr[index]!.id,
      // The dragged row stays mounted, however far you scroll from it.
      rangeExtractor: (range: Range) => {
        const indexes = defaultRangeExtractor(range);
        return pin < 0 || indexes.includes(pin) ? indexes : [...indexes, pin].sort((a, b) => a - b);
      },
      // Measurements land after the library's animation pass: animate them too.
      onChange: () => captureLayout(),
    };
  }),
);
const rows = computed(() => virtualizer.value.getVirtualItems());
const measure = (el: unknown) => virtualizer.value.measureElement(el as HTMLElement | null);
</script>

<template>
  <div ref="scroll" style="height: 340px; overflow: auto; contain: strict">
    <div :style="{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }">
      <div
        v-for="row in rows"
        :key="(row.key as string)"
        :ref="measure"
        :data-index="row.index"
        :style="{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${row.start}px)` }"
      >
        <NoteRow :note="items[row.index]!" />
      </div>
    </div>
  </div>
</template>

Qué hace cada pieza:

PiezaPor qué
count y getItemKey a partir de itemsEl virtualizador renderiza el borrador, así que las filas se mueven mientras arrastras.
Un solo computed alrededor de las opcionesgetItemKey y rangeExtractor deben ser funciones nuevas tras cada reordenación, o TanStack conserva las medidas y el rango anteriores.
measureElement con data-indexCada fila se mide al renderizarse: estimateSize es solo una primera estimación.
rangeExtractor con activeIndexAñade la fila arrastrada al rango renderizado cuando sale de él al desplazarse. activeIndex es -1 cuando no se arrastra ninguna fila de esta lista.
onChange: () => captureLayout()Una nueva medición mueve filas después de que la biblioteca haya animado una reordenación. captureLayout() anima también ese movimiento. Fuera de un arrastre no hace nada.

¿Cuándo sale la fila arrastrada del rango? Su hueco sigue al puntero mientras el puntero está sobre la lista. Lleva el puntero justo debajo de la lista y mantenlo ahí: el desplazamiento automático continúa, el hueco se queda donde estaba y pronto queda muy por encima de las filas en pantalla. La fijación lo mantiene montado, para que el drop aterrice en un elemento real. Una lista con placeholder: 'indicator' mantiene la fila arrastrada en su sitio durante todo el arrastre, así que ahí importa todavía más.

  • Fijar requiere filas posicionadas una a una, en absoluto, como arriba. Un virtualizador que renderiza un único bloque con padding no puede añadir una fila lejana.
  • El componente de fila pasa su id como getter: makeSortableItem(el, { id: () => props.note.id }).
  • Mantén el anchorTo: 'start' de TanStack, el valor por defecto. Con lanes, usa @tanstack/virtual-core 3.19 o posterior (@tanstack/vue-virtual lo instala).

Columnas kanban virtuales

Cada columna es su propia lista ordenable y su propio virtualizador, y las listas comparten un group. Tres columnas de 1000 tarjetas renderizan unas pocas decenas:

Live

Cargando la demo…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';
import { makeColumns } from './cards';

// 3,000 cards; each column renders the dozen you can see.
const columns = makeColumns(1_000);
</script>

<template>
  <DnDProvider>
    <div class="board">
      <Column v-for="column in columns" :key="column.id" :column="column" />
    </div>
  </DnDProvider>
</template>

<style scoped>
/* Three columns; on a phone they keep their width and the board scrolls sideways. */
.board {
  display: grid;
  grid-template-columns: repeat(3, minmax(200px, 1fr));
  gap: 10px;
  width: 100%;
  max-width: 680px;
  margin: 0 auto;
  overflow-x: auto;
}
</style>

Una tarjeta que llega de otra columna todavía no tiene medida en esta columna. Estímala con el tamaño real de la tarjeta arrastrada, activeSize, leído en el mismo computed:

Column.vue
const { items, activeIndex, activeSize, captureLayout } = makeSortableList(scrollRef, {
  id: props.column.id,
  items: props.column.cards, // a shallowRef per column
  group: 'board',
});

const virtualizer = useVirtualizer(
  computed(() => {
    const arr = items.value;
    const pin = activeIndex.value;
    const size = activeSize.value;
    return {
      count: arr.length,
      getScrollElement: () => scrollRef.value,
      // A card coming from another column gets its real height before it is measured.
      estimateSize: (index: number) => (index === pin && size ? size.height + GAP : 72),
      // …getItemKey, rangeExtractor and onChange as above
    };
  }),
);
  • Da a cada columna sus datos como un shallowRef: la lista escribe en él sus drops, y 1000 tarjetas no reciben proxies profundos.
  • activeSize es el tamaño del elemento arrastrado al inicio del arrastre. Suma lo que tu hueco pone alrededor de la tarjeta: aquí el espacio de debajo (GAP) es el padding del hueco, así que el virtualizador también lo mide.
  • La cabecera cuenta items.length, el borrador: cambia en el momento en que una tarjeta entra o sale.

Qué sigue montado

La fila arrastrada puede desmontarse mientras arrastras: salió del rango al desplazarse, o el virtualizador recicló su componente. El arrastre sobrevive. El overlay sigue mostrando el ítem, la selección se conserva por id y el ítem aterriza donde lo pone el borrador. Las animaciones solo tocan las filas montadas.

Cuando el virtualizador lo permita, mantén de todos modos montada la fila arrastrada, como hace la receta de TanStack con rangeExtractor. La lista te dice qué fila es:

Devuelto por la listaPara qué sirve
itemsEl borrador: dáselo al virtualizador.
activeIndexEl índice de la fila que hay que mantener montada en el borrador de esta lista, -1 cuando no está aquí.
activeKeyLa clave de esa fila, null cuando no está aquí.
activeSizeEl tamaño de la fila arrastrada al inicio del arrastre: estima con él una fila entrante.
captureLayout()Llámalo justo antes de un cambio de maquetación que no hizo la biblioteca (una nueva medición). Anima las filas desde donde estaban. Fuera de un arrastre no hace nada.

Árboles

makeSortableTree devuelve un items plano con las filas visibles, así que un árbol se virtualiza como una lista: con node.key como clave, y con activeIndex y activeKey indicando la fila que hay que mantener montada. Consulta Árboles.