Перейти к содержимому
Поиск
Разработка

TanStack Virtual во Vue: большие списки и таблицы — мастерская и 8 живых опытов

Восемь задач с настоящим Vue Virtual: большой каталог, динамические карточки, таблица, сетка, поиск и возврат к позиции. Мастерская, скачиваемый Vue-компонент и полный проект для Windows/Linux.

Александр Некрасов
TanStack Virtual — большие списки и таблицы во Vue

TanStack Virtual во Vue: большие списки, динамические карточки, таблица и сетка. Восемь живых опытов, мини-мастерская и исходники для Windows, Linux и VPS.

TanStack Virtual: большой набор данных в небольшом окне

В CRM накопилось десять тысяч клиентов. Разработчик получает массив и выводит для каждой записи карточку. Сначала всё выглядит просто, но браузеру приходится создавать тысячи элементов, рассчитывать их размеры, раскладывать текст и поддерживать обработчики. Если строки сложные, интерфейс начинает дольше открываться и тяжелее реагировать на изменения. Пользователь при этом видит лишь несколько клиентов в пределах экрана.

TanStack Virtual помогает показывать только нужный участок большого списка. Остальные элементы временно отсутствуют в DOM, а расчётная высота сохраняет привычную полосу прокрутки. Человек может перейти к далёкой записи, открыть карточку и вернуться к прежнему месту. Библиотека рассчитывает окно и координаты, а внешний вид и поведение продукта остаются под вашим управлением.

В этом материале восемь связанных опытов: каталог, запас строк, динамические карточки, переход по ID, фильтры, таблица, двухмерная сетка и клавиатурная работа с восстановлением позиции. На странице запускается настоящий Vue Virtual. Архив содержит полный проект, тесты и инструкции для Windows, Linux и статического размещения на VPS.

Где применять: кабинеты, каталоги, журналы и большие справочники

Первое применение — рабочий кабинет со множеством однотипных записей: клиенты, обращения, товары, оборудование или события. Пользователь знает, что ищет, меняет фильтр и прокручивает результат. Здесь особенно полезно сохранять контекст: выбранный объект, поиск, порядок и место просмотра после возвращения из подробной карточки.

Второе применение — интерфейс с дорогой строкой. Даже несколько сотен карточек могут содержать много текста, кнопок и вложенной разметки. Виртуализация позволяет не держать всё это одновременно. Но сначала стоит проверить сам компонент: тяжёлые вычисления на каждый рендер или огромные картинки не исчезнут только потому, что строк стало меньше.

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

Виртуализация, пагинация, Query и Table решают разные задачи

Виртуализация ограничивает число отрисованных элементов. Пагинация ограничивает порцию данных, которую человек получает или просматривает. Эти подходы можно сочетать. Если API возвращает слишком большой ответ, уменьшение DOM само по себе не ускорит передачу, разбор JSON и хранение массива в памяти браузера.

TanStack Query во Vue организует асинхронные данные, кеш, ошибки и обновления. Virtual работает с отображением доступного набора. TanStack Table решает задачи табличной модели: столбцы, сортировка и другие правила. Наличие общего имени TanStack не превращает эти инструменты в одну обязательную установку.

Если нужна готовая насыщенная таблица, сравните задачу с AG Grid Community. Если вы создаёте собственный необычный интерфейс и хотите сами определять разметку, headless-подход Virtual даёт больше свободы. Взамен вам нужно самостоятельно продумать семантику, фокус, состояния и поведение на узком экране.

// Данные и их отображение — разные уровни.
const rows = response.items; // порцию возвращает ваш API
const options = {
  count: rows.length,
  getScrollElement: () => viewport.value,
  estimateSize: () => 96,
  getItemKey: index => rows[index].id
};

Как устроено окно: контейнер, общий размер и координаты строк

Представьте прокручиваемый контейнер известной высоты. Библиотека знает количество элементов, оценку их размеров и текущее смещение прокрутки. Из этого она определяет видимый диапазон и дополнительный запас. Для каждого нужного элемента возвращаются индекс, устойчивый ключ, начало и размер.

Внутри контейнера размещается область общей расчётной высоты. Существующие строки позиционируются на своих координатах. Поэтому удаление тысяч невидимых элементов не означает исчезновение длинной полосы прокрутки. При движении окна одни компоненты появляются, другие размонтируются. Это жизненный цикл интерфейса, который влияет и на локальное состояние строки.

