vue-virtual-scroller-kit 0.2: большое обновление — RTL, кастомный скроллбар и ещё десяток новых возможностей

17.07.2026
vue-virtual-scroller-kit 0.2: большое обновление — RTL, кастомный скроллбар и ещё десяток новых возможностей

Когда я писал первый пост про vue-virtual-scroller-kit, задача была закрыть базовый набор сценариев виртуализации: списки, таблицы, сетки, деревья, select. С тех пор пакет пожил в реальных проектах, и стало видно, чего не хватает не в теории, а на практике.

Оказалось, что «отрендерить только видимые строки» — это только половина истории. Вторая половина — это то, что пользователь обычно хочет делать с этими строками и вокруг них: выбирать несколько сразу зажатым Shift, искать нужный элемент среди тысяч через API, а не по всему массиву в памяти, настраивать таблицу под себя — двигать, прятать и менять ширину колонок, крутить контент не только вниз, но и вбок, видеть скроллбар, который вписывается в дизайн, а не торчит системной полоской, чувствовать, что список одинаково хорошо работает что слева направо, что справа налево, и — самое неожиданное — просто знать, какой из тысяч элементов сейчас реально виден на экране.

Набралось на десяток пунктов, и один из них — целый новый компонент. Но принцип не поменялся: всё легло поверх существующего API аддитивно, без единого breaking change. Разберу каждую фичу подробно, а в конце — три сценария, где это реально пригождается.


useRowSelection — выбор строк, который переживает скролл

Первое, что приходит в голову для чекбоксов в списке — завести ref(false) внутри самой строки и повесить v-model на чекбокс. В обычном списке это сработает. В виртуализированном — нет, и не сразу понятно почему.

Проблема в том, что строка в виртуализированном списке — не долгоживущая сущность. Она монтируется, когда попадает в видимую область, и размонтируется, когда её покидает. Локальное состояние внутри строки живёт ровно столько же — стоит прокрутить список туда и обратно, и чекбокс «забывает», что был отмечен. А если включён recyclePool, всё ещё хуже: DOM-узел переиспользуется под другие данные, и локальное состояние может утечь на чужую строку. Выбор нужно хранить снаружи, привязанным не к DOM-узлу и не к позиции в списке, а к идентичности элемента — обычно к id.

Именно так и устроен useRowSelection. Это не часть VirtualTable или VirtualList, а отдельный composable, который ничего не знает про DOM и подписывается только на массив данных:

ts Copy
const selection = useRowSelection<Order>({ items: orders })

selection.isSelected(order, index)    // boolean, реактивно
selection.toggle(order, index, event) // клик / Shift+клик
selection.selectedItems               // ComputedRef<Order[]>
selection.selectAll()
selection.clearSelection()

Логику toggle подглядел в Gmail и любом файловом менеджере, потому что именно её пользователи считают «нормальным» поведением: обычный клик переключает конкретную строку, а Shift+клик заполняет диапазон от последней переключённой строки до текущей — причём добавляет этот диапазон к уже выбранному, а не заменяет его целиком. Якорь диапазона — индекс последнего клика, а не индекс первого выбранного элемента, и это важно: если якорить от первого элемента, повторный Shift+клик в другую сторону будет вести себя контринтуитивно.

Чтобы toggle можно было дотянуться до конкретной ячейки таблицы, пришлось расширить существующий API — #cell и #pinned-cell в VirtualTable теперь дополнительно прокидывают index:

vue Copy
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualTable, useRowSelection } from 'vue-virtual-scroller-kit'
import type { ColumnDef } from 'vue-virtual-scroller-kit'

interface Order { id: number; client: string; amount: number }

const orders = ref<Order[]>(await fetchOrders())
const selection = useRowSelection<Order>({ items: orders })

const columns: ColumnDef[] = [
  { key: '__select', title: '', width: 36, fixed: 'left' },
  { key: 'client', title: 'Клиент', width: 200 },
  { key: 'amount', title: 'Сумма', width: 120 },
]
</script>

<template>
  <VirtualTable :columns="columns" :rows="orders" key-field="id" style="height: 600px">
    <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 Order] }}</span>
    </template>
  </VirtualTable>
</template>

Обратите внимание на @click, а не @change на чекбоксе — именно click-событие несёт shiftKey, которое toggle читает для диапазона. С @change Shift-выбор просто не будет работать.

Для сценариев, где выбор должен быть одиночным (радиокнопки, «выбрать одну строку для редактирования») — multiple: false. Тогда toggle не добавляет к выбору, а заменяет его целиком, и Shift-диапазон теряет смысл автоматически.

