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
Включить одноколоночную сортировку по клику на заголовок. Сортируемые ячейки заголовка также сообщают своё состояние через aria-sort ("ascending", "descending" или "none").
multiSort
boolean · по умолчанию: false
Включить многоколоночную сортировку через Shift+клик. Так же управляет aria-sort на каждом задействованном заголовке, как и sortable.
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 — форма каждой записи в пропе columns.
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
sort-change
Payload: SortChange | SortChange[]
Одиночный объект сортировки (sortable) либо массив (multiSort).
column-resize
Payload: [key: string, width: number]
Срабатывает после завершения drag-resize столбца.
column-reorder
Payload: [order: string[]]
Срабатывает после завершения drag-reorder столбца, с новым порядком ключей столбцов.
column-visibility-change
Payload: { key: string; visible: boolean }
Срабатывает после того, как setColumnVisible/toggleColumnVisible меняет видимость столбца.
scroll
Payload: Event
Нативное событие прокрутки контейнера.
visible-range-change
Payload: { start: number; end: number }
Срабатывает при прокрутке с текущими индексами видимых строк.
Событие сортировки
SortChange — payload события sort-change.
interface SortChange {
key: string
direction: 'asc' | 'desc' | null
}Публичный API
scrollTo(rowIndex, align?, options?)
Прокрутить к строке. align — 'start' | 'center' | 'end' | 'auto'.
scrollToOffset(px, options?)
Прокрутить к пиксельному смещению.
clearSort()
Очищает текущее состояние сортировки.
getSortStack()
Возвращает текущее состояние сортировки (SortChange[]).
getScrollElement()
Возвращает элемент, который реально прокручивается — сочетайте с VirtualScrollbar.
toggleColumnVisible(key)
Скрыть/показать столбец во время выполнения.
setColumnVisible(key, visible)
Явно задать видимость столбца.
getHiddenColumns()
Возвращает текущие ключи скрытых столбцов.
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>