Для фиксированных размеров геометрия проста и предсказуема. Для динамического текста первоначальное положение основано на оценке, а после измерения уточняется. Поэтому обещать абсолютно неизменный пиксельный результат до измерения всех далёких карточек нельзя. Поля и методы описаны в официальном API Virtualizer; ниже мы проверим их на конкретных действиях.

const items = computed(() => virtual.value.getVirtualItems());
const total = computed(() => virtual.value.getTotalSize());
// item.index — индекс в текущем наборе
// item.key — постоянный ключ записи
// item.start — координата начала; item.size — известный/оценённый размер

Мини-мастерская: восемь опытов на настоящем Vue Virtual

В мастерской создаются десять тысяч синтетических клиентов с городом, статусом и описанием. Данные находятся в памяти вкладки. Здесь нет скрытого запроса к серверу сайта и нет имитации алгоритма: окно, измерения и переходы обслуживает установленный @tanstack/vue-virtual. Переключение сценария меняет задачу и представление, а поиск и сортировка доступны непосредственно рядом с результатом.

Начните с обычного каталога и перейдите к клиенту 7850. Затем сравните два списка по двести одинаковых записей, увеличьте overscan, раскройте карточки и откройте сетку. Счётчики используют реальные DOM-узлы. Выбранная или сфокусированная строка может дополнительно удерживаться вне видимого диапазона — это объяснено прямо под показателями.

Завершите опыт клавиатурой: поставьте фокус в каталог, нажмите End, затем Enter. Откроется карточка последнего клиента. Напишите заметку и вернитесь: список создаётся заново, позиция восстанавливается, заметка остаётся в модели по ID. На телефоне удобнее открыть мастерскую отдельной страницей; встроенная версия тоже доступна ниже.

Версии, файлы и отсутствие внешних CDN

В проекте закреплены @tanstack/vue-virtual 3.13.52, его virtual-core 3.19.11, Vue 3.5.45 и Bootstrap 5.3.8. Сборку выполняет esbuild 0.28.2. Зависимости устанавливаются из package-lock.json, а готовая папка public содержит локальные JavaScript и CSS. Случайное изменение CDN не должно менять поведение уже скачанного примера.

src/core.js отвечает за учебные данные, фильтры, преобразование ID в индекс и экспорт. В src/components.js находятся реальные виртуализаторы списка, таблицы и сетки. src/app.js связывает сценарии, подробную карточку, заметки и действия пользователя. Такое разделение помогает переносить отдельный механизм, а не копировать всю демонстрационную страницу в приложение.

Vue-адаптер возвращает реактивную ссылку на экземпляр: в setup используется virtual.value. В шаблоне Vue автоматически раскрывает верхнеуровневые refs. Адаптер и его отличие от window-варианта описаны в Vue Virtual. В этой мастерской прокручивается собственный контейнер, поэтому используется useVirtualizer.

npm install @tanstack/vue-virtual@3.13.52 vue@3.5.45
# В готовом архиве зависимости уже перечислены и закреплены:
npm ci --ignore-scripts

Windows: запуск готового проекта и первый полезный опыт

Установите поддерживаемый Node.js 24 LTS не ниже 24.15, распакуйте архив и откройте PowerShell в каталоге tanstack-virtual-project. Команды ниже устанавливают зависимости, собирают мастерскую и запускают проверки. После npm start браузер открывает http://127.0.0.1:8400/. Сервер слушает только loopback и останавливается Ctrl+C в своём терминале.

Для первого опыта не нужно менять код. Найдите клиента 7850, откройте карточку, вернитесь и поменяйте порядок на обратный. Повторный переход по тому же номеру приведёт к той же записи, хотя её индекс стал другим. Это практическая проверка того, что бизнес-идентификатор отделён от позиции массива.

Если порт занят, укажите свободный PORT перед запуском и удалите переменную после остановки. Не закрывайте неизвестный процесс только ради примера. Готовую public можно обслужить и своим локальным HTTP-сервером; исходный src с bare imports требует сборщика. Архив включает уже собранную версию для быстрого знакомства.

node --version
npm.cmd ci --ignore-scripts
npm.cmd run build
npm.cmd test
npm.cmd start
# Если8400 занят:
$env:PORT = '8401'
npm.cmd start
# После Ctrl+C:
Remove-Item Env:PORT