Composable дата-агностичен: он одинаково работает с VirtualTable, с обычным VirtualList (там index в #default-слоте был всегда) и вообще с любым списком, для которого можно передать items как реактивный массив — таблица тут вообще не обязательна.


VirtualSelect: когда фильтрация на клиенте перестаёт работать

До этой версии VirtualSelect умел искать только локально — по полю labelField внутри уже загруженного options. Для справочника на пару сотен записей это идеально: моментально, без сети, без лишнего кода. Но стоит вырасти до десятков тысяч записей — сотрудники крупной компании, каталог интернет-магазина, база клиентов — и подход разваливается сразу по двум фронтам: тащить весь массив в браузер целиком дорого по трафику и памяти, а сам массив быстро устаревает, если данные меняются на сервере.

Три новых пропса переносят поиск на сервер, не трогая при этом ничего из существующего поведения:

  • remote — выключает клиентскую фильтрацию, options рендерится как есть, что бы в нём ни лежало;
  • debounce-ms — задержка перед тем, как событие search реально уйдёт наружу (по умолчанию 0 — старое синхронное поведение никуда не делось, ничего не сломается у тех, кто просто обновит зависимость);
  • is-loading — показывает слот #loading вместо списка опций, причём проверяется он раньше, чем #empty, чтобы во время запроса на долю секунды не мигало «Ничего не найдено» прежде, чем придут реальные результаты.
vue Copy
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualSelect } from 'vue-virtual-scroller-kit'

interface Employee { id: string; name: string; department: string }

const selected = ref<Employee | null>(null)
const results = ref<Employee[]>([])
const isLoading = ref(false)
let requestId = 0

async function onSearch(query: string) {
  const id = ++requestId
  isLoading.value = true
  try {
    const res = await api.searchEmployees(query)
    if (id !== requestId) return // ответ на устаревший запрос — игнорируем
    results.value = res
  } finally {
    if (id === requestId) isLoading.value = false
  }
}
</script>

<template>
  <VirtualSelect
    v-model="selected"
    :options="results"
    remote
    :debounce-ms="300"
    :is-loading="isLoading"
    label-field="name"
    value-field="id"
    placeholder="Начните вводить имя..."
    style="width: 320px"
    @search="onSearch"
  >
    <template #default="{ option }">
      {{ option.name }} <span class="dept">{{ option.department }}</span>
    </template>
    <template #loading>Ищу...</template>
  </VirtualSelect>
</template>

Паттерн с requestId — не часть библиотеки, а обычная защита от гонки запросов, но без неё асинхронный поиск почти гарантированно рано или поздно сломается: пользователь печатает быстрее, чем отвечает сервер, и если не игнорировать устаревшие ответы, в поле в какой-то момент подставится результат не для того запроса, что был введён последним. Три строки кода экономят потом час отладки «select иногда показывает не то».

Сам VirtualSelect при этом остаётся виртуализированным списком опций — даже если сервер вернёт тысячу совпадений разом, в DOM всё так же попадут только видимые строки, а клавиатурная навигация (↑↓, Enter, Escape) продолжает работать без изменений.


VirtualTable: полный контроль над колонками

Широкие таблицы — вечная проблема админок и CRM: у заявки может быть двадцать полей, но конкретному пользователю в моменте нужны от силы шесть, и не факт, что в том порядке, в котором их придумал дизайнер. Менять ширину колонок (resizableColumns) vue-virtual-scroller-kit умел и раньше — в этом релизе таблица научилась ещё двум вещам: перетаскивать колонки местами и прятать ненужные целиком.

Drag-to-reorder

reorderableColumns включает перетаскивание за весь заголовок колонки, а не за узкую полоску, как это часто бывает в таблицах на CSS Grid. Важная деталь — эта логика полностью независима от resizableColumns: у обеих есть общая зона — угол заголовка, — но захват именно ручки для изменения ширины никогда не запускает перетаскивание, и наоборот. Это тот случай, где легко получить конфликт двух жестов на одном элементе, если не разделить их явно с самого начала.

vue Copy
<script setup lang="ts">
function onColumnReorder(order: string[]) {
  localStorage.setItem('table-column-order', JSON.stringify(order))
}
</script>

<template>
  <VirtualTable
    :columns="columns"
    :rows="rows"
    reorderable-columns
    style="height: 500px"
    @column-reorder="onColumnReorder"
  />
</template>

Порядок, как и ширина при resize, хранится внутри компонента и не переписывает исходный пропс columns — слушать column-reorder нужно самому, если порядок должен переживать перезагрузку страницы.

Скрытие колонок

Реализовано ровно по той же схеме, что и порядок с шириной — состояние видимости живёт внутри компонента как отдельный оверлей поверх пропса columns, а не мутирует переданный массив:

ts Copy
tableRef.value?.toggleColumnVisible('email')
tableRef.value?.setColumnVisible('email', false)
tableRef.value?.getHiddenColumns() // ['email']

Скрытая колонка не просто визуально прячется — она полностью выпадает из расчётов: из рендера ячеек, из горизонтальной виртуализации колонок (virtualizeColumns) и из вычисления смещения fixed-колонок. То есть если скрыть колонку слева от закреплённой — закреплённая корректно «подъедет» на освободившееся место, а не оставит пустую дыру.

vue Copy
<template>
  <label v-for="col in rawColumns" :key="col.key" class="col-toggle">
    <input type="checkbox" checked @change="tableRef?.toggleColumnVisible(col.key)" />
    {{ col.title }}
  </label>

  <VirtualTable
    ref="tableRef"
    :columns="columns"
    :rows="rows"
    style="height: 500px"
    @column-visibility-change="saveColumnPrefs"
  />
</template>

Событие column-visibility-change даёт ту же зацепку, что и column-reorder выше, — сохранить выбор пользователя в localStorage, чтобы при следующем визите таблица открылась уже в привычной конфигурации. Вместе column-resize, column-reorder и column-visibility-change закрывают весь набор для персонализации таблицы: пользователь может подвинуть, изменить ширину и скрыть колонки, а приложению остаётся один раз сохранить три JSON-объекта и применить их при следующей загрузке.


