Бесконечная прокрутка

Бесконечная прокрутка в Slim Select реализуется через механизм динамической подгрузки опций по мере взаимодействия пользователя со списком. Библиотека изначально ориентирована на работу с ограниченными наборами данных, однако поддержка удалённых источников и кастомной логики загрузки позволяет строить поведение, аналогичное infinite scroll, без явной постраничной навигации.

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

Slim Select не предоставляет встроенного полноценного infinite scroll в стиле виртуализированных списков, но позволяет реализовать его через комбинацию:

  • удалённого источника данных (AJAX/fetch)
  • обработки события поиска
  • кастомной логики добавления новых опций
  • контроля смещения (offset) или курсора (cursor)
  • состояния загрузки

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

Базовая схема работы с удалёнными данными

При использовании удалённого API Slim Select конфигурируется через ajax:

new SlimSelect({
  select: '#select',
  ajax: function (search, callback) {
    fetch(`/api/items?query=${search}`)
      .then(res => res.json())
      .then(data => {
        callback(data.items);
      });
  }
});

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

Пагинация как основа бесконечной прокрутки

Infinite scroll в контексте Slim Select строится поверх пагинации. API должно поддерживать параметры:

  • limit — количество элементов
  • offset или page — смещение
  • либо cursor — указатель на последнюю запись

Пример запроса:

function fetchPage({ search, page }) {
  return fetch(`/api/items?search=${search}&page=${page}&limit=20`)
    .then(res => res.json());
}

Далее состояние страницы хранится локально:

let currentPage = 1;
let isLoading = false;
let hasMore = true;

Интеграция с Slim Select

Slim Select позволяет динамически обновлять список через callback. Базовая загрузка первой страницы:

new SlimSelect({
  select: '#select',
  ajax: (search, callback) => {
    currentPage = 1;
    hasMore = true;

    fetchPage({ search, page: currentPage })
      .then(data => {
        hasMore = data.items.length === 20;
        callback(data.items);
      });
  }
});

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

Обнаружение момента подгрузки

Slim Select рендерит выпадающий список в DOM, что позволяет отслеживать прокрутку через контейнер списка. Используется событие scroll:

const select = new SlimSelect({
  select: '#select',
  ajax: (search, callback) => {
    loadFirstPage(search, callback);
  }
});

setTimeout(() => {
  const dropdown = document.querySelector('.ss-content .ss-list');

  dropdown.addEventListener('scroll', () => {
    const nearBottom =
      dropdown.scrollTop + dropdown.clientHeight >= dropdown.scrollHeight - 10;

    if (nearBottom && !isLoading && hasMore) {
      loadNextPage();
    }
  });
}, 0);

Проверка -10 пикселей используется для компенсации инерции прокрутки и особенностей рендера.

Загрузка следующей страницы

Функция догрузки расширяет список без его сброса:

function loadNextPage() {
  isLoading = true;
  currentPage += 1;

  fetchPage({ search: currentSearch, page: currentPage })
    .then(data => {
      if (data.items.length < 20) {
        hasMore = false;
      }

      select.add(data.items);
    })
    .finally(() => {
      isLoading = false;
    });
}

Метод add используется для добавления новых элементов без очистки текущего списка.

Управление поисковым состоянием

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

let currentSearch = '';

function loadFirstPage(search, callback) {
  currentSearch = search;
  currentPage = 1;

  fetchPage({ search, page: currentPage })
    .then(data => {
      hasMore = data.items.length === 20;
      callback(data.items);
    });
}

Изменение запроса всегда приводит к полной перезагрузке списка.

Защита от дублирования запросов

При быстром скролле возможны множественные вызовы загрузки. Для предотвращения этого используется флаг блокировки:

if (isLoading || !hasMore) return;

Дополнительно применяется debounce для scroll-события:

function debounce(fn, delay) {
  let t;
  return function () {
    clearTimeout(t);
    t = setTimeout(() => fn.apply(this, arguments), delay);
  };
}

Применение:

dropdown.addEventListener('scroll', debounce(handleScroll, 100));

Использование cursor-based пагинации

Cursor-based подход более устойчив к изменению данных на сервере. Вместо номера страницы используется курсор последнего элемента:

let cursor = null;

function fetchPage({ search, cursor }) {
  let url = `/api/items?search=${search}&limit=20`;

  if (cursor) {
    url += `&cursor=${cursor}`;
  }

  return fetch(url).then(res => res.json());
}

Обновление состояния:

function loadNextPage() {
  isLoading = true;

  fetchPage({ search: currentSearch, cursor })
    .then(data => {
      cursor = data.nextCursor;
      hasMore = !!data.nextCursor;

      select.add(data.items);
    })
    .finally(() => {
      isLoading = false;
    });
}

Cursor-подход устраняет проблемы с дубликатами и изменением порядка элементов на сервере.

Очистка и переинициализация списка

При смене контекста (например, нового поиска) требуется полная очистка:

select.setData([]);

currentPage = 1;
cursor = null;
hasMore = true;

После этого инициируется новый запрос первой страницы.

Индикатор загрузки

Поскольку Slim Select не отображает встроенный loader для infinite scroll, состояние загрузки реализуется вручную через добавление временного элемента:

function showLoader() {
  select.add([{ text: 'Загрузка...', value: '__loading__' }]);
}

function hideLoader() {
  select.setData(select.getData().filter(i => i.value !== '__loading__'));
}

Использование:

function loadNextPage() {
  if (isLoading) return;

  isLoading = true;
  showLoader();

  fetchPage(...)
    .then(...)
    .finally(() => {
      hideLoader();
      isLoading = false;
    });
}

Оптимизация количества запросов

При работе с быстрым скроллом и большими наборами данных важна минимизация сетевых вызовов:

  • объединение запросов при частом скролле
  • кеширование страниц по ключу search+page
  • отмена предыдущих запросов через AbortController

Пример кеширования:

const cache = new Map();

function fetchCached(url) {
  if (cache.has(url)) {
    return Promise.resolve(cache.get(url));
  }

  return fetch(url)
    .then(res => res.json())
    .then(data => {
      cache.set(url, data);
      return data;
    });
}

Ограничения DOM-подхода

Реализация infinite scroll через DOM-контейнер Slim Select имеет ряд особенностей:

  • отсутствие виртуализации элементов
  • рост DOM при большом количестве опций
  • необходимость ручного контроля очистки
  • зависимость от внутренней структуры .ss-content

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

Комбинированная модель поведения

На практике используется смешанный подход:

  • первая страница загружается через ajax Slim Select
  • последующие страницы догружаются через scroll listener
  • поиск сбрасывает состояние
  • cursor используется вместо page при сложных данных
  • кеширование снижает нагрузку на API

Такая модель позволяет добиться поведения, близкого к нативному infinite scroll, сохраняя совместимость с архитектурой Slim Select и не нарушая его внутреннюю логику управления списком опций.