Пагинация результатов

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

Основная модель Tom Select предполагает асинхронную функцию загрузки:

new TomSelect('#select', {
  valueField: 'id',
  labelField: 'title',
  searchField: 'title',

  load: function(query, callback) {
    fetch(`/api/items?q=${encodeURIComponent(query)}`)
      .then(res => res.json())
      .then(json => {
        callback(json.items);
      })
      .catch(() => callback());
  }
});

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


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

Пагинация в Tom Select строится вокруг двух ключевых принципов:

  • разбиение результата на страницы (page / limit или offset / limit)
  • сохранение состояния текущего запроса

Серверная часть обычно возвращает структуру вида:

{
  "items": [
    { "id": 1, "title": "Alpha" },
    { "id": 2, "title": "Beta" }
  ],
  "has_more": true,
  "next_page": 2
}

Реализация пагинации через page/limit

Наиболее читаемый вариант — использование номера страницы.

Клиентская логика

new TomSelect('#select', {
  valueField: 'id',
  labelField: 'title',
  searchField: 'title',

  load: function(query, callback) {
    const self = this;

    self.page = self.page || 1;
    self.query = self.query || null;

    if (self.query !== query) {
      self.page = 1;
      self.query = query;
      self.clearOptions();
    }

    fetch(`/api/items?q=${encodeURIComponent(query)}&page=${self.page}`)
      .then(res => res.json())
      .then(data => {
        callback(data.items);

        self.page++;
        self.hasMore = data.has_more;
      })
      .catch(() => callback());
  }
});

Ключевые моменты реализации

  • self.page хранит текущую страницу
  • при изменении запроса состояние сбрасывается
  • новые элементы добавляются поверх существующих
  • флаг hasMore контролирует возможность дальнейшей загрузки

Пагинация через offset/limit

Альтернативный и часто более предсказуемый способ — использование смещения.

Пример реализации

new TomSelect('#select', {
  valueField: 'id',
  labelField: 'title',
  searchField: 'title',

  load: function(query, callback) {
    const self = this;

    self.offset = self.offset || 0;
    self.limit = 20;
    self.query = self.query || null;

    if (self.query !== query) {
      self.offset = 0;
      self.query = query;
      self.clearOptions();
    }

    fetch(`/api/items?q=${encodeURIComponent(query)}&offset=${self.offset}&limit=${self.limit}`)
      .then(res => res.json())
      .then(data => {
        callback(data.items);

        self.offset += data.items.length;
        self.hasMore = data.has_more;
      })
      .catch(() => callback());
  }
});

Особенности offset-подхода

  • проще синхронизировать состояние
  • устойчив к изменению структуры страниц на сервере
  • может быть менее эффективен при больших offset значениях в БД

Управление догрузкой данных

Tom Select не навязывает автоматическую пагинацию интерфейса, поэтому механизм “догрузки” реализуется вручную.

Типичный паттерн — загрузка следующей страницы при достижении конца списка результатов:

onDropdownOpen: function() {
  const self = this;

  const loadMoreIfNeeded = () => {
    if (!self.hasMore) return;

    const dropdown = self.dropdown_content;
    const scrollPosition = dropdown.scrollTop + dropdown.clientHeight;
    const scrollHeight = dropdown.scrollHeight;

    if (scrollPosition >= scrollHeight - 20) {
      self.load(self.lastQuery, () => {});
    }
  };

  self.dropdown_content.addEventListener('scroll', loadMoreIfNeeded);
}

Объединение результатов при пагинации

При постраничной загрузке важно не затирать ранее загруженные элементы. Tom Select ожидает, что callback() будет получать только новые элементы, а не полный список.

Корректная стратегия добавления

callback(data.items);

Не допускается:

callback([...oldItems, ...newItems]);

Старая коллекция управляется самим Tom Select.


Защита от повторных запросов

При быстром вводе текста возможны гонки запросов, когда ответы приходят в неверном порядке. Для пагинации это особенно критично.

Пример блокировки устаревших ответов

load: function(query, callback) {
  const self = this;

  self.requestId = (self.requestId || 0) + 1;
  const currentRequest = self.requestId;

  fetch(`/api/items?q=${query}`)
    .then(res => res.json())
    .then(data => {
      if (currentRequest !== self.requestId) return;
      callback(data.items);
    });
}

Кеширование страниц

При повторном вводе одинакового запроса полезно кешировать уже загруженные страницы.

Пример структуры кеша

self.cache = self.cache || {};

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

const key = `${query}:${self.page}`;

if (self.cache[key]) {
  callback(self.cache[key]);
  return;
}

Сохранение результата

self.cache[key] = data.items;

Кеширование снижает количество запросов и ускоряет работу интерфейса.


Пагинация и сортировка

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

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

if (self.sort !== newSort) {
  self.page = 1;
  self.offset = 0;
  self.clearOptions();
  self.cache = {};
}

Сортировка должна быть частью ключа запроса:

const key = `${query}:${sort}:${page}`;

Серверная часть пагинации

Хотя основное внимание уделяется клиенту, корректная серверная реализация критична.

Пример логики (Node.js-подобный псевдокод)

app.get('/api/items', (req, res) => {
  const q = req.query.q || '';
  const page = parseInt(req.query.page || 1);
  const limit = 20;

  const filtered = database.filter(item =>
    item.title.toLowerCase().includes(q.toLowerCase())
  );

  const start = (page - 1) * limit;
  const end = start + limit;

  res.json({
    items: filtered.slice(start, end),
    has_more: end < filtered.length
  });
});

Обработка пустых страниц

При пагинации возможна ситуация, когда следующая страница пуста, но has_more ещё не обновился корректно.

Защитный механизм

if (!data.items.length) {
  self.hasMore = false;
  return;
}

Оптимизация сетевых запросов

Пагинация часто сочетается с debounce логикой ввода.

loadThrottle: 300

или ручная реализация:

let timer;

load: function(query, callback) {
  clearTimeout(timer);

  timer = setTimeout(() => {
    fetch(`/api/items?q=${query}`)
      .then(res => res.json())
      .then(data => callback(data.items));
  }, 300);
}

Комбинированная модель: поиск + бесконечная пагинация

Наиболее сложный, но распространённый сценарий:

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

Единая структура состояния

self.state = {
  query: '',
  page: 1,
  hasMore: true,
  loading: false
};

Контроль загрузки

if (self.state.loading || !self.state.hasMore) return;

self.state.loading = true;

Типичные ошибки реализации

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

Поведение при пустом поиске

Часто требуется отдельная логика, когда запрос пустой:

if (!query.length) {
  callback([]);
  return;
}

или загрузка популярных элементов:

fetch('/api/items?popular=1')

Итоговая архитектурная схема

Пагинация в Tom Select строится вокруг трёх уровней:

  • UI слой — Tom Select и его load
  • клиентская логика — управление page/offset, кеш, блокировки
  • сервер — отдача порций данных + флаг продолжения

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