VirtualList: горизонтальный режим

Почти вся виртуализация по умолчанию мыслится как вертикальная — список сверху вниз, скролл по scrollTop. Но горизонтальная прокрутка в интерфейсах встречается ничуть не реже: карусели сторис, лента похожих товаров, ряды в Kanban-доске, таймлайны. До этой версии сделать такое с vue-virtual-scroller-kit можно было только через горизонтальную виртуализацию колонок VirtualTable — рабочий, но искусственный обходной путь для того, что по сути является простым списком.

Хорошая новость в том, что почти вся внутренняя механика — PositionManager, useVirtualScroll — с самого начала была не завязана на конкретную ось, просто нигде эта абстракция раньше не была вытащена наружу. Добавить horizontal оказалось не переписыванием движка, а его раскрытием: он читает scrollLeft/clientWidth вместо scrollTop/clientHeight, и всё остальное — измерение размеров, компенсация якоря при доизмерении, scrollTo — работает по тем же формулам, что и раньше.

vue Copy
<VirtualList
  horizontal
  :items="stories"
  :estimated-item-size="140"
  style="height: 220px"
>
  <template #default="{ item }">
    <StoryCard :story="item" style="width: 140px; height: 100%" />
  </template>
</VirtualList>

В горизонтальном режиме estimated-item-size — это уже оценка ширины карточки, а не высоты строки, но пропс остался тем же самым: не пришлось вводить отдельный estimated-item-width. Ряд позиционируется через inset-inline-start вместо top — логическое CSS-свойство само зеркалится в RTL-раскладке, без единой строчки JS-условий на направление. RTL здесь поддержан честно: под капотом та же нормализация scrollLeft, что используется в VirtualScrollbar и в горизонтальной виртуализации колонок таблицы — все три места делят один и тот же код, а не изобретают направление заново каждый на свой лад. Для кастомного скроллбара под горизонтальным списком естественно смотрится <VirtualScrollbar orientation="horizontal">.

Есть нюанс, который легко упустить и который стоит перечислить явно: horizontal, как и pageMode, считывается один раз при монтировании компонента — это не реактивный пропс в привычном смысле. Если в интерфейсе есть переключатель «вертикально / горизонтально» и ожидается, что список сам перестроится при клике, ничего не произойдёт — composable внутри просто не пересоздаёт свою логику на лету. Компонент нужно принудительно пересоздать через :key:

vue Copy
<VirtualList :key="layout" :horizontal="layout === 'horizontal'" ... />

Решение сознательное, а не недосмотр: переключение оси скролла в рантайме — редкий кейс (по сути, только демо-стенды и настройки для разработчика), а держать внутри компонента реактивный watcher ради него означало бы усложнять и без того горячий путь пересчёта видимого диапазона для всех.


useVisibilityTracker — самая нетривиальная фича релиза

Из всего списка это единственная фича, которая появилась не потому, что «не хватало очевидной кнопки», а потому, что задача сама по себе оказалась сложнее, чем казалось на старте.

Формулировка простая: нужно знать момент, когда конкретный элемент списка появляется на экране, и момент, когда он с экрана уходит — например, чтобы подсвечивать пункт бокового меню, пока соответствующий блок виден пользователю, или чтобы запускать воспроизведение видео только когда оно реально в кадре, а не где-то за пределами вьюпорта.

На обычной странице это тривиально — навесить IntersectionObserver на нужный DOM-узел, и он сам сообщит о пересечении. В виртуализированном списке всплывают сразу две проблемы, из-за которых наивное решение не работает:

  1. Строки не живут постоянно. Строка монтируется, когда попадает в видимую область (плюс буфер overscan), и размонтируется, когда её покидает. Значит, недостаточно один раз навесить наблюдатель — его нужно переустанавливать при каждом новом монтировании того же самого элемента, иначе через пару прокруток вы будете наблюдать за DOM-узлами, которых уже нет.
  2. visibleRange — это не то же самое, что «видно на экране». Диапазон видимости в виртуализированных списках всегда чуть шире реального вьюпорта — за счёт того самого overscan, который рендерит на пару строк больше с каждой стороны для плавности скролла. Если ориентироваться на индексы из visibleRange, строка будет считаться «видимой», хотя физически может быть на несколько рядов выше или ниже экрана.

Городить эвристику поверх индексов и overscan-буфера можно, но это путь к тонким багам, которые вылезают только на конкретных сочетаниях overscan и высоты строк. useVisibilityTracker решает обе проблемы напрямую, через настоящий IntersectionObserver, а не приближение через математику:

ts Copy
const tracker = useVisibilityTracker({
  root: () => listRef.value?.getScrollElement() ?? null,
  rootMargin: '0px',
  threshold: 0,
})

tracker.observe(el, key)   // начать отслеживать элемент под произвольным ключом
tracker.unobserve(key)     // остановить отслеживание
tracker.isVisible(key)     // boolean, реактивно
tracker.visibleKeys        // Readonly<Ref<Set<key>>> — все видимые ключи разом

Ключом может быть что угодно — id строки, индекс, произвольная строка. Composable не хранит данные списка и не привязан к конкретному компоненту: он знает только про соответствие «ключ → DOM-элемент → пересекает ли он root прямо сейчас». Именно поэтому его можно подключить и к VirtualList, и к VirtualTable, и даже к обычной, невиртуализированной странице — root можно не указывать вообще, тогда пересечение считается относительно вьюпорта браузера, и получается классический scrollspy без всякой виртуализации.

Отслеживание элемента нужно переустанавливать вручную на монтировании и размонтировании строки — ровно так же, как сам VirtualList внутри себя переустанавливает ResizeObserver для измерения высоты:

vue Copy
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualList, useVisibilityTracker } from 'vue-virtual-scroller-kit'
import type { VirtualListExpose } from 'vue-virtual-scroller-kit'

interface Section { id: number; title: string }

const sections = ref<Section[]>(await loadReport())
const listRef = ref<VirtualListExpose | null>(null)
const rowEls = new Map<number, Element>()

const tracker = useVisibilityTracker({
  root: () => listRef.value?.getScrollElement() ?? null,
})

function onRowMount(el: Element, id: number) {
  rowEls.set(id, el)
  tracker.observe(el, id)
}
function onRowUnmount(id: number) {
  rowEls.delete(id)
  tracker.unobserve(id)
}
</script>

<template>
  <VirtualList ref="listRef" :items="sections" key-field="id" :estimated-item-size="64" style="height: 600px">
    <template #default="{ item }">
      <div
        :ref="(el) => el && onRowMount(el as Element, item.id)"
        @vue:unmounted="onRowUnmount(item.id)"
      >
        <SectionBlock :section="item" />
      </div>
    </template>
  </VirtualList>
</template>

Две настройки стоит держать в голове при подключении. threshold — это доля элемента, которая должна попасть в кадр, чтобы он считался видимым: 0 (по умолчанию) значит «виден хотя бы один пиксель», 0.5 — «виден хотя бы наполовину», что обычно ближе к тому, что имеет в виду человек, говоря «эта секция сейчас на экране». А rootMargin расширяет или сужает эффективные границы root — с его помощью можно, например, считать элемент видимым чуть раньше, чем он физически пересечёт край вьюпорта, чтобы подсветка в навигации не запаздывала.

Отдельно стоит того, что root необязательно должен быть готов сразу. Если ссылка на скролл-контейнер резолвится позже, чем отработал setup() самого composable (частая ситуация, когда список — соседний компонент), useVisibilityTracker подождёт несколько кадров и подключится, как только элемент появится. А если список целиком пересоздаётся — например, через :key при переключении horizontal, о котором шла речь выше, — наблюдатель автоматически пересобирается на новом корневом элементе, без ручной синхронизации с обеих сторон.


VirtualScrollbar — свой скроллбар вместо системного

Отдельный повод для этого релиза — единственный по-настоящему новый компонент в пакете, а не расширение существующих.

Системный скроллбар — вечная головная боль в тёмных и кастомных интерфейсах: ::-webkit-scrollbar худо-бедно стилизуется в Chrome и Safari, но в Firefox из всего набора доступны только scrollbar-width и scrollbar-color — пары грубых настроек, а не полноценная тема. Получить одинаково выглядящий, тонкий, тематизируемый скроллбар кроссбраузерно средствами CSS просто не выйдет.

VirtualScrollbar — это трек и ползунок, целиком нарисованные компонентом и управляемые pointer-событиями, а не браузерный скроллбар со скином. При этом он умышленно не завязан на useVirtualScroll напрямую — вместо этого принимает функцию target, возвращающую произвольный HTMLElement, и синхронизируется с ним через обычные scroll/ResizeObserver-слушатели. Это значит, что он одинаково хорошо работает и с любым компонентом пакета через getScrollElement(), и с абсолютно любым скроллящимся div, который вообще не имеет отношения к виртуализации:

vue Copy
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualList, VirtualScrollbar } from 'vue-virtual-scroller-kit'
import type { VirtualListExpose } from 'vue-virtual-scroller-kit'

const listRef = ref<VirtualListExpose | null>(null)
const items = Array.from({ length: 10_000 }, (_, i) => ({ id: i, text: `Row ${i + 1}` }))
</script>

<template>
  <div style="position: relative; display: flex; height: 500px">
    <VirtualList
      ref="listRef"
      :items="items"
      :estimated-item-size="48"
      class="vvsk-scrollbar-hidden"
      style="flex: 1"
    >
      <template #default="{ item }">
        <div style="padding: 12px 16px; border-bottom: 1px solid #eee">{{ item.text }}</div>
      </template>
    </VirtualList>

    <VirtualScrollbar :target="() => listRef?.getScrollElement() ?? null" />
  </div>
</template>
css Copy
.vvsk-scrollbar-hidden {
  scrollbar-width: none; /* Firefox */
}
.vvsk-scrollbar-hidden::-webkit-scrollbar {
  display: none; /* Chrome, Safari, Edge */
}

Тема задаётся четырьмя CSS-переменными (--vvsk-scrollbar-size, --vvsk-scrollbar-track, --vvsk-scrollbar-thumb, --vvsk-scrollbar-thumb-hover) — без переопределения классов и без !important. orientation="horizontal" разворачивает тот же компонент под горизонтальный список или таблицу, а minThumbSize не даёт ползунку сжаться до нечитаемой полоски, когда в списке сто тысяч строк, а видно из них пятнадцать — без этого порога ширина ползунка была бы пропорциональна доле видимого контента и на таких масштабах превращалась бы в один-два пикселя, за которые физически невозможно зацепиться курсором.


