並べ替えはデータ駆動です。ドラフトの items は並べ替えのたびに新しい配列になり、行はアイテムでキー付けされ、アニメーションにはマウントされている行だけがあれば十分です。そのため、配列から描画する仮想化ライブラリなら何でも使えます——配列の代わりにドラフトを渡してください。
デモを読み込み中…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import TaskList from './TaskList.vue';
</script>
<template>
<DnDProvider>
<TaskList />
</DnDProvider>
</template>ルール
- 仮想化ライブラリには、元の配列ではなくドラフトの
itemsを渡します。 - 行はアイテムのキーでキー付けし、インデックスは使いません。TanStack と VueUse の公式例はインデックスでキー付けしているので、変更してください。
makeSortableItemには id をゲッターとして渡します:{ id: () => props.item.id }。仮想化ライブラリは行コンポーネントに別のアイテムを渡すことがあります。- 大きな配列は
shallowRefに保持します。ライブラリがデータを深く走査することはありません。 - プレースホルダーは行と同じサイズに保ちます。中身は隠しても、潰さないでください。
- オーバースキャンは少なくとも 5 行(VueUse では少なくとも 1 行)にして、自動スクロールが届く前に行がマウントされるようにします。
- 行の
height、margin、paddingをアニメーションせず、スクロールコンテナーにwill-changeを付けないでください。
VueUse useVirtualList
上のデモです。すべての行が同じ高さで、VueUse の計算は O(1) のままです。
<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はボーダーを含む行の実際の高さです。行の間にマージンを入れないでください。VueUse はそれを数えません。containerPropsが自身のrefをスクロールコンテナーにバインドするので、並べ替え可能なリストは外側の要素にします。自動スクロールはそれでもスクロールコンテナーを見つけます。ポインターの下のコンテナーをスクロールするからです。- VueUse は行をマウントしたままにできません。ドラッグ中の行は、スクロールで見えなくなるとアンマウントされます。それで問題ありません。マウントされたままのものを参照してください。
TanStack Virtual
TanStack はすべての行を測定するので、行の高さを中身に合わせられます。また、どれだけ遠くへスクロールしても、ドラッグ中の行をマウントしたままにできます。
デモを読み込み中…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import NoteList from './NoteList.vue';
</script>
<template>
<DnDProvider>
<NoteList />
</DnDProvider>
</template><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>
それぞれの部分の役割:
| 部分 | 理由 |
|---|---|
items からの count と getItemKey | 仮想化ライブラリがドラフトを描画するので、ドラッグ中に行が動きます。 |
オプション全体を 1 つの computed で囲む | 並べ替えのたびに getItemKey と rangeExtractor が新しい関数である必要があります。そうでないと TanStack は古い測定結果と範囲を保ちます。 |
data-index 付きの measureElement | 各行は描画時に測定されます。estimateSize は最初の推定にすぎません。 |
activeIndex 付きの rangeExtractor | ドラッグ中の行が描画範囲から外れたとき、それを範囲に加えます。このリストの行がドラッグされていなければ activeIndex は -1 です。 |
onChange: () => captureLayout() | 再測定は、ライブラリが並べ替えをアニメーションした後に行を動かします。captureLayout() はその移動もアニメーションします。ドラッグ中でなければ何もしません。 |
ドラッグ中の行が範囲から外れるのはどんなときでしょうか? ポインターがリストの上にある間、その枠はポインターに追従します。ポインターをリストのすぐ下へ動かしてそこに保つと、自動スクロールは続きますが、枠は元の場所にとどまり、すぐに画面上の行よりはるか上になります。固定によってマウントされたままなので、ドロップは実際の要素に着地します。placeholder: 'indicator' のリストはドラッグ中ずっとドラッグ中の行を元の場所に置いたままにするので、そこではなおさら重要です。
- 固定するには、上のように行を 1 つずつ絶対配置する必要があります。パディング付きの 1 つのブロックとして描画する仮想化ライブラリでは、遠く離れた行を追加できません。
- 行コンポーネントは id をゲッターとして渡します:
makeSortableItem(el, { id: () => props.note.id })。 - TanStack の
anchorTo: 'start'(デフォルト)はそのままにしてください。lanesを使う場合は、@tanstack/virtual-core3.19 以降(@tanstack/vue-virtualがインストールします)を使ってください。
仮想化したカンバンの列
各列はそれぞれ独自の並べ替え可能なリストと独自の仮想化ライブラリを持ち、リストは group を共有します。1,000 枚のカードの列が 3 つあっても、描画されるのは数十枚です。
デモを読み込み中…
<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>別の列から入ってくるカードには、この列での測定結果がまだありません。同じ computed で読み取った、ドラッグ中のカードの実際のサイズ activeSize で推定してください。
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
};
}),
);
- 各列のデータは
shallowRefとして渡してください。リストはそこへドロップを書き込み、1,000 枚のカードに深いプロキシが作られることはありません。 activeSizeはドラッグ開始時のドラッグされた要素のサイズです。枠がカードの周りに加えるものを足してください。ここではカードの下の隙間(GAP)が枠のパディングなので、仮想化ライブラリはそれも測定します。- ヘッダーはドラフトである
items.lengthを数えるので、カードが入ったり出たりした瞬間に変わります。
マウントされたままのもの
ドラッグ中の行は、ドラッグ中にアンマウントされることがあります:スクロールで範囲から外れた、または仮想化ライブラリがコンポーネントを再利用した場合です。それでもドラッグは続きます。オーバーレイはアイテムを表示し続け、選択は id で保たれ、アイテムはドラフトが置く場所に着地します。アニメーションが扱うのはマウントされている行だけです。
それでも、仮想化ライブラリが対応していれば、TanStack のレシピが rangeExtractor で行っているように、ドラッグ中の行はマウントしたままにしてください。どの行かはリストが教えてくれます。
| リストが返すもの | 用途 |
|---|---|
items | ドラフト:仮想化ライブラリに渡します。 |
activeIndex | このリストのドラフトで、マウントしたままにする行のインデックス。ここにない場合は -1。 |
activeKey | その行のキー。ここにない場合は null。 |
activeSize | ドラッグ開始時のドラッグ中の行のサイズ:入ってくる行の推定に使います。 |
captureLayout() | ライブラリによらないレイアウトの変化(再測定)の直前に呼びます。行を元の位置からアニメーションします。ドラッグ中でなければ何もしません。 |
ツリー
makeSortableTree は表示されている行をフラットな items として返すので、ツリーもリストと同じように仮想化できます:node.key でキー付けし、activeIndex と activeKey がマウントしたままにする行を示します。ツリーを参照してください。