Виртуальный скроллинг в Select

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

  • замедление открытия выпадающего списка
  • увеличение потребления памяти
  • снижение плавности прокрутки
  • рост времени обновления DOM

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

Библиотека Naive UI реализует виртуализацию в компоненте NSelect, что позволяет эффективно работать со списками размером в десятки тысяч элементов.


Принцип работы виртуального списка

Обычный список:

10000 элементов -> 10000 DOM-узлов

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

10000 элементов -> ~20–40 DOM-узлов

Виртуализация строится на следующих принципах:

1. Вычисление высоты контейнера

Контейнер списка имеет фиксированную высоту.

containerHeight = visibleItems * itemHeight

2. Определение видимого диапазона

На основе текущего положения прокрутки определяется диапазон элементов:

startIndex
endIndex

3. Отрисовка только видимых элементов

DOM содержит только элементы из этого диапазона.

4. Использование пустого пространства

Для имитации полной высоты списка используется внутренний контейнер с padding или transform.

totalHeight = itemsCount * itemHeight

Таким образом создаётся ощущение полного списка.


Виртуализация в компоненте NSelect

В Naive UI виртуальный скроллинг уже встроен в NSelect и активируется автоматически при использовании большого массива опций.

Простейший пример:

import { NSelect } from "naive-ui"
import { ref } from "vue"

const options = Array.from({ length: 10000 }).map((_, i) => ({
  label: `Option ${i}`,
  value: i
}))

const value = ref(null)
<n-select
  v-model:value="value"
  :options="options"
  filterable
/>

Даже при 10 000 элементов компонент остаётся отзывчивым благодаря виртуализации.


Управление высотой выпадающего списка

Ключевым параметром виртуального скроллинга является высота списка. Она определяет количество элементов, которые одновременно находятся в DOM.

Свойство:

menu-props

Пример настройки:

<n-select
  v-model:value="value"
  :options="options"
  :menu-props="{ style: { maxHeight: '300px' } }"
/>

Если высота контейнера составляет 300px, а высота элемента около 34px, одновременно будет отображаться примерно:

300 / 34 ≈ 8–9 элементов

Это минимизирует количество DOM-узлов.


Настройка высоты элемента

Для корректной виртуализации библиотеке необходимо знать высоту одного элемента списка.

В Naive UI используется приблизительное значение, рассчитанное для стандартных опций.

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

Пример кастомного рендера:

<n-select
  v-model:value="value"
  :options="options"
  :render-option="renderOption"
/>
function renderOption({ node, option }) {
  return h(
    "div",
    {
      style: {
        padding: "8px 12px",
        display: "flex",
        alignItems: "center"
      }
    },
    option.label
  )
}

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


Работа фильтрации при виртуальном скроллинге

NSelect поддерживает фильтрацию (filterable). При вводе текста происходит:

  1. фильтрация массива опций
  2. пересчёт длины списка
  3. обновление виртуального диапазона

Пример:

<n-select
  v-model:value="value"
  :options="options"
  filterable
  clearable
/>

Алгоритм фильтрации:

input -> filter(options) -> newOptions

После фильтрации:

totalHeight = newOptions.length * itemHeight

Виртуальный список автоматически адаптируется.


Асинхронная загрузка опций

При очень больших наборах данных часто используется ленивая загрузка.

Типичный сценарий:

  • сервер содержит миллионы записей
  • клиент загружает только часть
  • при поиске выполняется запрос

Пример:

const options = ref([])
const loading = ref(false)

async function search(value) {
  loading.value = true

  const response = await fetch(`/api/users?q=${value}`)
  const data = await response.json()

  options.value = data.map(user => ({
    label: user.name,
    value: user.id
  }))

  loading.value = false
}
<n-select
  v-model:value="value"
  :options="options"
  filterable
  remote
  :loading="loading"
  :on-search="search"
/>

При этом виртуальный скроллинг продолжает работать с текущим набором данных.


Производительность и оптимизация

Минимизация вычислений

Опции желательно создавать один раз.

Плохой вариант:

const options = computed(() =>
  items.value.map(i => ({
    label: i.name,
    value: i.id
  }))
)

Лучше:

const options = ref([])