GroupedVirtualList: закреплённые заголовки групп

В сгруппированном списке — например, контакты по алфавиту или задачи по приоритету — легко потерять контекст: проскроллил вниз на пару экранов, и уже не помнишь, к какой группе относится строка перед глазами. Обычный CSS-рецепт для этого — position: sticky на заголовке группы. Здесь он не работает в принципе: виртуализированные строки позиционируются через position: absolute с явным top/left, а sticky по спецификации залипает относительно ближайшего скроллящегося предка с обычным потоком документа — для абсолютно спозиционированных элементов эта механика попросту не применяется.

stickyGroupHeaders решает это в обход, но с тем же визуальным результатом: включённый флаг рисует поверх списка отдельный оверлей, который переиспользует тот же самый слот #group-header, но каждый раз подставляет в него ту группу, что сейчас находится в начале видимого диапазона.

vue Copy
<GroupedVirtualList
  :groups="groups"
  sticky-group-headers
  :estimated-item-size="56"
  style="height: 600px"
>
  <template #group-header="{ group, toggle, isCollapsed }">
    <div class="group-header" @click="toggle">
      {{ group.label }} <span class="count">{{ group.items.length }}</span>
    </div>
  </template>
</GroupedVirtualList>

Честно говоря о компромиссе: переключение между группами происходит мгновенно, без анимации «выталкивания» одного заголовка другим, которую можно увидеть в некоторых нативных реализациях sticky-заголовков. Для оверлея поверх абсолютно спозиционированных строк это осознанное упрощение — синхронизировать плавный переезд заголовка с независимым скроллом списка добавило бы сложности несоразмерно эффекту.


VirtualGrid: динамическая высота строк

Сетка карточек с одинаковой высотой ячеек — это просто до тех пор, пока в карточках не появляется текст переменной длины: подпись под фото в одну строку у одних товаров и в три — у других. С фиксированной rowHeight длинный текст либо обрезается, либо вылезает за пределы ячейки и наезжает на соседний ряд.

dynamicRowHeight измеряет фактическую высоту каждого ряда через ResizeObserver, а не берёт её из пропса как константу. Важный нюанс — измеряется именно ряд целиком, а не отдельная ячейка: высотой ряда становится максимум среди высот всех ячеек в нём, потому что иначе сетка перестанет быть сеткой — соседние по горизонтали карточки должны заканчиваться на одной линии, а не на разных.

vue Copy
<VirtualGrid
  :items="photos"
  :column-width="220"
  :row-height="220"
  dynamic-row-height
  :gap="12"
  style="height: 600px"
>
  <template #default="{ item }">
    <div class="photo-card" style="height: auto">
      <img :src="item.url" :alt="item.title" />
      <p>{{ item.title }}</p>
      <p v-if="item.caption" class="photo-card__caption">{{ item.caption }}</p>
    </div>
  </template>
</VirtualGrid>

Пропс row-height при включённом dynamic-row-height не исчезает — он становится начальной оценкой для ещё не отрендеренных строк, той же ролью, что estimated-item-size играет в списках. А в самой карточке высоту обязательно нужно оставить auto (или не задавать вовсе) — если жёстко зафиксировать её в CSS, измерение через ResizeObserver просто вернёт ту же константу, что и раньше, и вся фича превратится в no-op.


Плавный скролл, компенсация скачков и motion blur — как список ощущается на глаз

Три вещи про то, как виртуализированный контент выглядит и ощущается в движении, а не про то, что он умеет по API. Первые две тесно связаны технически, поэтому логично идут вместе.

Компенсация скачков при доизмерении

У виртуализации с переменной высотой строк есть известная болячка: пока строка не отрендерилась, её высота — только оценка (estimated-item-size). Если оценка была занижена, а пользователь в этот момент проматывает список вверх, строки выше текущей видимой области могут доизмериться и вырасти уже после того, как пользователь их проскроллил — и тогда весь контент под ними скачком сдвигается вниз или вверх на разницу, прямо под курсором.

Теперь движок отслеживает это явно: если после доизмерения строки, находящейся выше текущего видимого диапазона, её реальная высота отличается от оценки, scrollTop (или scrollLeft в горизонтальном режиме — с той же нормализацией, что и везде) молча подправляется на эту разницу в том же кадре. Пользователь не видит скачка, потому что для него ничего не сдвинулось относительно контента, на который он смотрит — сдвинулась только цифра прокрутки, компенсирующая то, что произошло выше экрана. Это внутренняя механика без отдельного пропса — работает всегда и везде, где есть измерение переменной высоты.

behavior: 'smooth'

scrollTo и scrollToOffset теперь принимают { behavior: 'smooth' } во всех компонентах — под капотом это нативная браузерная анимация скролла, та же самая, что стоит за CSS scroll-behavior: smooth, просто доступная программно и с колбэком выравнивания (start/center/end/auto). По умолчанию поведение осталось прежним, 'auto' — мгновенный прыжок, так что ни один существующий вызов scrollTo ничего не потерял.

ts Copy
listRef.value?.scrollTo(index, 'center', { behavior: 'smooth' })

