virtual-list виртуальный список —— Настройка
Виртуальный список — это «решение рендеринга для десятков тысяч записей»: отрисовываются лишь десятки элементов в области просмотра, а при прокрутке они динамически заменяются. Отрисовка 100 000 строк наглухо вешает браузер, а виртуальный список держит число DOM-узлов постоянным. Цена — высота должна быть предсказуема: в режиме фиксированной высоты она вычисляется напрямую, а в режиме динамической высоты — сначала измеряется. core/virtual-list инкапсулирует оба режима и разделяет с table единый алгоритм ядра из shared/virtual.
Когда использовать
Заголовок раздела «Когда использовать»- При реализации
<LhVirtualList>на каждой платформе: длинные списки сообщений, потоки логов, адресные книги, опции выпадающих списков с большими данными. - Имеет смысл, только когда объем данных превышает вместимость области просмотра в 5+ раз и отрисовка каждого элемента недешева.
- Не следует использовать: десятки или сотни записей (рендерить напрямую — расходы на виртуализацию не окупаются); высота элементов абсолютно непредсказуема и не измеряется (придётся принять фиксированную высоту или обеспечить возможность измерения); сценарии, требующие полного DOM (выделение всех, поиск Ctrl+F в браузере, печать целой страницы — содержимое вне виртуального списка отсутствует в DOM и не найдётся).
import { LH_VIRTUAL_ITEM_SIZE, LH_VIRTUAL_OVERSCAN, computeWindow, computeVirtualRange, buildVirtualItems, patchMeasuredSize, findVirtualRange, getItemOffset, getVirtualListClassNames,} from '@lh-ui/share/core/virtual-list'import type { LhVirtualItem, LhVirtualRange } from '@lh-ui/share/core/virtual-list'Замысел
Заголовок раздела «Замысел»Два режима: фиксированная высота (computeRange, диапазон за O(1)) и динамическая высота (buildVirtualItems + patchMeasuredSize + findVirtualRange, требуется измерение). Фиксированная высота рекомендована по умолчанию — просто и без мерцания. Динамическая применяется, когда высота содержимого действительно неравномерна (сообщения чата разной длины).
Общее ядро с table: комментарии в shared/virtual фиксируют историю — алгоритм фиксированной высоты когда-то существовал в двух экземплярах (virtual-list и table), причём даже лишняя прокрутка по умолчанию была разной (2 vs 3), и исправление в одном месте не синхронизировалось с другим. После выделения единого алгоритма оба места стали тонкими обёртками. LH_TABLE_OVERSCAN = 3 — кастомное значение со стороны table, а virtual-list использует значение по умолчанию LH_VIRTUAL_OVERSCAN = 2.
Очистка NaN защищает от белого экрана: комментарии в computeRange говорят прямо — NaN-овый scrollTop, попадая в Math.max / Math.floor, порождает NaN-овое окно, а array.slice(NaN) возвращает пустой массив → белый экран. Все входные параметры проходят очистку через Number.isFinite. findVirtualRange идёт дальше: items[NaN].offset выбросит TypeError и прервёт отрисовку.
first ограничивается total - 1: в том кадре, когда данные сжались (удалили строки), а scrollTop ещё не ограничен, first может превысить total, что приведёт к start > end → slice вернёт пустоту → белый экран. Это ограничение является исправлением гонки.
patchMeasuredSize меняет на месте: измерив реальную высоту, функция модифицирует исходный массив, сдвигая смещения всех последующих элементов, и возвращает дельту изменения размеров. В отличие от полного перестроения buildVirtualItems (новый массив за O(n) + дифф для всех элементов), она проходит по массиву один раз и не вызывает повторного рендеринга неизменившихся элементов. Комментарии фиксируют этот компромисс — неизменяемость здесь уступает место производительности.
Ускорение getItemOffset для фиксированной высоты: прокрутка к N-ному элементу в режиме фиксированной высоты делается напрямую как N * itemHeight, массив items не нужен — документация функции прямо говорит, что он используется только в динамическом режиме.
computeWindow — версия, не зависящая от оси: горизонтальная виртуальная прокрутка (scrollLeft/viewportWidth/itemWidth) и вертикальная используют один и тот же алгоритм, различаются лишь имена полей: offsetY заменяется на offset. LhAxisRange и LhVirtualRange синонимичны.
Таблица функций
Заголовок раздела «Таблица функций»| Функция / константа | Сигнатура | Возврат | Назначение |
|---|---|---|---|
computeRange (через computeVirtualRange) |
(scrollTop, viewportHeight, itemHeight, total, overscan?) => LhVirtualRange |
видимый диапазон | режим фиксированной высоты |
computeWindow |
(scrollOffset, viewportSize, itemSize, total, overscan?) => LhAxisRange |
диапазон, не зависящий от оси | горизонтальная виртуализация |
buildVirtualItems |
(count, sizes, defaultSize?) => { items, totalHeight } |
массив элементов | предрасчёт динамических высот |
patchMeasuredSize |
(items, index, size, defaultSize?) => number |
delta | инкрементное обновление измеренных размеров |
findVirtualRange |
(items, scrollTop, viewportHeight, overscan?) => LhVirtualRange |
видимый диапазон | бинарный поиск при динамической высоте |
getItemOffset ⚠️ |
(items, index) => number |
смещение | прокрутка к заданному элементу |
getVirtualListClassNames |
() => string[] |
классы | контейнер |
Константы: LH_VIRTUAL_ITEM_SIZE = 40 (высота элемента по умолчанию), LH_VIRTUAL_OVERSCAN = 2 (лишняя прокрутка по умолчанию).
⚠️ computeVirtualRange / getItemOffset — совместимые псевдонимы @deprecated; новый код должен использовать computeRange / getItemOffsetAt из shared/virtual.
Определения типов
Заголовок раздела «Определения типов»export interface LhVirtualItem { size: number // расчётная/измеренная высота элемента offset: number // смещение элемента относительно начала списка}
export interface LhVirtualRange { start: number // начальный индекс (включительно) end: number // конечный индекс (не включительно) offsetY: number // смещение окна, px (высота плейсхолдера, удерживающая ползунок прокрутки) totalHeight: number // полная высота, px (создаёт полосу прокрутки)}Доступность
Заголовок раздела «Доступность»Виртуальный список — невидимый вызов для скринридеров: в DOM находятся лишь десятки элементов, и скринридер не может сообщить «элемент 3000 из 10000».
- aria-rowcount / aria-rowindex: контейнеру задаётся
aria-rowcount(общее число элементов), а каждому элементу —aria-rowindex(реальный индекс + 1). Так делается виртуальная прокрутка вtable; для списков это можно позаимствовать. Эти атрибуты отрисовывает UI-слой, core вычисляет только диапазон. - Сохранение фокуса при прокрутке: если пользователь навёл фокус на элемент клавиатурой, а затем прокрутил список, элемент покидает DOM и теряет фокус. UI-слой обязан запомнить индекс фокуса перед размонтированием и восстановить после повторного монтирования — это ключевая трудность клавиатурной пригодности виртуального списка.
- Не делайте виртуальный список семантическим списком: «обход по элементам» у скринридера проходит только по тем элементам, которые есть в DOM. Чисто показательные потоки больших данных (логи, сообщения) подходят для виртуализации; списки, по которым пользователь скринридера хочет ходить поштучно, виртуализируйте с осторожностью (например, каталог товаров, который хотят прослушать элемент за элементом).
- Поиск и позиционирование: содержимое вне виртуального списка отсутствует в DOM, и браузерный Ctrl+F его не найдёт. Возмещайте это функциями «перейти к N-ному элементу» и «поиск-фильтрация».
Примеры использования
Заголовок раздела «Примеры использования»import { computeRange, buildVirtualItems, patchMeasuredSize, findVirtualRange,} from '@lh-ui/share/core/virtual-list'
// Режим фиксированной высотыcomputeRange(800, 400, 50, 1000)// { start: 14, end: 26, offsetY: 700, totalHeight: 50000 }// (first = floor(800/50) = 16, start = 16-2 = 14, visible = ceil(400/50) = 8,// end = 14 + 8 + 4 = 26)
computeRange(0, 400, 50, 10) // { start: 0, end: 10, offsetY: 0, totalHeight: 500 }computeRange(NaN, 400, 50, 10) // { start: 0, end: 10, offsetY: 0, totalHeight: 500 } (очистка NaN)computeRange(0, 400, 0, 10) // { start: 0, end: 0, offsetY: 0, totalHeight: 0 } (некорректный itemHeight)// Режим динамической высотыconst { items, totalHeight } = buildVirtualItems(5, [30, 0, 60], 40)// items: [{ size: 30, offset: 0 }, { size: 40, offset: 30 }, { size: 60, offset: 70 },// { size: 40, offset: 130 }, { size: 40, offset: 170 }]// totalHeight: 210 (пропущенные размеры оцениваются по defaultSize=40)
const delta = patchMeasuredSize(items, 1, 50) // 10 (дельта изменения 40 → 50)// items[2..4].offset сдвигаются на +10; items[1].size = 50
findVirtualRange(items, 100, 200)// бинарный поиск диапазона области просмотра, возвращает { start, end, offsetY, totalHeight }// UI-слой: рендеринг в режиме фиксированной высотыconst range = ref<LhVirtualRange>({ start: 0, end: 0, offsetY: 0, totalHeight: 0 })
function onScroll() { range.value = computeRange( container.value!.scrollTop, container.value!.clientHeight, props.itemHeight, props.data.length, )}const visibleData = computed(() => props.data.slice(range.value.start, range.value.end))<!-- Vue3 --><template> <div class="lh-virtual-list" @scroll.passive="onScroll"> <div class="lh-virtual-list__phantom" :style="{ height: range.totalHeight + 'px' }"> <div class="lh-virtual-list__window" :style="{ transform: `translateY(${range.offsetY}px)` }"> <div v-for="item in visibleData" :key="item.id" class="lh-virtual-list__item"> {{ item.text }} </div> </div> </div> </div></template>// Режим динамической высоты: после рендера измеряем реальную высоту каждого itemfunction onItemMounted(index: number, el: HTMLElement) { const height = el.getBoundingClientRect().height const delta = patchMeasuredSize(items.value, index, height) if (delta !== 0) { // изменение высоты меняет totalHeight и последующие offset, пересчитываем диапазон range.value = findVirtualRange(items.value, scrollTop.value, viewportHeight.value) }}// React: массив items хранится в useRef (patchMeasuredSize меняет на месте и не// вызывает setState, чтобы не запустить цепочку повторных измерений);// события прокрутки троттлятся через throttle или requestAnimationFrame// uniapp: <scroll-view scroll-y :scroll-top> + запрос узлов (createSelectorQuery)// для измерения высоты; события scroll в мини-программе уже троттлятся,// но прокрутка scroll-view отстаёт от h5 на тактСтили и токены
Заголовок раздела «Стили и токены»| Класс | Семантика |
|---|---|
.lh-virtual-list |
контейнер прокрутки (фиксированная высота + overflow: auto) |
.lh-virtual-list__phantom |
элемент-плейсхолдер (высота = totalHeight, создаёт полосу прокрутки) |
.lh-virtual-list__window |
видимое окно (transform: translateY(offsetY)) |
.lh-virtual-list__item |
отдельный элемент |
Высота контейнера задаётся бизнесом (height: 400px или через flex); will-change: transform оптимизирует производительность сдвига окна.
Замечания
Заголовок раздела «Замечания»- В режиме фиксированной высоты высота должна быть действительно фиксированной: переносы содержимого, разные размеры иконок делают высоту элементов неравномерной, и в фиксированном режиме возникает рассинхрон (элементы обрезаются или накладываются). Если есть сомнения — переходите на динамическую высоту или принудительно задавайте одну строку через CSS
white-space: nowrap; overflow: hidden; text-overflow: ellipsis. - Смысл
overscan: лишние 2 элемента сверху и снизу области просмотра предрендерятся, чтобы при быстрой прокрутке не мелькали пустоты. Чем больше значение, тем плавнее, но рендерится больше — 2 это эмпирическое значение по умолчанию, у table используется 3 (строки таблицы сложнее). - Мерцание при динамической высоте: первый экран рендерится с расчётными высотами, а после измерения реальные высоты отличаются → список подпрыгивает. Смягчение: максимально точные расчётные значения (средняя высота из истории) или синхронное измерение при первом рендере.
- Применение delta из
patchMeasuredSize: возвращаемая разница высот нужна для коррекции scrollTop — после изменения размеров положение прокрутки может перестать соответствовать прежнему содержимому, и UI-слой компенсирует прокрутку. - Стабильность key: key в
v-forобязан быть стабильным бизнес-id (а не индексом) — виртуальный список часто добавляет и удаляет элементы, и использование индекса в качестве key приведёт к повторному использованию не тех DOM-узлов. - Горизонтальная виртуализация — через
computeWindow: прокручивающиеся вбок карусели и таймлайны используют версию, не зависящую от оси; алгоритм тот же. - Псевдонимы
@deprecated:computeVirtualRange/getItemOffset— старые имена, делегирующие вshared/virtual; новый код подключает ядро напрямую (@lh-ui/share/core/shared/virtual), а нижестоящий vue2 всё ещё ссылается на старые имена — до удаления нужно сначала перевести его. - Циклическая зависимость момента измерения: рендер зависит от диапазона → диапазон зависит от высоты → высота зависит от измерения после рендера. Разрывается так: первый экран рендерится с расчётными высотами, а после измерения обновляется один раз. Не делайте синхронного принудительного layout в колбэке измерения (
getBoundingClientRect— это уже один reflow). - Не делайте сложных анимаций в виртуальном списке: элементы часто монтируются и размонтируются, анимации появления и исчезновения будут мерцать. Если анимация нужна, используйте FLIP или размещайте её внутри самого элемента.
@lh-ui/share/core/virtual-list → computeRange( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue