Таблица на 50 000 строк, которая не вешает браузер
«Хочу листать, как в Excel» - реальное требование заказчика на биллинге. Что пришлось сделать, чтобы вкладка не отъедала гигабайт.
На биллинге ТКО был реестр начислений на десятки тысяч строк. Требование заказчика звучало коротко: «хочу листать, как в Excel».
Первая версия была честной и наивной:
<tr v-for="row in rows" :key="row.id">
50 000 строк по 8 ячеек - это 400 тысяч DOM-узлов. Браузер старался: вкладка съедала гигабайт, скролл шёл рывками, а на ноутбуке тестировщика всё это просто умирало.
Тормозит не JS, а количество узлов
На каждый узел браузер тратит память, считает стили и раскладку. У Chrome-команды есть рекомендация держаться в районе полутора тысяч узлов на страницу - не как закон, а как порог, после которого начинается веселье.
Отсюда две вещи, которые надо развести:
- строк в DOM должно быть столько, сколько влезает в экран;
- данных в памяти - столько, сколько нужно прямо сейчас.
Это две разные задачи, и решаются они по-разному.
Виртуализация
Идея простая: контейнер фиксированной высоты, внутри распорка на полную высоту списка, а видимые строки позиционируются абсолютно. Пользователь видит скролл на 50 тысяч строк, в DOM живёт тридцать.
<script setup lang="ts">
const parentRef = ref<HTMLElement | null>(null);
const virtualizer = useVirtualizer({
count: rows.value.length,
getScrollElement: () => parentRef.value,
estimateSize: () => 40, // высота строки
overscan: 8, // запас за пределами экрана
});
</script>
<template>
<div ref="parentRef" class="table-viewport">
<div :style="{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }">
<div
v-for="v in virtualizer.getVirtualItems()"
:key="rows[v.index].id"
:style="{ position: 'absolute', top: 0, transform: `translateY(${v.start}px)`, height: `${v.size}px` }"
>
<TableRow :row="rows[v.index]" />
</div>
</div>
</div>
</template>
Скролл стал плавным сразу. Но есть побочка, о которой лучше узнать до демо, а не на демо: виртуализация ломает поиск по странице через Ctrl+F, печать и выделение всей таблицы мышкой. Мы поставили кнопку «Выгрузить в XLSX», и вопрос закрылся - оказалось, людям нужно было именно это.
Порции с сервера
Виртуализация чинит рендер, но не сеть. 50 тысяч строк JSON - это мегабайты, которые парсятся в главном потоке и потом висят в памяти.
Так что по умолчанию - пагинация, а «бесконечный скролл» - это просто подгрузка следующей страницы:
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['charges', filters],
queryFn: ({ pageParam }) => chargesApi.list({ ...filters.value, cursor: pageParam }),
getNextPageParam: (last) => last.nextCursor ?? undefined,
initialPageParam: null,
});
В стандартах у нас записано так: если экран может показать больше двухсот записей - он ходит на сервер порциями. Никаких «выгрузим всё и отфильтруем на клиенте».
Мелочи, которые дают больше, чем кажется
:key по id, а не по индексу. С индексом Vue переиспользует DOM неправильно: вставили строку в начало - перерисовался весь список, а значения инпутов переехали к соседям.
Вычисления - в computed. Выражение в шаблоне считается на каждый ререндер, computed кэшируется.
shallowRef для больших массивов. Vue делает объекты глубоко реактивными, то есть обходит все вложенные поля. Если массив на 50 тысяч объектов целиком заменяется после каждого запроса, глубокая реактивность - чистые потери:
const rows = shallowRef<Row[]>([]);
Форматтеры - один раз наверху модуля. new Intl.NumberFormat(...) внутри ячейки создаётся заново на каждый рендер каждой ячейки, а это далеко не бесплатно:
const money = new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB' });
Ну и content-visibility: auto на большие блоки вне экрана, если полноценную виртуализацию делать некогда.
Как доказать, что стало лучше
Не на глаз. Performance в DevTools, троттлинг процессора 4х, скроллим, смотрим длинные задачи и количество узлов. Цифры «до» и «после» - единственный способ объяснить заказчику, за что заплачено, и единственный способ самому не обмануться.
Порядок работ, если пришли с «таблица тормозит», всегда один: сначала уменьшить объём данных, потом количество узлов, и только потом микрооптимизации. В обратном порядке это неделя работы ради пяти процентов.