Вот здесь компенсация скачков сверху и смогла бы навредить сама себе. Пока идёт нативная smooth-анимация, каждый прямой scrollTop-write со стороны компенсации — это конфликт двух источников, которые одновременно двигают одно и то же значение: браузер плавно едет к цели, а компенсация в это же время пытается тихо подправить позицию под доизмеренную строку. На практике это либо обрывает анимацию, либо превращается в заметный рывок прямо посреди неё. Поэтому компенсация на секунду отключается после любого behavior: 'smooth'-вызова — анимации дают спокойно доехать до цели, ничего её не перебивает.

motionBlur

Пропс motionBlur доступен почти везде — в VirtualList, GroupedVirtualList, VirtualTable, VirtualGrid, VirtualSelect, InfiniteLoader, VirtualTree. Идея — не функциональная, а чисто про ощущение: при быстрой прокрутке к контенту применяется CSS-блюр, масштаб которого зависит от текущей скорости скролла, и который плавно сходит на нет спустя ~150 мс после того, как скролл остановился.

vue Copy
<VirtualList :items="rows" motion-blur :estimated-item-size="48" style="height: 500px">
  <template #default="{ item }">
    <div class="row">{{ item.name }}</div>
  </template>
</VirtualList>

Формула внутри простая: скорость в пикселях на миллисекунду делится на порог чувствительности и линейно превращается в радиус блюра, не превышающий заданный максимум (по умолчанию 6px при скорости от ~3000 px/s и выше). Похожий эффект давно используют нативные списки iOS и Android — он не столько украшает, сколько маскирует то, что при очень быстрой прокрутке строки успевают только частично отрендериться и измениться между кадрами; лёгкое размытие делает это визуально незаметным вместо того, чтобы бороться с этим на уровне рендера. Пропс по умолчанию выключен и не стоит ничего, пока не включён явно — ни лишних слушателей, ни вычислений.


RTL-поддержка

Отдельного rtl-пропса в пакете нет нигде, и это осознанное решение, а не недоделанная фича — RTL работает просто оттого, что весь пакет физически не умеет иначе.

Все компоненты позиционируют контент через логические CSS-свойства — inset-inline-start/inset-inline-end, padding-inline-start, margin-inline-start — вместо физических left/right. Отступ VirtualTree, закреплённые колонки и ручка resize в VirtualTable, раскладка ячеек VirtualGrid, горизонтальный ползунок VirtualScrollbar — всё это зеркалится браузером автоматически, в тот момент, когда он резолвит direction: rtl где-то выше по дереву. Ни одной JS-ветки на направление во всём этом коде нет и не требуется:

vue Copy
<VirtualTable :columns="columns" :rows="rows" dir="rtl" style="height: 500px" />

Единственное место, которое честно нельзя решить одним CSS — это element.scrollLeft. У него в RTL-режиме в разных браузерах исторически было по-разному: где-то отрицательные значения от нуля к началу, где-то положительные, отсчитываемые не от той стороны. Современная спецификация фиксирует это как «0 у начального края, отрицательные значения по мере скролла к концу», но полагаться на голое чтение scrollLeft всё равно рискованно — весь код, которому в принципе есть дело до горизонтальной позиции (виртуализация колонок таблицы, горизонтальный VirtualScrollbar, горизонтальный режим VirtualList), читает и пишет её только через normalizeScrollLeft/setNormalizedScrollLeft, которые приводят это значение к «расстоянию от начала» независимо от направления. Обе функции экспортируются из пакета — ими можно пользоваться и в собственном коде, если понадобится своя горизонтальная механика:

ts Copy
import { normalizeScrollLeft, setNormalizedScrollLeft } from 'vue-virtual-scroller-kit'

const distanceFromStart = normalizeScrollLeft(el) // одинаково работает в LTR и RTL
setNormalizedScrollLeft(el, distanceFromStart + 100)

Из-за этого горизонтальный режим VirtualList, о котором шла речь выше, получил корректную RTL-поддержку фактически бесплатно — нормализация уже была написана и проверена на таблице и скроллбаре в рамках этого же обновления, оставалось только переиспользовать её на новом месте, а не изобретать заново.


Замеры производительности

Ядро — сегментное дерево PositionManager — не менялось, оно и раньше давало O(log n) на обновление высоты и поиск смещения вместо линейного перебора. Новое здесь — не сама логика, а то, что теперь это не голословное заявление в README. В src/__bench__/ появились бенчмарки на встроенном vitest bench, и цифры оттуда — настоящие, а не приблизительные:

Операция (100 000 элементов) Среднее время
findIndex ~0.0002 мс
set (изменение высоты) ~0.0002 мс
getOffset ~0.0002 мс

Ключевое здесь — не абсолютные цифры, а то, что они практически не растут при увеличении списка с 1 000 до 100 000 элементов. Именно это и обещает O(log n): рост в сто раз по количеству строк даёт рост на единицы процентов по времени запроса, а не в сто раз. Проверить самостоятельно можно командой npm run bench — числа получатся свои, зависящие от железа, но характер роста должен остаться тем же.


