Большая таблица на Vue с 5-10 тысячами строк в v‑for рендерится в лоб, и браузер начинает тормозить: скролл дёргается, фильтр применяется с задержкой в секунду-две, а вкладка Performance в DevTools показывает несколько тысяч DOM-узлов в одной ветке дерева. Я сталкивался с этим на дашборде для интернет-магазина, где таблица заказов подтягивала статусы оплаты из Т‑Банка и трек-номера СДЭК и за пиковый месяц разрасталась до 15 000 строк. Дальше разберу два рабочих подхода: виртуальный скролл, который держит в DOM только видимые строки, и серверную пагинацию, которая вообще не отдаёт клиенту лишние данные, а в конце покажу, как их совмещать и на что реально смотреть при выборе.
Почему большая таблица на Vue тормозит без оптимизации
Vue 3 оборачивает каждый элемент реактивного массива в прокси, а v‑for создаёт по узлу VNode на каждую строку. Для таблицы на 5000 строк и 10 колонок это 50 000 связанных биндингов, каждый из которых движок отслеживает для реактивности. Пока данные не меняются, первый рендер проходит терпимо, но любое обновление, задевающее массив целиком, например сортировка или смена фильтра, заставляет Vue пересчитать diff по всему списку сразу.
На одном проекте рендер 6000 строк без виртуализации занимал у клиента больше 4 секунд на слабом ноутбуке, а каждый символ в поле поиска запускал пересчёт computed по всему массиву и подвешивал вкладку на полсекунды. Отдельная частая причина тормозов, не связанная с объёмом данных напрямую: v‑for с :key, привязанным к index, вместо id строки. При сортировке или удалении элемента Vue не может сопоставить старые и новые VNode по смыслу и пересоздаёт DOM заново вместо точечного патча.
Проверить, где именно теряется время, проще всего через вкладку Performance в Chrome DevTools. Если основная нагрузка приходится на Recalculate Style и Layout, дело в количестве DOM-узлов, и здесь помогает виртуальный скролл. Если тормозит Scripting, скорее всего где-то есть лишний watch с deep: true на большом массиве или тяжёлый computed без мемоизации, и виртуализация тут не поможет, а поможет пересмотр логики фильтрации.
Виртуальный скролл для большой таблицы на Vue
Идея простая: в DOM держим не весь массив строк, а только те, что попадают в видимую область плюс небольшой буфер сверху и снизу. При скролле компонент не создаёт новые DOM-узлы на каждую строку, а переиспользует уже существующие и просто подставляет в них новые данные, сдвигая блок через transform: translateY. Итоговое количество DOM-узлов в таблице на 20 000 строк остаётся тем же, что и на 50, обычно 20-40 штук с учётом буфера.
vue-virtual-scroller и компонент RecycleScroller
Быстрее всего подключить готовый vue-virtual-scroller с компонентом RecycleScroller. Ему нужен уникальный ключ (key-field) и размер строки, причём item-size принимает три варианта: фиксированное число, функцию вида (item, index) => number или null, и тогда скроллер переходит в режим переменного размера и берёт высоту из поля каждого элемента, имя которого задаётся через sizeField (по умолчанию size). Настройка занимает 15-20 минут.
npm install vue-virtual-scroller
<template>
<RecycleScroller
class="orders-scroller"
:items="orders"
:item-size="48"
key-field="id"
v-slot="{ item }"
>
<div class="row">
<span>{{ item.id }}</span>
<span>{{ item.customer }}</span>
<span>{{ item.status }}</span>
<span>{{ item.total }} ₽</span>
</div>
</RecycleScroller>
</template>
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { ref, onMounted } from 'vue'
const orders = ref([])
onMounted(async () => {
const res = await fetch('/api/orders?per_page=1000')
const json = await res.json()
orders.value = json.data
})
</script>
Ограничение мягче, чем кажется: одинаковая высота строк обязательна только до тех пор, пока не включён режим переменного размера. Если в ячейке текст переносится на две-три строки в зависимости от контента, при фиксированном item-size высота поедет и строки начнут перекрываться. Выходов три: обрезать текст через CSS с многоточием, отдать item-size как null и положить высоту каждой строки в поле size, чтобы скроллер работал в режиме переменного размера, или взять DynamicScroller из того же пакета, который измеряет высоту сам.
TanStack Virtual как более гибкий вариант
@tanstack/vue-virtual даёт низкоуровневый composable useVirtualizer вместо готового компонента. Разметку строк и колонок собираешь сам, зато проще сделать закреплённые (sticky) колонки, вложенные группы строк или таблицу с горизонтальной виртуализацией по колонкам, что в RecycleScroller руками не сделать без переписывания части компонента.
Бесплатный материал
🎁 Полезный скрипт в подарок
Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.
Без спама. Отписка в 1 клик.
Серверная пагинация вместо загрузки всех строк разом
Виртуальный скролл решает проблему рендера, но не решает проблему сети и памяти браузера: если бэкенд отдаёт разом 50 000 строк JSON-ом, страница будет грузиться долго ещё до того, как Vue успеет что-то отрисовать. Здесь помогает пагинация на уровне запроса: фронт запрашивает не весь массив, а конкретную страницу по page и per_page.
Для таблиц, в которые часто добавляются новые строки, например живой список заказов, offset-пагинация иногда сдвигает данные между запросами и одна и та же строка может показаться дважды или пропасть со страницы. В таких случаях удобнее курсорная пагинация, где следующая страница запрашивается по id или дате последней полученной строки, а не по номеру.
import { ref, watch } from 'vue'
import axios from 'axios'
export function usePagedOrders() {
const orders = ref([])
const page = ref(1)
const perPage = 50
const total = ref(0)
const search = ref('')
const loading = ref(false)
async function load() {
loading.value = true
const { data } = await axios.get('/api/orders', {
params: { page: page.value, per_page: perPage, q: search.value },
})
orders.value = data.data
total.value = data.total
loading.value = false
}
let timer
watch(search, () => {
clearTimeout(timer)
timer = setTimeout(() => {
page.value = 1
load()
}, 300)
})
watch(page, load)
load()
return { orders, page, perPage, total, search, loading }
}
Задержка в 300 миллисекунд на watch(search) нужна, чтобы не дёргать бэкенд на каждый введённый символ. Без неё поиск по таблице из 15 000 строк превращается в очередь из десятка обгоняющих друг друга запросов, и результат на экране иногда прыгает между старым и новым.
Гибридный подход: виртуальный скролл поверх серверной пагинации
Для админок и дашбордов чаще всего нужна не постраничная навигация с кнопками, а бесконечная подгрузка: пользователь скроллит таблицу, а компонент незаметно догружает следующую порцию строк с сервера и добавляет их в тот же виртуализированный список.
<template>
<RecycleScroller
ref="scroller"
class="orders-scroller"
:items="orders"
:item-size="48"
key-field="id"
v-slot="{ item }"
>
<div class="row">{{ item.customer }}</div>
</RecycleScroller>
</template>
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import { ref, onMounted } from 'vue'
import axios from 'axios'
const scroller = ref(null)
const orders = ref([])
const page = ref(1)
const loadingMore = ref(false)
async function loadPage() {
const { data } = await axios.get('/api/orders', {
params: { page: page.value, per_page: 100 },
})
orders.value.push(...data.data)
}
function onScroll() {
const el = scroller.value.$el
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300
if (nearBottom && !loadingMore.value) {
loadingMore.value = true
page.value += 1
loadPage().finally(() => {
loadingMore.value = false
})
}
}
onMounted(() => {
scroller.value.$el.addEventListener('scroll', onScroll)
loadPage()
})
</script>
Важный момент: слушатель scroll вешается на внутренний $el компонента RecycleScroller, а не на window, потому что скроллится именно внутренний контейнер таблицы. И не забывай снимать обработчик в onUnmounted, иначе при частом переключении вкладок с такой таблицей накопится утечка слушателей.
Виртуальный скролл или серверная пагинация: как выбрать
На практике редко выбираю что-то одно в чистом виде, но для понимания разницы держу в голове такую таблицу.
| Критерий | Виртуальный скролл | Серверная пагинация |
|---|---|---|
| DOM-узлов в таблице | Постоянное число (20-40 строк) | Равно размеру одной страницы |
| Нагрузка на сеть | Один запрос на весь набор данных | Запрос на каждую страницу или подгрузку |
| Сортировка и фильтры | Применяются к массиву на клиенте | Требуют поддержки на бэкенде |
| Верхняя граница объёма данных | Ограничена памятью браузера | Практически не ограничена |
| Сложность интеграции с API | Минимальная, один запрос | Нужен эндпоинт с page/per_page или курсором |
Если данные конечны и умещаются в память браузера, например справочник на 10-20 тысяч строк, который целиком грузится раз при открытии страницы, чистого виртуального скролла достаточно. Если таблица растёт без предсказуемого потолка, как история заказов за несколько лет, без серверной пагинации или курсора не обойтись, иначе рано или поздно один запрос за всеми строками положит и фронт, и бэкенд.
Частые ошибки при работе с большими таблицами на Vue
- Вешают watch с deep: true на весь массив строк вместо отслеживания отдельных полей или использования shallowRef, из-за чего Vue пересчитывает зависимости на каждое изменение любой вложенной ячейки.
- Используют index как :key в v‑for, из-за чего сортировка и удаление строк пересоздают DOM вместо переиспользования существующих узлов.
- Ставят фиксированный item-size в RecycleScroller, а потом добавляют в ячейку текст с переносом на несколько строк, из-за чего строки начинают наезжать друг на друга.
- Забывают снимать обработчик scroll в onUnmounted при бесконечной подгрузке, что приводит к утечке памяти при частой навигации между вкладками с такой таблицей.
- Держат в памяти клиента результат курсорной пагинации без ограничения сверху: список растёт при каждой подгрузке и через тысячи строк съедает столько же памяти, сколько съела бы таблица без пагинации вообще.
Если нужна не типовая таблица, а готовая админка с виртуализацией, серверной пагинацией, сортировкой и графиками поверх тех же данных, обычно быстрее заказать разработку дашборда на Vue.js, чем неделями дособирать это руками из отдельных компонентов. У меня такая работа начинается от 90 000 ₽ и включает проектирование API под конкретный объём данных, а не только фронтенд-часть.
Vue регулярно обновляется, поэтому перед внедрением я сверяюсь с актуальной документацией.
Частые вопросы
Сколько строк можно рендерить в Vue-таблице без виртуализации?
На практике простая v‑for таблица без тяжёлых computed в ячейках держится нормально до 500‑1000 строк. После этого порога скролл и любые обновления массива начинают заметно тормозить, особенно на слабых устройствах, и стоит подключать виртуализацию.
Виртуальный скролл ломает поиск по Ctrl+F на странице?
Да, потому что в DOM в любой момент есть только видимые строки плюс буфер, и браузерный поиск не находит текст в строках, которые ещё не отрендерены. Для таких случаев делаю отдельное поле поиска внутри таблицы, которое фильтрует данные до передачи в виртуализатор или отправляет запрос на бэкенд.
Можно ли совмещать виртуальный скролл с сортировкой и фильтрами по колонкам?
Можно. Сортировку и фильтрацию применяю к исходному массиву или к ответу с сервера до того, как передать данные в items компонента RecycleScroller. Сам виртуализатор ничего не знает о сортировке, он просто рисует то, что ему передали, в нужном порядке.
Что выбрать для админки: виртуальный скролл или пагинация с постраничной подгрузкой?
Для внутренних админок с объёмом до 5-10 тысяч строк обычно хватает виртуального скролла с фильтрацией на клиенте, это проще в разработке и не требует правок на бэкенде. Если объём данных растёт без предсказуемого потолка, например архив заказов за годы, надёжнее серверная пагинация или курсорная подгрузка, которая ограничивает и память браузера, и размер каждого запроса.