Linux и VPS: та же сборка, отдельная роль веб-сервера

На Linux проект использует тот же lock-файл. После установки подходящего Node.js выполните npm ci, build и test обычным пользователем. Для локального просмотра запустите npm start. Если вы работаете на VPS, SSH-туннель позволяет открыть loopback-сервер в браузере своего компьютера, не публикуя учебный порт наружу.

Для постоянного размещения достаточно статической папки public. Node нужен для сборки и удобного локального стенда, но готовому интерфейсу не требуется постоянно работающий Node-процесс. Caddy, Nginx или существующий PHP-сайт могут отдавать эту статику. Реальный API, если вы его подключите, остаётся отдельной системой со своими правами и ограничениями.

Не переносите в публичный document root весь рабочий каталог вместе с node_modules и служебными файлами. Сначала проверьте готовые ресурсы, затем подключите маршрут под доменом. Ниже есть отдельный раздел с конфигурацией. Эта разница между сборкой и размещением полезна, когда пример нужно применить на обычном Linux-хостинге, а не только запустить на рабочем Windows.

node --version
npm ci --ignore-scripts
npm run build
npm test
npm start
# На рабочем компьютере, если стенд запущен на VPS:
ssh -L 8400:127.0.0.1:8400 user@your-server

Опыт 1. Фиксированная строка: размер должен совпадать с CSS

В основном каталоге каждая строка занимает 96 пикселей вместе с внутренними отступами и нижней границей. Такое решение делает расчёт точным: библиотека знает размер ещё до появления элемента. Текст ограничен одной строкой в каждом поле, поэтому длинное название не должно неожиданно растянуть блок.

Главная практическая ошибка — задать estimateSize 96, а реальному элементу позволить стать выше из-за переносов, padding или border. На коротком списке расхождение может быть незаметно, но после тысяч строк накопится большая ошибка положения. Для фиксированного режима синхронизируйте CSS и оценку; для действительно меняющейся высоты используйте измерение.

Вынесите размер в понятную константу или общий договор между компонентом и стилем. При изменении дизайна проверяйте его в браузере с фактическим шрифтом и шириной. В скачиваемом маленьком SFC используется другой согласованный размер — 72 пикселя и в настройке, и в разметке. Это отдельный минимальный пример, а не противоречие каталогу.

const viewport = ref(null);
const virtual = useVirtualizer({
  count: rows.length,
  getScrollElement: () => viewport.value,
  estimateSize: () => 96,
  getItemKey: index => rows[index].id,
  overscan: 5
});

Разметка окна: общая высота и отдельные видимые элементы

Контейнер должен иметь ограниченную высоту и overflow:auto. Иначе браузер разложит весь внутренний размер в обычный документ и ожидаемого собственного окна не получится. Внутренняя область получает getTotalSize, а строки занимают абсолютные позиции, соответствующие item.start.

Не вычисляйте координату как индекс видимой строки, умноженный на высоту. Номер в массиве getVirtualItems начинается заново для каждого окна, тогда как координата относится ко всему набору. Используйте данные виртуализатора. Аналогично key должен описывать запись, а не случайное место элемента в текущем видимом массиве.

Пример ниже показывает только геометрическую часть шаблона. Полный компонент с импортами и setup скачивается из мастерской. Семантика и управление фокусом в основном каталоге добавлены отдельно: минимальная демонстрация позиционирования сама по себе не является законченным доступным виджетом выбора клиента.

<div ref="viewport" style="height:420px;overflow:auto">
  <div :style="{height:total + 'px',position:'relative'}">
    <div v-for="item in items" :key="item.key"
      :style="{position:'absolute',top:0,left:0,width:'100%',
        height:'96px',boxSizing:'border-box',
        transform:'translateY(' + item.start + 'px)'}">
      {{ rows[item.index].name }}
    </div>
  </div>
</div>

Опыт 2. Overscan и честное сравнение числа DOM-строк

Overscan добавляет элементы до и после видимой области. Небольшой запас помогает подготовить ближайшие строки к появлению. Увеличение запаса одновременно увеличивает работу рендера и количество узлов. Поэтому максимальное значение не является автоматическим улучшением; подходящий вариант зависит от сложности карточки, скорости прокрутки и устройства.

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