watch(items, () => {
  options.value = items.value.map(i => ({
    label: i.name,
    value: i.id
  }))
})

Это предотвращает лишние перерасчёты.


Использование ключей

Каждая опция должна иметь стабильный value, который используется как ключ.

value -> key

Плохой пример:

value = index

Хороший пример:

value = database_id

Ограничение количества элементов

Даже при виртуализации массив из 100 000 объектов может занимать значительную память.

Практика:

5 000 – комфортный диапазон
10 000 – допустимый
> 50 000 – желательно серверная фильтрация

Кастомизация отображения опций

Компонент поддерживает функцию render-option.

import { h } from "vue"

function renderOption({ option }) {
  return h(
    "div",
    {
      style: {
        display: "flex",
        justifyContent: "space-between"
      }
    },
    [
      option.label,
      h("span", { style: { opacity: 0.5 } }, option.value)
    ]
  )
}
<n-select
  :options="options"
  :render-option="renderOption"
/>

Виртуализация остаётся активной, поскольку компонент управляет контейнером списка.


Группировка опций

Naive UI поддерживает группировку:

const options = [
  {
    type: "group",
    label: "Frontend",
    key: "frontend",
    children: [
      { label: "Vue", value: "vue" },
      { label: "React", value: "react" }
    ]
  },
  {
    type: "group",
    label: "Backend",
    key: "backend",
    children: [
      { label: "Node.js", value: "node" },
      { label: "Go", value: "go" }
    ]
  }
]
<n-select :options="options" />

При виртуализации группы учитываются как отдельные элементы списка.


Ограничения виртуального скроллинга

Переменная высота элементов

Если элементы имеют различную высоту:

item1 = 32px
item2 = 70px
item3 = 40px

виртуальный список может неправильно вычислять позицию.

Рекомендуется:

  • одинаковая высота
  • фиксированный padding
  • отсутствие динамического контента

Сложные компоненты внутри опций

Если внутри опций используются:

  • изображения
  • анимации
  • вложенные компоненты

это может снижать производительность.

Лучше использовать:

  • простую разметку
  • минимум реактивности

Перерисовка при изменении options

При полной замене массива:

options.value = newArray

виртуальный список пересчитывает структуру.

Если изменения частичные, эффективнее обновлять элементы внутри массива.


Практический пример большого списка

Пример с 20 000 элементов.

const options = Array.from({ length: 20000 }).map((_, i) => ({
  label: `User ${i}`,
  value: i
}))
<n-select
  v-model:value="value"
  :options="options"
  filterable
  placeholder="Select user"
/>

Без виртуализации:

20000 DOM элементов

С виртуализацией:

~30 DOM элементов

Разница в производительности может достигать десятков раз.


Взаимодействие с другими возможностями Select

Виртуальный скроллинг корректно работает совместно с:

  • multiple
  • tag
  • filterable
  • remote
  • render-option
  • render-label

Пример множественного выбора:

<n-select
  v-model:value="value"
  :options="options"
  multiple
  filterable
/>

Даже при выборе множества значений список остаётся виртуализированным.


Внутренние механизмы виртуализации

Компонент NSelect использует внутренний виртуальный список, реализованный на основе принципов:

scrollTop
itemHeight
visibleCount
bufferSize

Алгоритм:

startIndex = floor(scrollTop / itemHeight)

endIndex = startIndex + visibleCount + buffer

Буфер необходим для предотвращения “пустых” областей при быстрой прокрутке.


Поведение при быстром скроллинге

При быстром перемещении:

  1. браузер обновляет scrollTop
  2. вычисляется новый диапазон
  3. DOM обновляется

Чтобы избежать скачков интерфейса:

  • используется буфер элементов
  • применяются CSS transform вместо top

Это позволяет сохранять плавность прокрутки.


Практические рекомендации

Использовать виртуализацию при:

  • списках более 200–300 элементов
  • динамических данных
  • фильтрации
  • удалённых API

Избегать:

  • переменной высоты строк
  • тяжёлых компонентов внутри опций
  • постоянного пересоздания массива options

Оптимальная архитектура:

API
 ↓
store / composable
 ↓
options[]
 ↓
NSelect (virtual list)

Такой подход обеспечивает масштабируемость и высокую производительность интерфейса при работе с большими наборами данных.