정렬은 데이터 중심입니다. 초안 items는 순서가 바뀔 때마다 새 배열이 되고, 행은 항목으로
식별되며, 애니메이션에는 마운트된 행만 있으면 됩니다. 그래서 배열로 렌더링하는 가상화 도구라면 무엇이든
동작합니다. 원본 배열 대신 초안을 넘기세요.
데모 불러오는 중…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import TaskList from './TaskList.vue';
</script>
<template>
<DnDProvider>
<TaskList />
</DnDProvider>
</template>규칙
- 가상화 도구에는 원본 배열이 아니라 **초안
items**를 넘기세요. - 행은 항목의 key로 식별하세요. 인덱스는 절대 안 됩니다. TanStack과 VueUse의 공식 예제는 인덱스를 key로 씁니다. 이를 바꾸세요.
makeSortableItem에는 id를 getter로 넘기세요:{ 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'인 리스트는 드래그 내내 드래그 중인 행을 제자리에 두므로, 이
고정이 더욱 중요합니다.
- 고정하려면 위처럼 행을 하나씩 절대 위치로 배치해야 합니다. 패딩을 준 블록 하나로 렌더링하는 가상화 도구는 멀리 떨어진 행을 추가할 수 없습니다.
- 행 컴포넌트는 id를 getter로 넘깁니다:
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로 식별하고, activeIndex와 activeKey로 마운트를 유지할 행을 알 수 있습니다.
트리를 참고하세요.