Эта мастерская не измеряет FPS и не заявляет ускорение в определённое количество раз. Меньше DOM — наблюдаемый факт, но итоговая отзывчивость зависит от всей страницы. Если вы хотите сравнивать время, измеряйте одинаковый сценарий на одном устройстве, учитывайте прогрев, расширения браузера и стоимость обработчиков, а результаты подписывайте как измерение конкретного окружения.

const options = computed(() => ({
  count: rows.value.length,
  getScrollElement: () => viewport.value,
  estimateSize: () => 96,
  getItemKey: index => rows.value[index].id,
  overscan: overscan.value // в мастерской0–20
}));

Опыт 3. Карточки переменной высоты: оценить, затем измерить

Описание клиента может занимать одну строку или несколько абзацев. После раскрытия деталей размер меняется ещё раз. Здесь фиксированная формула неудобна: библиотеке нужна первоначальная оценка, затем фактическое измерение DOM. В опыте dynamic карточки действительно имеют разную высоту и раскрываются по кнопке.

Корневой элемент карточки передаётся в measureElement, а data-index связывает измерение с индексом текущего набора. ResizeObserver сообщает об изменении размеров после раскрытия и изменения ширины. В мастерской первоначальная оценка равна 260 пикселям; это стартовое предположение, а не обещание, что любая карточка имеет такую высоту.

Прокрутите список после раскрытия нескольких элементов и убедитесь, что соседние карточки не перекрываются. Затем измените ширину окна. Перенос текста меняет геометрию даже без изменения данных. Официальный пример dynamic во Vue показывает тот же класс задачи; наш опыт дополняет его действиями клиента и восстановлением контекста.

const virtual = useVirtualizer(computed(() => ({
  count: rows.value.length,
  getScrollElement: () => viewport.value,
  estimateSize: () => 260,
  getItemKey: index => rows.value[index].id,
  overscan: 5
})));
function measure(element) {
  virtual.value.measureElement(element);
}
// В корневом элементе карточки:
// :ref="measure" :data-index="item.index"

Устойчивые ключи и состояние: индекс не является клиентом

После сортировки клиент с ID 7850 может оказаться на другой позиции. Если состояние и измерение привязаны только к индексу, прежний размер или черновик легко связать с другой записью. getItemKey должен возвращать постоянный идентификатор сущности, а Vue key — использовать соответствующий ключ виртуального элемента.

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

Сам по себе хороший key не определяет бизнес-политику при обновлении набора. Нужно решить, сохранять ли выбранного клиента, сбрасывать ли позицию и что делать, если объект исчез. Для фильтрации в мастерской выбран понятный договор: начинается новый просмотр с первой строки, а заметки остаются у своих клиентов до сброса или перезагрузки.

const rows = ref([{id: 17, name: 'Атлас'}, {id: 42, name: 'Контур'}]);
const keyForIndex = computed(() => {
  const currentRows = rows.value;
  return index => currentRows[index].id;
});
// В options: getItemKey: keyForIndex.value
// Черновик хранится независимо от видимого окна:
const drafts = reactive({});
drafts[42] = 'Уточнить состав отчёта';

Опыт 4. Переход к клиенту: сначала ID, потом индекс

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

Введите 7850, перейдите, измените порядок на обратный и повторите. Клиент останется тем же, но индекс изменится. Если текущий фильтр скрывает запись, мастерская сообщает об этом вместо молчаливого перехода к соседней строке. Дробные, отрицательные и отсутствующие номера также получают понятную ошибку.

Для динамической высоты далёкий переход сначала опирается на оценки и уточняется по мере измерения. Здесь используется behavior:auto. Плавную анимацию к ещё неизвестному размеру нельзя без проверки обещать одинаково точной во всех ситуациях. Для операционного интерфейса предсказуемое попадание к нужному клиенту полезнее эффектного, но нестабильного перемещения.

const index = visibleRows.value.findIndex(row => row.id === requestedId);
if (index < 0) {
  message.value = 'Клиент скрыт фильтром или отсутствует';
} else {
  virtual.value.scrollToIndex(index, {align: 'auto', behavior: 'auto'});
}

Опыт 5. Поиск, фильтры и сортировка меняют набор до виртуализации

