Skip to content

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 выполняет собственную компенсацию якоря: когда строка над вьюпортом измеряется с другой высотой, позиция прокрутки сдвигается на ту же дельту, так что видимые строки никогда не прыгают.

Пропы

ПропТипПо умолчаниюОписание
columnsColumnDef[]Определения столбцов
rowsT[]Строки данных
estimatedItemSizenumber40Оценочная высота строки до измерения
stickyHeaderbooleantrueЗакрепить строку заголовка сверху
stickyHeaderOffsetnumber0Верхний отступ для липкого заголовка (например, высота навбара)
sortablebooleanfalseВключить одноколоночную сортировку по клику на заголовок
multiSortbooleanfalseВключить многоколоночную сортировку через Shift+клик
virtualizeColumnsbooleanfalseРендерить только горизонтально видимые столбцы (для 50+ столбцов)
resizableColumnsbooleanfalseРазрешить drag-to-resize границ столбцов
reorderableColumnsbooleanfalseРазрешить перетаскивание целого заголовка для изменения порядка столбцов (независимо от resizableColumns)
pinnedTopRowsT[][]Строки, закреплённые внутри <thead>, всегда видны сверху
pinnedBottomRowsT[][]Строки, закреплённые внутри <tfoot>, всегда видны снизу
overscannumber3Дополнительные строки, рендерящиеся вне вьюпорта
keyFieldstring'id'Поле ключа строки (должно быть уникальным для каждой строки)
onLoadMore() => voidВызывается, когда пользователь прокручивает почти до конца и hasMore равно true
hasMorebooleanfalseДоступны ли ещё строки для загрузки
isLoadingbooleanfalseВыполняется ли загрузка в данный момент (предотвращает дублирующие вызовы)
loadMoreThresholdnumber150Расстояние от нижнего края в px, при котором срабатывает onLoadMore
uniformRowHeightbooleanfalseВсе строки одной высоты — отключает ResizeObserver, чтобы предотвратить дрейф прокрутки. Установите estimatedItemSize в точную высоту строки
motionBlurbooleanfalseПрименить CSS-размытие, масштабируемое по скорости прокрутки при быстрой прокрутке

ColumnDef

ts
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-changeSortChange | 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 меняет видимость столбца
scrollEventНативное событие прокрутки контейнера
visible-range-change{ start: number; end: number }Срабатывает при прокрутке с текущими индексами видимых строк

SortChange

ts
interface SortChange {
  key: string
  direction: 'asc' | 'desc' | null
}

Публичный API

ts
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 для фона своих ячеек, чтобы перекрывать прокручивающийся контент позади них. Задайте его на элементе таблицы под свою тему:

css
.my-table {
  --vvsk-sticky-bg: var(--surface-color);
}

Fallback по умолчанию — #fff.

Примеры

Базовый — сортировка, зафиксированные столбцы, кастомные ячейки, изменяемые столбцы:

vue
<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+клик добавляет столбцы в стек сортировки):

vue
<VirtualTable
  :columns="columns"
  :rows="rows"
  multi-sort
  style="height: 500px"
  @sort-change="onMultiSort"
/>
ts
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 перед рендером:

ts
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 снизу):

vue
<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), так что они всегда перекрывают прокручивающиеся строки позади них.

Ленивая загрузка — бесконечная прокрутка, срабатывающая рядом с концом:

vue
<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+ столбцов), рендерятся только видимые столбцы:

vue
<VirtualTable :columns="columns" :rows="rows" :virtualize-columns="true" style="height: 500px" />

Drag-to-reorder столбцов — перетащите весь заголовок, чтобы переместить его; независимо от resizableColumns (перетаскивание ручки изменения размера на краю столбца никогда не запускает reorder):

vue
<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, если хотите его сохранить.

Показ/скрытие столбцов — чек-лист, переключающий столбцы во время выполнения:

vue
<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+клик выбирает диапазон:

vue
<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>