Что изменилось по сравнению с прошлой версией

  • #cell и #pinned-cell в VirtualTable теперь дополнительно передают index — единственное изменение существующей сигнатуры во всём релизе, и оно строго аддитивное: код, который не заглядывает в index, продолжает работать без единой правки.
  • Компенсация скачков при доизмерении строк выше видимой области — это исправление поведения, а не новый пропс: раньше доизмерение могло дёрнуть контент под курсором, теперь scrollTop/scrollLeft тихо подстраивается, и заметить разницу можно только по тому, что скачков больше нет.
  • Breaking changes отсутствуют, несмотря на объём релиза. Все новые пропсы — remote, debounce-ms, is-loading, horizontal, reorderableColumns, stickyGroupHeaders, dynamicRowHeight, motionBlur и остальные — имеют дефолты, которые полностью сохраняют прежнее поведение. VirtualScrollbar — новый компонент, а не замена чему-то существующему, использовать его или нет — решение целиком на стороне проекта. Обновление версии не требует ничего менять в существующем коде.
  • RTL-поддержка покрывает весь пакет целиком, включая новые VirtualScrollbar и горизонтальный VirtualList — без отдельного флага и без разницы в поведении между компонентами.
  • Дев-тулинг под капотом подрос: CI больше не тестирует сборку на Node 18 — актуальные версии jsdom и stylelint, которые пакет использует для тестов и линта, сами отказались от поддержки Node 18 (он в статусе EOL с апреля 2025 года). На заявленную для потребителей пакета минимальную версию (engines: ">=18") это никак не влияет — рантайм-код пакета не использует ничего, что требовало бы Node 20 и выше, речь только о внутренней разработке.

Сценарии в реальных проектах

Админ-панель: таблица заявок с массовыми действиями

Классическая задача бэк-офиса: список заявок с чекбоксами для массовых операций, возможностью спрятать неважные для конкретного пользователя колонки и панелью действий, которая появляется, как только что-то выбрано.

vue Copy
<script setup lang="ts">
import { ref, computed } from 'vue'
import { VirtualTable, useRowSelection } from 'vue-virtual-scroller-kit'
import type { ColumnDef } from 'vue-virtual-scroller-kit'

interface Ticket { id: number; subject: string; client: string; priority: string; assignee: string }

const tickets = ref<Ticket[]>(await fetchTickets())
const selection = useRowSelection<Ticket>({ items: tickets })
const tableRef = ref()

const rawColumns: ColumnDef[] = [
  { key: 'subject',  title: 'Тема',      width: 260 },
  { key: 'client',   title: 'Клиент',    width: 160 },
  { key: 'priority', title: 'Приоритет', width: 100 },
  { key: 'assignee', title: 'Исполнитель', width: 160 },
]

const columns = computed<ColumnDef[]>(() => [
  { key: '__select', title: '', width: 36, fixed: 'left' },
  ...rawColumns,
])

async function bulkClose() {
  await api.closeTickets(selection.selectedItems.value.map((t) => t.id))
  selection.clearSelection()
}
</script>

<template>
  <div class="toolbar">
    <label v-for="col in rawColumns" :key="col.key">
      <input type="checkbox" checked @change="tableRef?.toggleColumnVisible(col.key)" />
      {{ col.title }}
    </label>
  </div>

  <VirtualTable ref="tableRef" :columns="columns" :rows="tickets" key-field="id" style="height: 70vh">
    <template #cell="{ column, row, index }">
      <input
        v-if="column.key === '__select'"
        type="checkbox"
        :checked="selection.isSelected(row, index)"
        @click="selection.toggle(row, index, $event)"
      />
      <PriorityBadge v-else-if="column.key === 'priority'" :priority="row.priority" />
      <span v-else>{{ row[column.key as keyof Ticket] }}</span>
    </template>
  </VirtualTable>

  <div v-if="selection.selectedItems.value.length" class="bulk-toolbar">
    Выбрано: {{ selection.selectedItems.value.length }}
    <button @click="bulkClose">Закрыть выбранные</button>
  </div>
</template>

Здесь три новые фичи работают вместе, но не мешают друг другу: чеклист колонок, чекбоксы выбора и панель массовых действий — три независимых куска состояния, которые просто оказались в одном экране. Shift+клик по чекбоксу закрывает сразу диапазон заявок — привычное поведение, которое не пришлось реализовывать вручную поверх индексов и событий мыши. А скрытые через чеклист колонки не трогают исходный rawColumns — состояние видимости живёт внутри tableRef, поэтому один и тот же список колонок можно спокойно переиспользовать где-то ещё, не боясь, что скрытая где-то колонка «утечёт» в другое место интерфейса.


Форма назначения: поиск сотрудника среди тысяч профилей

В корпоративном приложении выбор исполнителя из справочника на 20 000+ сотрудников — ровно тот случай, где клиентская фильтрация VirtualSelect из прошлой версии перестаёт быть хорошей идеей: тащить весь справочник компании в браузер на каждое открытие формы — и долго, и не нужно, если в 99% случаев пользователь ищет одного конкретного человека.

vue Copy
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualSelect } from 'vue-virtual-scroller-kit'

interface Employee { id: string; name: string; team: string; avatar: string }

const assignee = ref<Employee | null>(null)
const candidates = ref<Employee[]>([])
const searching = ref(false)
let requestId = 0