В мастерской поиск учитывает имя, город и ID, фильтр — статус, а сортировка — номер или город. Сначала получается актуальный массив, затем его длина и ключи передаются виртуализатору. Нельзя оставить count от полного каталога, а для вывода использовать укороченный результат: рано или поздно индекс укажет на несуществующий объект.

Когда состав меняется, просмотр возвращается вверх. Иначе человек, находившийся у конца десяти тысяч записей, может оказаться за пределами короткой выборки. Одновременно очищается ошибка старого поиска. Это небольшие решения вокруг библиотеки, которые делают готовый инструмент понятным, а не просто технически демонстрируют прокрутку.

Локальная фильтрация десяти тысяч учебных записей выполняется в браузере. Для рабочего каталога с большим объёмом и правами доступа часть условий часто должна обрабатываться сервером. Виртуализация не уменьшает стоимость фильтрации массива сама по себе. Выбирайте размер загружаемой порции и место вычислений по реальным данным приложения.

const filtered = computed(() => source.value.filter(row =>
  status.value === 'all' || row.status === status.value
));
watch([search, status, sort], async () => {
  await nextTick();
  virtual.value.scrollToOffset(0);
});

Пустой результат и неверный ввод — полноценные состояния

У большого списка есть не только счастливый путь. Введите бессмысленную строку поиска: результат должен стать пустым, счётчик DOM — нулевым, а интерфейс — объяснить, как вернуться к данным. Пустая выборка не является сетевой ошибкой и не требует бесконечного индикатора загрузки.

Переход к клиенту отключён, когда искать не в чем. Если номер неверный, сообщение находится рядом с формой и не перекрывает весь экран. В сетке отдельно проверяются обе координаты: строки от 1 до 1000, колонки от 1 до 24. Нельзя молча округлять дробное значение и делать вид, что человек запросил другую ячейку.

Сброс опыта возвращает фильтры, порядок, overscan и заметки к исходному состоянию. Для реального продукта подумайте, какие данные допустимо сбрасывать одной кнопкой: учебный черновик и несохранённая рабочая форма имеют разные последствия. Здесь механизм ограничен памятью вкладки и прямо описан посетителю.

function validateCell(row, column) {
  if (!Number.isInteger(row) || row < 1 || row > 1000 ||
      !Number.isInteger(column) || column < 1 || column > 24) {
    throw new Error('Строка:1–1000, колонка:1–24');
  }
  return {row: row - 1, column: column - 1};
}

Опыт 6. Таблица: семантика, шапка и пустые промежутки

Табличные данные удобно оставлять таблицей: заголовки столбцов, строки, связь ячейки с названием. В шестом опыте используются настоящие table, thead, tbody и th. Шапка закреплена внутри прокручиваемого контейнера, а на узком экране горизонтальная прокрутка не растягивает всю страницу.

Для сохранения обычной табличной раскладки пропущенные участки представлены пустыми строками с заданной высотой и aria-hidden. Это отличается от абсолютно позиционированных карточек. Высота обычной строки равна 64 пикселям, шапки — 44. paddingStart и scrollPaddingStart согласованы с шапкой, чтобы переход не прятал нужную запись под ней.

Сфокусированная строка может удерживаться далеко от текущего окна. Поэтому код рассчитывает промежутки между всеми существующими строками, а не только один верхний и нижний отступ. Иначе дополнительная удержанная строка нарушила бы геометрию таблицы. Такой нюанс важно учитывать при объединении виртуализации и клавиатурных действий.

const options = {
  count: rows.length,
  getScrollElement: () => viewport.value,
  estimateSize: () => 64,
  paddingStart: 44,
  scrollPaddingStart: 44,
  getItemKey: index => rows[index].id
};
// Реальная CSS-высота шапки44px, строки64px.
// Пустые tr между диапазонами скрыты от accessibility tree.

Опыт 7. Двухмерная сетка: виртуализировать обе оси

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

Оба экземпляра получают один scrollElement, но у горизонтального включён horizontal. Высота строки и ширина колонки заданы отдельно. Переход к координате выполняет два scrollToIndex. Введите строку 750 и колонку 18: окно переместится по обеим осям, а заголовок останется доступным сверху.

Это сетка для чтения, а не готовый Excel. Она не реализует редактирование ячеек, формулы, выделение диапазонов и полный набор клавиатурных правил сложной таблицы. Если продукту нужны эти возможности, оцените готовую grid-библиотеку либо заложите отдельную работу над поведением и доступностью. Виртуализация решает только геометрическую часть.

