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

Пропы ​

columns ​

ColumnDef[]

Определения столбцов.

rows ​

T[]

Строки данных.

estimatedItemSize ​

number · по умолчанию: 40

Оценочная высота строки до измерения.

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.

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 ​

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.

ts
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() ​

Возвращает текущие ключи скрытых столбцов.

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>