排序是数据驱动的:每次重新排序时,草稿 items 都是一个新数组,行以项为 key,动画也只需要已挂载的行。所以任何根据数组渲染的虚拟化器都能用——把草稿而不是你的数组交给它。
正在加载演示…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import TaskList from './TaskList.vue';
</script>
<template>
<DnDProvider>
<TaskList />
</DnDProvider>
</template>规则
- 把草稿
items交给虚拟化器,而不是你的源数组。 - 用项的 key 作为行的 key,永远不要用索引。TanStack 和 VueUse 的官方示例用索引作为 key;把它改掉。
- 以 getter 的形式把 id 传给
makeSortableItem,即{ id: () => props.item.id }:虚拟化器可能会把另一个项交给同一个行组件。 - 把大数组放在
shallowRef中。库从不深度遍历你的数据。 - 让占位元素与行保持相同尺寸:隐藏它的内容,不要让它塌陷。
- 至少预渲染(overscan)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 | 虚拟化器渲染的是草稿,所以拖拽时行会移动。 |
用一个 computed 包住选项 | 每次重新排序后,getItemKey 和 rangeExtractor 都必须是新函数,否则 TanStack 会保留旧的测量结果和范围。 |
带 data-index 的 measureElement | 每一行在渲染时被测量:estimateSize 只是最初的估计。 |
带 activeIndex 的 rangeExtractor | 被拖拽的行滚出渲染范围时,把它加回范围中。本列表没有行被拖拽时,activeIndex 为 -1。 |
onChange: () => captureLayout() | 重新测量会在库为重新排序添加动画之后移动行。captureLayout() 也为这次移动添加动画。在拖拽之外它什么都不做。 |
被拖拽的行什么时候会离开范围?指针位于列表上方时,它的空位会跟随指针。把指针移到列表正下方并停在那里:自动滚动继续,空位留在原处,很快它就远在屏幕上的行之上了。固定让它保持挂载,所以放置会落到一个真实的元素上。使用 placeholder: 'indicator' 的列表在整个拖拽期间都把被拖拽的行留在原处,所以在那里这一点更加重要。
- 固定需要行像上面那样逐一绝对定位。渲染成一整块带内边距区域的虚拟化器无法在很远的地方添加一行。
- 行组件以 getter 形式传入它的 id:
makeSortableItem(el, { id: () => props.note.id })。 - 保持 TanStack 的
anchorTo: 'start',即默认值。使用lanes时,需要@tanstack/virtual-core3.19 或更新版本(@tanstack/vue-virtual会安装它)。
虚拟化的看板列
每一列都是独立的可排序列表和独立的虚拟化器,这些列表共享一个 group。三列各 1,000 张卡片,只渲染几十张:
正在加载演示…
<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 | 那一行的 key,不在这里时为 null。 |
activeSize | 拖拽开始时被拖拽行的尺寸:用它估计进入的行。 |
captureLayout() | 在一次不是由库造成的布局变化(重新测量)之前调用。它会让行从原来的位置以动画过渡。在拖拽之外它什么都不做。 |
树
makeSortableTree 返回由可见行组成的扁平 items,所以树的虚拟化方式与列表相同:以 node.key 为 key,由 activeIndex 和 activeKey 指出需要保持挂载的行。见树。