const vertical = useVirtualizer({
  count: 1000, getScrollElement: () => viewport.value,
  estimateSize: () => 60, overscan: 3, paddingStart: 40
});
const horizontal = useVirtualizer({
  count: 24, getScrollElement: () => viewport.value,
  estimateSize: () => 160, overscan: 1, horizontal: true
});
vertical.value.scrollToIndex(749, {align: 'start'});
horizontal.value.scrollToIndex(17, {align: 'start'});

Опыт 8. Клавиатура: DOM-фокус должен оставаться устойчивым

Если фокус находится на элементе, который размонтировался при прокрутке, пользователь может потерять своё место. В основном каталоге выбран другой подход: реальный фокус остаётся на стабильном контейнере listbox, а aria-activedescendant указывает на выбранную option. Стрелки меняют выбор, Home и End переходят к границам, Enter открывает карточку.

Выбранная option дополнительно удерживается в DOM через rangeExtractor. Поэтому ссылка aria-activedescendant не указывает на отсутствующий элемент даже после прокрутки мышью. Счётчик честно включает такую строку. Состояние выбора относится к ID клиента, а индекс используется только для навигации внутри текущего массива.

Для карточек с настоящими кнопками и таблицы применяется удержание сфокусированной строки до выхода фокуса. Это не заменяет проверку конкретного screen reader и браузера. В проекте есть автоматические проверки поведения клавиатуры, но полное покрытие вспомогательных технологий не заявляется. Для рабочего сервиса проверяйте целевой путь вместе с выбранной семантикой.

const rangeExtractor = range => {
  const indexes = defaultRangeExtractor(range);
  if (activeIndex.value >= 0 && !indexes.includes(activeIndex.value)) {
    indexes.push(activeIndex.value);
  }
  return indexes.sort((a, b) => a - b);
};
// activeIndex должен быть валиден для текущего count.
// aria-activedescendant контейнера ссылается на ID выбранной option.

Карточка клиента и заметка: состояние живёт вне строки

Нажмите Enter или кнопку открытия карточки. Список временно размонтируется, а на его месте появляется подробный экран. Поле заметки принадлежит модели приложения и индексируется постоянным ID. После возврата и дальнейшей прокрутки заметка не исчезает вместе с виртуальной строкой.

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

Учебная заметка не отправляется серверу и исчезает после перезагрузки страницы. При переносе в CRM добавьте явное сохранение, сообщения об ошибке, обработку конфликта и правила ухода с несохранённым вводом. Нельзя выдавать наличие текста в браузерной памяти за подтверждённое сохранение в рабочем приложении.

const notesById = reactive({});
function setNote(clientId, text) {
  notesById[clientId] = text;
}
// Строка может размонтироваться; модель остаётся в родителе.
// На сервер данные отправляет отдельная операция сохранения.

Восстановление позиции: offset, выбранный ID и измерения

При открытии карточки мастерская сохраняет смещение прокрутки, выбранного клиента, снимок измерений и раскрытые карточки. После возврата новый экземпляр Virtualizer получает initialOffset и initialMeasurementsCache. Это помогает продолжить просмотр без возвращения к началу длинного списка.

Сохранённый offset имеет смысл только в контексте того же набора и геометрии. Если изменились фильтр, сортировка, ширина или содержание, прежнее число пикселей может указывать на другое место. Поэтому мастерская сбрасывает снимок при изменении условий поиска, а для динамического списка после смены ширины измеряет заново и переходит к выбранному ID.

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

const saved = {
  offset: viewport.value.scrollTop,
  selectedId: selectedId.value,
  measurements: virtual.value.takeSnapshot()
};
// При новом создании useVirtualizer:
const restoreOptions = {
  initialOffset: saved.offset,
  initialMeasurementsCache: saved.measurements
};

Изменение ширины, шрифта и раскрытия: что требует измерения

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

Не добавляйте внешние margin, о которых виртуализатор ничего не знает, и не смешивайте несколько независимых способов изменения размера без ясного договора. В нашем примере разделитель входит в border-box карточки. Для отдельного расстояния между элементами можно использовать предусмотренный gap, чтобы геометрия была известна расчёту.

