VirtualTable
Виртуальная таблица, рендерящаяся как нативный элемент <table>. Компонент использует <thead> / <tbody> / <tfoot> с двумя строками-распорками (сверху и снизу) для создания эффекта виртуальной прокрутки, сохраняя при этом встроенную синхронизацию ширины столбцов между заголовком и телом браузера. Высоты строк измеряются ResizeObserver после каждого рендера, поэтому строки могут содержать произвольный контент.
Возможности: липкий заголовок, зафиксированные левые/правые столбцы, одно-/многоколоночная сортировка с индикатором стека сортировки, опциональный drag-to-resize столбцов, горизонтальная виртуализация столбцов, закреплённые верхние/нижние строки и встроенная бесконечная прокрутка (onLoadMore).
Архитектура
<div class="vvsk-table"> ← контейнер прокрутки (overflow: auto)
<table>
<colgroup> ← ширины столбцов, автосинхронизация заголовок ↔ тело
<thead> ← position: sticky top; содержит строку заголовка
<tr> … <th> … ← заголовки столбцов (сортировка по клику)
<tr> … <td> … ← pinnedTopRows (всегда видны, sticky)
<tbody>
<tr class="spacer"> ← верхнее виртуальное пространство (height = offsetTop)
<tr v-for visibleRows> ← рендерятся только видимые строки
<tr class="spacer"> ← нижнее виртуальное пространство
<tfoot> ← position: sticky bottom; pinnedBottomRowsНативное якорение прокрутки браузера (
overflow-anchor) отключено на.vvsk-table, потому что оно конфликтует с техникой виртуальной прокрутки через строки-распорки. Вместо этогоuseVirtualScrollвыполняет собственную компенсацию якоря: когда строка над вьюпортом измеряется с другой высотой, позиция прокрутки сдвигается на ту же дельту, так что видимые строки никогда не прыгают.
Пропы
| Проп | Тип | По умолчанию | Описание |
|---|---|---|---|
columns | ColumnDef[] | — | Определения столбцов |
rows | T[] | — | Строки данных |
estimatedItemSize | number | 40 | Оценочная высота строки до измерения |
stickyHeader | boolean | true | Закрепить строку заголовка сверху |
stickyHeaderOffset | number | 0 | Верхний отступ для липкого заголовка (например, высота навбара) |
sortable | boolean | false | Включить одноколоночную сортировку по клику на заголовок |
multiSort | boolean | false | Включить многоколоночную сортировку через Shift+клик |
virtualizeColumns | boolean | false | Рендерить только горизонтально видимые столбцы (для 50+ столбцов) |
resizableColumns | boolean | false | Разрешить drag-to-resize границ столбцов |
reorderableColumns | boolean | false | Разрешить перетаскивание целого заголовка для изменения порядка столбцов (независимо от resizableColumns) |
pinnedTopRows | T[] | [] | Строки, закреплённые внутри <thead>, всегда видны сверху |
pinnedBottomRows | T[] | [] | Строки, закреплённые внутри <tfoot>, всегда видны снизу |
overscan | number | 3 | Дополнительные строки, рендерящиеся вне вьюпорта |
keyField | string | 'id' | Поле ключа строки (должно быть уникальным для каждой строки) |
onLoadMore | () => void | — | Вызывается, когда пользователь прокручивает почти до конца и hasMore равно true |
hasMore | boolean | false | Доступны ли ещё строки для загрузки |
isLoading | boolean | false | Выполняется ли загрузка в данный момент (предотвращает дублирующие вызовы) |
loadMoreThreshold | number | 150 | Расстояние от нижнего края в px, при котором срабатывает onLoadMore |
uniformRowHeight | boolean | false | Все строки одной высоты — отключает ResizeObserver, чтобы предотвратить дрейф прокрутки. Установите estimatedItemSize в точную высоту строки |
motionBlur | boolean | false | Применить CSS-размытие, масштабируемое по скорости прокрутки при быстрой прокрутке |
ColumnDef
interface ColumnDef {
key: string // соответствует свойству объекта строки
title: string // метка заголовка
width?: number // ширина столбца в px (fallback: minWidth ?? 100)
minWidth?: number // минимальная ширина после drag-resize
maxWidth?: number // максимальная ширина после drag-resize
fixed?: 'left' | 'right' // sticky-зафиксированный столбец
}Слоты
| Слот | Область видимости | Описание |
|---|---|---|
#header-cell | { column: ColumnDef } | Кастомное содержимое ячейки заголовка. По умолчанию рендерит title + стрелку сортировки (↑/↓) только для активного столбца сортировки |
#row | { row: T, index: number } | Полная замена рендера <tr> |
#cell | { row: T, column: ColumnDef, value: unknown, index: number } | Кастомное содержимое ячейки |
#pinned-row | { row: T, index: number, position: 'top' | 'bottom' } | Полная замена закреплённого <tr> |
#pinned-cell | { row: T, column: ColumnDef, value: unknown, position: 'top' | 'bottom', index: number } | Кастомное содержимое закреплённой ячейки |
#loading-indicator | — | Показывается под таблицей, когда isLoading && hasMore |
Emits
| Событие | Payload | Описание |
|---|---|---|
sort-change | SortChange | SortChange[] | Одиночный объект сортировки (sortable) либо массив (multiSort) |
column-resize | [key: string, width: number] | Срабатывает после завершения drag-resize столбца |
column-reorder | [order: string[]] | Срабатывает после завершения drag-reorder столбца, с новым порядком ключей столбцов |
column-visibility-change | { key: string; visible: boolean } | Срабатывает после того, как setColumnVisible/toggleColumnVisible меняет видимость столбца |
scroll | Event | Нативное событие прокрутки контейнера |
visible-range-change | { start: number; end: number } | Срабатывает при прокрутке с текущими индексами видимых строк |
SortChange
interface SortChange {
key: string
direction: 'asc' | 'desc' | null
}Публичный API
import type { VirtualListExpose } from 'vue-virtual-scroller-kit'
const tableRef = ref<
| (VirtualListExpose & {
getSortStack: () => SortChange[]
clearSort: () => void
setColumnVisible: (key: string, visible: boolean) => void
toggleColumnVisible: (key: string) => void
getHiddenColumns: () => string[]
})
| null
>(null)
tableRef.value?.scrollTo(rowIndex, 'start') // 'start' | 'center' | 'end' | 'auto'
tableRef.value?.scrollTo(rowIndex, 'start', { behavior: 'smooth' })
tableRef.value?.scrollToOffset(px)
tableRef.value?.clearSort()
tableRef.value?.getSortStack() // текущее состояние сортировки
tableRef.value?.getScrollElement() // сочетайте с VirtualScrollbar
tableRef.value?.toggleColumnVisible('email') // скрыть/показать столбец во время выполнения
tableRef.value?.setColumnVisible('email', false)
tableRef.value?.getHiddenColumns() // текущие ключи скрытых столбцовСкрытие столбца — это состояние времени выполнения/интерактивности, как и сортировка или порядок столбцов, поэтому оно доступно через template-ref, а не проп. Скрытые столбцы исключаются из рендера, виртуализации столбцов и вычисления смещений зафиксированных столбцов единообразно.
CSS custom property
Зафиксированные столбцы и закреплённые строки используют --vvsk-sticky-bg для фона своих ячеек, чтобы перекрывать прокручивающийся контент позади них. Задайте его на элементе таблицы под свою тему:
.my-table {
--vvsk-sticky-bg: var(--surface-color);
}Fallback по умолчанию — #fff.
Примеры
Базовый — сортировка, зафиксированные столбцы, кастомные ячейки, изменяемые столбцы:
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualTable } from 'vue-virtual-scroller-kit'
import type { ColumnDef, SortChange } from 'vue-virtual-scroller-kit'
interface User {
id: number
name: string
email: string
age: number
}
const originalRows: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com', age: 28 },
{ id: 2, name: 'Bob', email: 'bob@example.com', age: 35 },
// …
]
const columns: ColumnDef[] = [
{ key: 'id', title: '#', width: 60, fixed: 'left' },
{ key: 'name', title: 'Name', width: 180 },
{ key: 'email', title: 'Email', minWidth: 200 },
{ key: 'age', title: 'Age', width: 80 },
]
const rows = ref<User[]>([...originalRows])
function onSort(sort: SortChange | SortChange[]) {
const s = Array.isArray(sort) ? sort[0] : sort
if (!s || !s.direction) {
rows.value = [...originalRows]
return
}
rows.value = [...rows.value].sort((a, b) =>
s.direction === 'asc'
? String(a[s.key as keyof User]).localeCompare(String(b[s.key as keyof User]))
: String(b[s.key as keyof User]).localeCompare(String(a[s.key as keyof User])),
)
}
</script>
<template>
<VirtualTable
:columns="columns"
:rows="rows"
key-field="id"
sortable
resizable-columns
style="height: 500px"
@sort-change="onSort"
>
<template #cell="{ column, value }">
<span
v-if="column.key === 'age'"
:style="{ color: (value as number) < 30 ? 'green' : 'inherit' }"
>
{{ value }}
</span>
<span v-else>{{ value }}</span>
</template>
</VirtualTable>
</template>Многоколоночная сортировка (Shift+клик добавляет столбцы в стек сортировки):
<VirtualTable
:columns="columns"
:rows="rows"
multi-sort
style="height: 500px"
@sort-change="onMultiSort"
/>function onMultiSort(sort: SortChange | SortChange[]) {
const stack = Array.isArray(sort) ? sort : [sort]
rows.value = [...rows.value].sort((a, b) => {
for (const { key, direction } of stack) {
if (!direction) continue
const cmp = String(a[key as keyof Row]).localeCompare(String(b[key as keyof Row]))
if (cmp !== 0) return direction === 'asc' ? cmp : -cmp
}
return 0
})
}Автоматические ширины столбцов — измерение содержимого через Canvas перед рендером:
import { autoColWidths } from 'vue-virtual-scroller-kit'
import type { ColumnDef } from 'vue-virtual-scroller-kit'
const rawCols = [
{ key: 'id', title: 'ID' },
{ key: 'name', title: 'Name' },
{ key: 'email', title: 'Email' },
]
// Вызывайте после загрузки строк
const widths = autoColWidths(rawCols, rows, {
font: '12px Inter, sans-serif',
padding: 24,
maxWidth: 400,
})
const columns: ColumnDef[] = rawCols.map((c) => ({
key: c.key,
title: c.title,
width: widths.get(c.key) ?? 120,
minWidth: 60,
}))Закреплённые строки — строки, остающиеся видимыми, пока тело прокручивается. Верхние строки живут в <thead> (sticky сверху), нижние — в <tfoot> (sticky снизу):
<script setup lang="ts">
const pinnedTop = [{ id: -1, name: '📌 Pinned', score: 0 }]
const pinnedBottom = [{ id: -2, name: '∑ Total', score: totalScore }]
</script>
<template>
<VirtualTable
:columns="columns"
:rows="rows"
:pinned-top-rows="pinnedTop"
:pinned-bottom-rows="pinnedBottom"
style="height: 500px; --vvsk-sticky-bg: #fff"
>
<template #pinned-cell="{ row, column, position }">
<strong v-if="position === 'bottom'">{{ row[column.key] }}</strong>
<span v-else>{{ row[column.key] }}</span>
</template>
</VirtualTable>
</template>Закреплённые ячейки автоматически получают
background-color: var(--vvsk-sticky-bg, #fff), так что они всегда перекрывают прокручивающиеся строки позади них.
Ленивая загрузка — бесконечная прокрутка, срабатывающая рядом с концом:
<script setup lang="ts">
import { ref, computed } from 'vue'
import { VirtualTable, autoColWidths } from 'vue-virtual-scroller-kit'
import type { ColumnDef, SortChange } from 'vue-virtual-scroller-kit'
interface Row {
id: number
name: string
email: string
}
const PAGE = 100
const rows = ref<Row[]>([])
const total = ref(0)
const loading = ref(false)
const hasMore = computed(() => rows.value.length < total.value)
// Столбцы, размеры которых зависят от данных после первой загрузки
const colWidths = ref<Map<string, number>>(new Map())
const rawCols = [
{ key: 'id', title: '#' },
{ key: 'name', title: 'Name' },
{ key: 'email', title: 'Email' },
]
const columns = computed((): ColumnDef[] => [
...rawCols.map((c) => ({
key: c.key,
title: c.title,
width: colWidths.value.get(c.key) ?? 120,
minWidth: 60,
})),
{ key: '__actions', title: '', width: 80, fixed: 'right' as const },
])
async function fetchRows(page: number, replace: boolean) {
if (loading.value) return
loading.value = true
try {
const res = await fetch(`/api/rows?page=${page}&limit=${PAGE}`)
const data = await res.json()
rows.value = replace ? data.rows : [...rows.value, ...data.rows]
total.value = data.total
if (replace)
colWidths.value = autoColWidths(rawCols, rows.value, { font: '12px Inter, sans-serif' })
} finally {
loading.value = false
}
}
function loadMore() {
fetchRows(Math.floor(rows.value.length / PAGE) + 1, false)
}
function onSort(sort: SortChange | SortChange[]) {
rows.value = []
fetchRows(1, true)
}
fetchRows(1, true)
</script>
<template>
<VirtualTable
:columns="columns"
:rows="rows"
key-field="id"
sortable
resizable-columns
:on-load-more="loadMore"
:has-more="hasMore"
:is-loading="loading"
:load-more-threshold="200"
style="height: 600px; --vvsk-sticky-bg: #fff"
@sort-change="onSort"
>
<template #cell="{ row, column }">
<template v-if="column.key === '__actions'">
<button @click="edit(row)">✏</button>
<button @click="remove(row)">✕</button>
</template>
<span v-else>{{ row[column.key as keyof Row] }}</span>
</template>
<template #loading-indicator>
<div style="padding: 12px; text-align: center; opacity: 0.5">Loading…</div>
</template>
</VirtualTable>
</template>Виртуализация столбцов — для очень широких таблиц (100+ столбцов), рендерятся только видимые столбцы:
<VirtualTable :columns="columns" :rows="rows" :virtualize-columns="true" style="height: 500px" />Drag-to-reorder столбцов — перетащите весь заголовок, чтобы переместить его; независимо от resizableColumns (перетаскивание ручки изменения размера на краю столбца никогда не запускает reorder):
<script setup lang="ts">
function onColumnReorder(order: string[]) {
// Сохраните новый порядок столбцов, например в localStorage.
localStorage.setItem('table-column-order', JSON.stringify(order))
}
</script>
<template>
<VirtualTable
:columns="columns"
:rows="rows"
reorderable-columns
style="height: 500px"
@column-reorder="onColumnReorder"
/>
</template>Порядок столбцов отслеживается внутренне (как и ширины
resizableColumns) и не записывается обратно в ваш пропcolumns— слушайтеcolumn-reorder, если хотите его сохранить.
Показ/скрытие столбцов — чек-лист, переключающий столбцы во время выполнения:
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualTable } from 'vue-virtual-scroller-kit'
const tableRef = ref<InstanceType<typeof VirtualTable> | null>(null)
</script>
<template>
<label v-for="col in columns" :key="col.key">
<input type="checkbox" checked @change="tableRef?.toggleColumnVisible(col.key)" />
{{ col.title }}
</label>
<VirtualTable ref="tableRef" :columns="columns" :rows="rows" style="height: 500px" />
</template>Как и
columnOrder, состояние скрытости живёт внутри компонента (не записывается обратно вcolumns) — слушайтеcolumn-visibility-change, чтобы сохранить его.
Выбор строк с чекбоксами — сочетает useRowSelection с index, теперь доступным на #cell/#pinned-cell. Клик переключает, Shift+клик выбирает диапазон:
<script setup lang="ts">
import { computed } from 'vue'
import { VirtualTable, useRowSelection } from 'vue-virtual-scroller-kit'
import type { ColumnDef } from 'vue-virtual-scroller-kit'
interface User {
id: number
name: string
email: string
}
const rows = ref<User[]>([/* … */])
const selection = useRowSelection<User>({ items: rows })
const columns: ColumnDef[] = [
{ key: '__select', title: '', width: 36, fixed: 'left' },
{ key: 'name', title: 'Name' },
{ key: 'email', title: 'Email' },
]
</script>
<template>
<VirtualTable :columns="columns" :rows="rows" key-field="id" style="height: 500px">
<template #cell="{ column, row, index }">
<input
v-if="column.key === '__select'"
type="checkbox"
:checked="selection.isSelected(row, index)"
@click="selection.toggle(row, index, $event)"
/>
<span v-else>{{ row[column.key as keyof User] }}</span>
</template>
</VirtualTable>
<p>{{ selection.selectedItems.value.length }} selected</p>
</template>