Dokumentation/Leitfäden

Virtuelle Listen

Sortiere Tausende von Zeilen mit TanStack Virtual oder useVirtualList von VueUse – gib dem Virtualizer den Entwurf, gib Zeilen ihre ID als Schlüssel und halte die gezogene Zeile gemountet.

Diese Seite wurde von KI übersetzt und kann Ungenauigkeiten enthalten. Englisches Original lesen

Sortieren ist datengetrieben: Der Entwurf items ist bei jeder Neuordnung ein neues Array, Zeilen werden über ihr Element identifiziert, und die Animationen brauchen nur die Zeilen, die gemountet sind. Also funktioniert jeder Virtualizer, der aus einem Array rendert – gib ihm den Entwurf statt deines Arrays.

Live

Demo wird geladen…

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

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

Die Regeln

  1. Gib dem Virtualizer den Entwurf items, nicht dein Quell-Array.
  2. Schlüssel Zeilen über den Schlüssel des Elements, nie über den Index. Die offiziellen Beispiele von TanStack und VueUse nehmen den Index als Schlüssel; ändere das.
  3. Übergib IDs an makeSortableItem als Getter, { id: () => props.item.id }: Der Virtualizer kann einer Zeilenkomponente ein anderes Element geben.
  4. Halte große Arrays in einem shallowRef. Die Bibliothek durchläuft deine Daten nie tief.
  5. Halte den Platzhalter so groß wie die Zeile: Verbirg seinen Inhalt, lass ihn nicht zusammenfallen.
  6. Rendere mindestens 5 Zeilen über den sichtbaren Bereich hinaus (Overscan; VueUse: mindestens 1), damit Zeilen gemountet sind, bevor Auto-Scroll sie erreicht.
  7. Animiere nicht height, margin oder padding der Zeilen und setz kein will-change auf den Scroller.

VueUse useVirtualList

Die Demo oben. Jede Zeile hat dieselbe Höhe, und die Rechnung von VueUse bleibt 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 ist die echte Höhe der Zeile, Rahmen eingeschlossen. Setz keine Margins zwischen Zeilen: VueUse zählt sie nicht mit.
  • Die sortierbare Liste ist das äußere Element, weil containerProps sein eigenes ref an den Scroller bindet. Auto-Scroll findet den Scroller trotzdem: Es scrollt die Container unter dem Zeiger.
  • VueUse kann eine Zeile nicht gemountet halten: Die gezogene Zeile wird unmountet, wenn du sie wegscrollst. Das ist in Ordnung, siehe Was gemountet bleibt.

TanStack Virtual

TanStack misst jede Zeile, Zeilen können also so hoch sein wie ihr Inhalt. Es kann außerdem die gezogene Zeile gemountet halten, egal wie weit du von ihr wegscrollst:

Live

Demo wird geladen…

<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>

Was jedes Teil tut:

TeilWarum
count und getItemKey aus itemsDer Virtualizer rendert den Entwurf, also bewegen sich Zeilen beim Ziehen.
Ein computed um die OptionengetItemKey und rangeExtractor müssen nach jeder Neuordnung neue Funktionen sein, sonst behält TanStack die alten Messungen und den alten Bereich.
measureElement mit data-indexJede Zeile wird beim Rendern gemessen: estimateSize ist nur eine erste Schätzung.
rangeExtractor mit activeIndexFügt die gezogene Zeile dem gerenderten Bereich hinzu, wenn sie aus ihm herausscrollt. activeIndex ist -1, wenn keine Zeile dieser Liste gezogen wird.
onChange: () => captureLayout()Ein Neumessen verschiebt Zeilen, nachdem die Bibliothek eine Neuordnung animiert hat. captureLayout() animiert auch diese Bewegung. Außerhalb eines Drags tut es nichts.

Wann verlässt die gezogene Zeile den Bereich? Ihr Slot folgt dem Zeiger, solange der Zeiger über der Liste ist. Bewege den Zeiger knapp unter die Liste und halte ihn dort: Auto-Scroll läuft weiter, der Slot bleibt, wo er war, und bald liegt er weit über den Zeilen auf dem Bildschirm. Das Anheften hält ihn gemountet, also landet der Drop auf einem echten Element. Eine Liste mit placeholder: 'indicator' hält die gezogene Zeile während des ganzen Drags an ihrem Platz, dort ist es also noch wichtiger.

  • Anheften braucht Zeilen, die einzeln und absolut positioniert sind, wie oben. Ein Virtualizer, der einen gepolsterten Block rendert, kann keine weit entfernte Zeile hinzufügen.
  • Die Zeilenkomponente übergibt ihre ID als Getter: makeSortableItem(el, { id: () => props.note.id }).
  • Behalte bei TanStack anchorTo: 'start', den Standard. Nimm mit lanes@tanstack/virtual-core 3.19 oder neuer (@tanstack/vue-virtual installiert es).

Virtuelle Kanban-Spalten

Jede Spalte ist eine eigene sortierbare Liste mit eigenem Virtualizer, und die Listen teilen sich eine group. Drei Spalten mit je 1.000 Karten rendern ein paar Dutzend:

Live

Demo wird geladen…

<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>

Eine Karte, die aus einer anderen Spalte hereinkommt, hat in dieser Spalte noch keine Messung. Schätze sie auf die echte Größe der gezogenen Karte, activeSize, gelesen im selben 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
    };
  }),
);
  • Gib jeder Spalte ihre Daten als shallowRef: Die Liste schreibt ihre Drops hinein, und 1.000 Karten bekommen keine tiefen Proxys.
  • activeSize ist die Größe des gezogenen Elements zu Beginn des Drags. Rechne hinzu, was dein Slot um die Karte legt: Hier ist der Abstand darunter (GAP) das Padding des Slots, also misst der Virtualizer ihn mit.
  • Die Kopfzeile zählt items.length, den Entwurf: Sie ändert sich in dem Moment, in dem eine Karte hereinkommt oder geht.

Was gemountet bleibt

Die gezogene Zeile kann während des Ziehens unmountet werden: Sie ist aus dem Bereich gescrollt, oder der Virtualizer hat ihre Komponente recycelt. Der Drag übersteht das. Das Overlay zeigt das Element weiter, die Auswahl wird über die ID gehalten, und das Element landet dort, wo der Entwurf es hinsetzt. Animationen betreffen nur die Zeilen, die gemountet sind.

Wo der Virtualizer es kann, halte die gezogene Zeile trotzdem gemountet, wie es das TanStack-Rezept mit rangeExtractor tut. Die Liste sagt dir, welche Zeile das ist:

Von der Liste zurückgegebenWofür es ist
itemsDer Entwurf: Gib ihn dem Virtualizer.
activeIndexDer Index der Zeile, die im Entwurf dieser Liste gemountet bleiben soll, -1, wenn sie nicht hier ist.
activeKeyDer Schlüssel dieser Zeile, null, wenn sie nicht hier ist.
activeSizeDie Größe der gezogenen Zeile zu Beginn des Drags: Schätze damit eine hereinkommende Zeile.
captureLayout()Ruf es direkt vor einer Layoutänderung auf, die nicht von der Bibliothek kommt (ein Neumessen). Es animiert die Zeilen von dort, wo sie waren. Außerhalb eines Drags tut es nichts.

Bäume

makeSortableTree gibt ein flaches items der sichtbaren Zeilen zurück, ein Baum wird also wie eine Liste virtualisiert: mit node.key als Schlüssel, und activeIndex und activeKey nennen die Zeile, die gemountet bleiben soll. Siehe Bäume.