После существенного изменения конфигурации может потребоваться measure для сброса сохранённых размеров. Но вызывать его при каждом кадре прокрутки бессмысленно: это уничтожит пользу кеша измерений. Сначала определите конкретную причину устаревания, затем обновляйте нужную геометрию. Проверяйте результат на настоящем CSS-layout, потому что тестовая DOM-среда его не рассчитывает.

// Например, после изменения режима оформления, влияющего на размеры:
await nextTick();
virtual.value.measure();
// Затем при необходимости вернуть выбранную запись:
virtual.value.scrollToIndex(selectedIndex.value, {align: 'start'});

Подключение к API: не потерять пользу виртуализации на загрузке

В рабочем каталоге данные приходят с сервера. Важно разделить ожидание ответа, ошибку, пустой результат и виртуальное отображение успешного набора. Если запрос ещё не завершён, count не должен выдавать произвольное число реальных клиентов. Если вы показываете placeholder-строку загрузки, обозначьте её отдельным типом и обработайте отдельно.

С Query можно получить кешированную страницу или несколько загруженных страниц, а Virtual использовать для отображения полученного массива. При смене условий ключ API-запроса и виртуальный набор должны относиться к одному фильтру. Старый ответ не должен тихо подменять новый поиск. Подробнее эта часть разобрана в нашей мастерской TanStack Query.

Загрузка следующей порции и бесконечная прокрутка требуют отдельной политики: когда запрашивать, как избежать повторных запросов, как показать отказ и как остановиться в конце. Наша мастерская сознательно использует полностью локальный набор и не изображает серверную пагинацию. В официальных примерах есть вариант infinite scroll, который можно изучить как следующий шаг.

// Схема интеграции: query возвращает настоящие данные API.
const rows = computed(() => query.data.value?.items ?? []);
const options = computed(() => ({
  count: rows.value.length,
  getScrollElement: () => viewport.value,
  estimateSize: () => 96,
  getItemKey: index => rows.value[index].id
}));
// Loading/error/empty показываются отдельно от виртуальных строк.

Поиск браузера, печать и экспорт полного результата

Браузерный Ctrl+F ищет в существующем документе. Запись, которая сейчас отсутствует в DOM, не становится доступной для такого поиска только потому, что полоса прокрутки длинная. По той же причине обычная печать и копирование страницы не гарантируют полный отчёт. Это важное продуктовое ограничение, о котором нужно знать заранее.

В мастерской есть собственный поиск по всему учебному массиву и CSV всей текущей выборки. Экспорт строится из данных, а не из видимых элементов. Поэтому после перехода к клиенту 7850 файл всё равно содержит весь результат фильтра, а не только несколько строк вокруг него.

Для рабочего сервиса полезен отдельный печатный отчёт или доступный режим с пагинацией. Не стройте SEO-каталог товаров исключительно на виртуальном окне и не обещайте поисковому роботу содержание, которое вы не отдаёте в подходящем виде. Страница статьи остаётся обычным текстом; виртуализация применяется только внутри учебного инструмента.

const exportedRows = filteredRows.value;
// Правильно: CSV строится из модели всех нужных записей.
// Неправильно: читать только .querySelectorAll('[data-virtual-row]').
const ids = exportedRows.map(row => row.id);

Производительность: что измерять кроме числа элементов

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

Используйте Performance и профилировщик Vue на повторяемом действии: открыть набор, прокрутить, сменить фильтр, раскрыть карточку. Сравнивайте одинаковые данные и одинаковую сборку. Разработка с дополнительными проверками, включённые расширения и случайная фоновая нагрузка делают сравнение шумным. Записывайте устройство, браузер и условия, когда публикуете численные результаты.

Слишком большой overscan увеличивает работу, слишком маленький может сделать быстрый скролл менее приятным. Огромная расчётная высота упирается в ограничения прокручиваемой геометрии браузера. Для действительно массивных наборов сочетайте ограничения загрузки, серверные фильтры, разумную пагинацию и виртуализацию. Не подменяйте архитектурное решение одним параметром библиотеки.

Скачиваемые исходники: маленький компонент и полный проект

Кнопка Vue-компонента выгружает полноценный VirtualClients.vue: script setup, настоящий useVirtualizer, десять тысяч простых записей и разметка фиксированной высоты. Overscan берётся из выбранного значения. Этот файл предназначен для проекта Vue с поддержкой SFC и установленным адаптером, а не для открытия двойным щелчком как HTML.