async function onSearch(query: string) {
  if (query.length < 2) { candidates.value = []; return }
  const id = ++requestId
  searching.value = true
  try {
    candidates.value = await api.searchEmployees(query, { limit: 30 })
  } finally {
    if (id === requestId) searching.value = false
  }
}
</script>

<template>
  <VirtualSelect
    v-model="assignee"
    :options="candidates"
    remote
    :debounce-ms="250"
    :is-loading="searching"
    label-field="name"
    value-field="id"
    clearable
    placeholder="Кому назначить задачу?"
    style="width: 360px"
    @search="onSearch"
  >
    <template #default="{ option }">
      <img :src="option.avatar" class="avatar" />
      {{ option.name }} <span class="team">{{ option.team }}</span>
    </template>
    <template #loading>Ищу сотрудников...</template>
    <template #empty>Никого не нашлось</template>
  </VirtualSelect>
</template>

Проверка query.length < 2 перед самим запросом — простой способ не дёргать бэкенд на каждую отдельную букву, пока пользователь только начал печатать имя, а debounce-ms="250" дополнительно сглаживает частоту ввода уже после этого порога. Вместе эти две простые проверки дают ощущение мгновенного, «умного» поиска, хотя реальных обращений к серверу — на порядок меньше, чем нажатий клавиш. Список кандидатов при этом всё так же виртуализирован: даже если API вернёт максимум из тридцати совпадений, в DOM в моменте всё равно попадут только те, что физически видны в открытом dropdown.


Длинный отчёт с навигацией по разделам

Документ или отчёт на сотни блоков — аналитика, комплаенс-документация, техническая спецификация — с боковым оглавлением, где активный пункт подсвечивается по мере того, как пользователь скроллит основной контент. Классический паттерн scrollspy, только на этот раз для контента, который целиком в DOM держать нельзя чисто по объёму.

vue Copy
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualList, useVisibilityTracker } from 'vue-virtual-scroller-kit'
import type { VirtualListExpose } from 'vue-virtual-scroller-kit'

interface Section { id: number; title: string; body: string }

const sections = ref<Section[]>(await loadReport())
const listRef = ref<VirtualListExpose | null>(null)
const rowEls = new Map<number, Element>()

const tracker = useVisibilityTracker({
  root: () => listRef.value?.getScrollElement() ?? null,
  rootMargin: '-40% 0px -40% 0px', // «активный» — тот, что ближе к центру viewport
})

function onRowMount(el: Element, id: number) {
  rowEls.set(id, el)
  tracker.observe(el, id)
}
function onRowUnmount(id: number) {
  rowEls.delete(id)
  tracker.unobserve(id)
}

function scrollToSection(id: number) {
  const index = sections.value.findIndex((s) => s.id === id)
  if (index >= 0) listRef.value?.scrollTo(index, 'start', { behavior: 'smooth' })
}
</script>

<template>
  <aside class="toc">
    <a
      v-for="s in sections"
      :key="s.id"
      :class="{ active: tracker.isVisible(s.id) }"
      @click="scrollToSection(s.id)"
    >
      {{ s.title }}
    </a>
  </aside>

  <VirtualList ref="listRef" :items="sections" key-field="id" :estimated-item-size="320" style="height: 100vh">
    <template #default="{ item }">
      <section
        :ref="(el) => el && onRowMount(el as Element, item.id)"
        @vue:unmounted="onRowUnmount(item.id)"
        class="report-section"
      >
        <h2>{{ item.title }}</h2>
        <div v-html="item.body" />
      </section>
    </template>
  </VirtualList>
</template>

Самое интересное здесь — отрицательный rootMargin сверху и снизу. Он «сжимает» эффективную зону, в которой элемент вообще может считаться пересекающимся, до узкой горизонтальной полосы вокруг центра экрана. Без этого приёма активным считался бы любой раздел, хотя бы частично попавший в кадр — а их там обычно два-три одновременно, верхний уходит, нижний только показался. С суженной зоной активным остаётся ровно тот раздел, что ближе всего к середине вьюпорта — визуально именно это пользователь и подразумевает, когда смотрит на подсвеченный пункт меню. Тот же приём отлично работает и без всякой виртуализации, на обычной длинной странице, но именно с виртуализированным списком он полезнее всего: без IntersectionObserver-подхода пришлось бы либо держать все разделы отчёта в DOM целиком ради простоты, либо изобретать собственную геометрию поверх visibleRange, натыкаясь на ту же проблему с буфером overscan, что обсуждалась выше.


NPM: https://www.npmjs.com/package/vue-virtual-scroller-kit
GitHub: https://github.com/macrulezru/vue-virtual-scroller-kit

Читать далее

API Сервисы: документация и тестирование эндпоинтов прямо в админ-панели

17.06.2026

Добавил в admin-панель модуль «API Сервисы» — каталог всех эндпоинтов проекта с возможностью сразу выполнить запрос и посмотреть ответ, без Postman и сторонних коллекций. Сами эндпоинты теперь редактируются через интерфейс, а не правкой кода.

Метки
APIadmin-панельVueдокументацияREST

Модуль «Приложения»: хостинг Vue и Nuxt прямо из git

04.06.2026

В Docker-платформе появился механизм размещения веб-приложений — сборка из git-репозитория, привязка домена и управление всем жизненным циклом из браузера. Для администрирования создан модуль «Приложения».

Метки
dockerdevopsvuenuxtself-hosted