Documentation/Guides

Listes virtuelles

Triez des milliers de lignes avec TanStack Virtual ou le useVirtualList de VueUse — donnez le brouillon au virtualiseur, utilisez l'id des lignes comme clé, et gardez la ligne glissée montée.

Modifier cette page

Cette page a été traduite par IA et peut contenir des inexactitudes. Lire l'original en anglais

Le tri est piloté par les données : le brouillon items est un nouveau tableau à chaque réordonnancement, les lignes ont pour clé leur item, et les animations n'ont besoin que des lignes montées. N'importe quel virtualiseur qui rend à partir d'un tableau fonctionne donc — donnez-lui le brouillon au lieu de votre tableau.

Live

Chargement de la démo…

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

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

Les règles

  1. Donnez au virtualiseur le brouillon items, pas votre tableau source.
  2. Utilisez la clé de l'item comme clé des lignes, jamais l'index. Les exemples officiels de TanStack et de VueUse utilisent l'index ; changez cela.
  3. Passez les ids à makeSortableItem sous forme de getter, { id: () => props.item.id } : le virtualiseur peut confier un autre item à un composant de ligne.
  4. Gardez les gros tableaux dans un shallowRef. La bibliothèque ne parcourt jamais vos données en profondeur.
  5. Gardez au placeholder la même taille que la ligne : masquez son contenu, ne le réduisez pas.
  6. Prévoyez un overscan d'au moins 5 lignes (VueUse : au moins 1), pour que les lignes soient montées avant que le défilement automatique ne les atteigne.
  7. N'animez pas la height, la margin ou le padding des lignes, et ne mettez pas de will-change sur le conteneur défilant.

VueUse useVirtualList

La démo ci-dessus. Toutes les lignes ont la même hauteur, et le calcul de VueUse reste en 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 est la hauteur réelle de la ligne, bordures comprises. Ne mettez pas de marges entre les lignes : VueUse ne les compte pas.
  • La liste triable est l'élément extérieur, parce que containerProps lie sa propre ref au conteneur défilant. Le défilement automatique trouve quand même ce conteneur : il fait défiler les conteneurs sous le pointeur.
  • VueUse ne peut pas garder une ligne montée : la ligne glissée est démontée quand vous la faites défiler hors de vue. Ce n'est pas un problème, voir Ce qui reste monté.

TanStack Virtual

TanStack mesure chaque ligne : les lignes peuvent donc être aussi hautes que leur contenu. Il peut aussi garder la ligne glissée montée, aussi loin que vous fassiez défiler :

Live

Chargement de la démo…

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

Ce que fait chaque morceau :

MorceauPourquoi
count et getItemKey à partir de itemsLe virtualiseur rend le brouillon : les lignes bougent donc pendant le glissement.
Un seul computed autour des optionsgetItemKey et rangeExtractor doivent être de nouvelles fonctions après chaque réordonnancement, sinon TanStack garde les anciennes mesures et l'ancienne plage.
measureElement avec data-indexChaque ligne est mesurée au moment où elle est rendue : estimateSize n'est qu'une première estimation.
rangeExtractor avec activeIndexAjoute la ligne glissée à la plage rendue quand elle en sort en défilant. activeIndex vaut -1 quand aucune ligne de cette liste n'est glissée.
onChange: () => captureLayout()Une nouvelle mesure déplace des lignes après que la bibliothèque a animé un réordonnancement. captureLayout() anime aussi ce déplacement. En dehors d'un glissement, il ne fait rien.

Quand la ligne glissée quitte-t-elle la plage ? Son emplacement suit le pointeur tant que le pointeur est au-dessus de la liste. Placez le pointeur juste sous la liste et maintenez-le là : le défilement automatique continue, l'emplacement reste là où il était, et il se retrouve vite loin au-dessus des lignes à l'écran. L'épinglage le garde monté, pour que le dépôt atterrisse sur un vrai élément. Une liste avec placeholder: 'indicator' garde la ligne glissée en place pendant tout le glissement : c'est donc encore plus important dans ce cas.

  • L'épinglage demande des lignes positionnées une à une, en absolu, comme ci-dessus. Un virtualiseur qui rend un seul bloc avec du padding ne peut pas ajouter une ligne éloignée.
  • Le composant de ligne passe son id sous forme de getter : makeSortableItem(el, { id: () => props.note.id }).
  • Gardez l'anchorTo: 'start' de TanStack, la valeur par défaut. Avec lanes, utilisez @tanstack/virtual-core 3.19 ou plus récent (@tanstack/vue-virtual l'installe).

Colonnes kanban virtuelles

Chaque colonne est sa propre liste triable et son propre virtualiseur, et les listes partagent un group. Trois colonnes de 1 000 cartes en rendent quelques dizaines :

Live

Chargement de la démo…

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

Une carte qui arrive d'une autre colonne n'a pas encore de mesure dans cette colonne. Estimez-la à la taille réelle de la carte glissée, activeSize, lue dans le même 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
    };
  }),
);
  • Donnez à chaque colonne ses données sous forme de shallowRef : la liste y écrit ses dépôts, et 1 000 cartes ne reçoivent aucun proxy profond.
  • activeSize est la taille de l'élément glissé au début du glissement. Ajoutez ce que votre emplacement met autour de la carte : ici, l'espace en dessous (GAP) est le padding de l'emplacement, que le virtualiseur mesure donc aussi.
  • L'en-tête compte items.length, le brouillon : il change dès qu'une carte entre ou sort.

Ce qui reste monté

La ligne glissée peut être démontée pendant le glissement : elle est sortie de la plage en défilant, ou le virtualiseur a recyclé son composant. Le glissement y survit. L'overlay continue d'afficher l'item, la sélection est gardée par id, et l'item atterrit là où le brouillon le place. Les animations ne touchent que les lignes montées.

Quand le virtualiseur le permet, gardez quand même la ligne glissée montée, comme le fait la recette TanStack avec rangeExtractor. La liste vous dit de quelle ligne il s'agit :

Renvoyé par la listeÀ quoi il sert
itemsLe brouillon : donnez-le au virtualiseur.
activeIndexL'index de la ligne à garder montée dans le brouillon de cette liste, -1 quand elle n'y est pas.
activeKeyLa clé de cette ligne, null quand elle n'y est pas.
activeSizeLa taille de la ligne glissée au début du glissement : estimez-en une ligne entrante.
captureLayout()Appelez-la juste avant un changement de mise en page que la bibliothèque n'a pas fait (une nouvelle mesure). Elle anime les lignes depuis l'endroit où elles étaient. En dehors d'un glissement, elle ne fait rien.

Arbres

makeSortableTree renvoie un items à plat des lignes visibles : un arbre se virtualise donc comme une liste, avec node.key comme clé, et activeIndex et activeKey qui désignent la ligne à garder montée. Voir les arbres.