Полный ZIP содержит все представления, управление карточкой, заметки, CSV, snapshot и тесты. JSON-настройки помогают зафиксировать условия собственного опыта: сценарий, фильтр, порядок, запас и текущую позицию. Импорт произвольного JSON в приложение не реализован; выгрузка не является обещанием автоматического восстановления любой внешней конфигурации.

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

// В существующем Vue SFC-проекте:
import VirtualClients from './components/VirtualClients.vue';
// Используйте <VirtualClients /> в шаблоне родителя.
// Установите закреплённую совместимую зависимость:
// npm install @tanstack/vue-virtual@3.13.52

Размещение за Caddy или Nginx: готовая статика под HTTPS

После сборки разместите public в отдельном каталоге, например /opt/virtual-demo/public. Веб-серверу достаточно чтения файлов. Настройте домен, HTTPS и корректные MIME-типы, особенно для JavaScript-модуля. В проекте публичные модули имеют расширение .js; исходные инструменты сборки остаются вне веб-каталога.

В deploy находятся примеры Caddyfile и Nginx location. Они требуют подстановки вашего домена и каталога и не устанавливают сервер автоматически. Подробные базовые действия есть в материалах о Caddy и Nginx. После изменения сначала выполните проверку конфигурации, затем проверяйте уже размещённые HTML, JS и CSS.

При встраивании в существующий сайт сохраняйте относительные пути ресурсов. Ссылка полного архива ожидает ZIP на уровень выше public. Если размещаете проект иначе, измените этот маршрут осмысленно. При обновлении используйте отдельный release или обратимое переключение каталога, чтобы можно было вернуться к предыдущей рабочей сборке.

# Пример Caddyfile: замените домен и путь
virtual-demo.example.test {
    root * /opt/virtual-demo/public
    encode zstd gzip
    file_server
}
# Проверка своей конфигурации:
# caddy validate --config /etc/caddy/Caddyfile

Проверки: библиотека, DOM-поведение и настоящий браузер

Автоматические проверки проекта используют настоящий Virtualizer: диапазон, общий размер, overscan, изменение измерений, устойчивые ключи и snapshot. Отдельно проверяются ID, фильтры, пустой набор, координаты сетки и полный CSV. Скачиваемый SFC проходит компиляцию настоящим компилятором Vue.

DOM-тесты запускают собранный Vue-интерфейс, переходят к дальней записи, открывают карточку, возвращают позицию и сохраняют заметку. Они также проверяют раскрытие, сравнение списков, обе оси сетки и глобальные номера строк таблицы. Геометрия в JSDOM задаётся явно: эта среда не рассчитывает настоящий CSS-layout и не заменяет проверку браузером.

После изменения стилей обязательно проверьте реальные высоты, шапку, быстрый скролл, смену ширины и клавиатуру. На узком экране горизонтальная прокрутка должна оставаться внутри широкой таблицы или сетки, а кнопки и объяснения — оставаться доступными. Успешный тест алгоритма не доказывает удобство фактического мобильного интерфейса.

npm test
npm run build
npm audit --audit-level=low
# После размещения своей сборки:
curl -I https://your-domain.example/app.js

Как внедрить в свой интерфейс без потери смысла

Выберите один длинный список и сначала определите его контракт: откуда данные, какова реальная высота строки, что считается идентичностью записи и какие действия нужны человеку. Если строки фиксированы, начните с простого режима. Динамическое измерение добавляйте тогда, когда оно требуется содержимому, а не ради усложнения примера.

Следующим шагом проверьте завершённый пользовательский путь: найти клиента, перейти, открыть, изменить черновик и вернуться. Затем добавьте пустой результат, ошибку ввода, изменение ширины и управление клавиатурой. Именно эти переходы превращают демонстрацию небольшого DOM в полезный рабочий интерфейс.

Мастерская даёт восемь воспроизводимых опытов и исходники, которые можно разобрать по частям. Сохраняйте честные границы: Virtual управляет отображением, API отвечает за данные и права, модель приложения — за черновики, а отдельные проверки — за доступность и реальную отзывчивость. Тогда библиотека помогает решить конкретную задачу пользователя, а не просто добавляет ещё одну зависимость.