Обработка ответов сервера

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

Наиболее простой вариант ответа — массив объектов. Каждый объект представляет один элемент списка и должен содержать как минимум поля, соответствующие конфигурации valueField и labelField (по умолчанию value и label).

[
  { "value": 1, "label": "Москва" },
  { "value": 2, "label": "Санкт-Петербург" }
]

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

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

[
  { "id": 1, "title": "Москва" },
  { "id": 2, "title": "Санкт-Петербург" }
]

необходимо явно указать соответствие:

new TomSelect("#select", {
  valueField: "id",
  labelField: "title",
  searchField: "title"
});

Режим удалённой загрузки данных

Удалённая загрузка активируется через параметр load, который определяет функцию запроса к серверу при вводе пользователя.

new TomSelect("#select", {
  valueField: "id",
  labelField: "name",
  searchField: "name",

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

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


Контракт формата данных при load

При использовании load сервер может возвращать:

  1. Массив объектов — самый простой вариант
  2. Объект с полем данных
  3. Структуру с метаданными (пагинация, статус)

Вариант с массивом

[
  { "id": 10, "name": "Алматы" },
  { "id": 11, "name": "Астана" }
]

Вариант с обёрткой

{
  "items": [
    { "id": 10, "name": "Алматы" },
    { "id": 11, "name": "Астана" }
  ]
}

В этом случае требуется трансформация:

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

Нормализация данных перед передачей в Tom Select

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

function normalizeResponse(json) {
  if (Array.isArray(json)) return json;
  if (Array.isArray(json.items)) return json.items;
  if (Array.isArray(json.data)) return json.data;
  return [];
}

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

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

Асинхронная реализация через async/await

Более читаемый вариант обработки ответа сервера:

load: async function(query, callback) {
  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const json = await res.json();
    callback(normalizeResponse(json));
  } catch (e) {
    callback();
  }
}

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


Обработка ошибок и отказоустойчивость

При работе с удалёнными источниками данных важно учитывать:

  • сетевые ошибки
  • некорректный JSON
  • таймауты
  • пустые ответы

Типовая стратегия:

load: function(query, callback) {
  fetch(`/api/search?q=${query}`)
    .then(res => {
      if (!res.ok) throw new Error("Network error");
      return res.json();
    })
    .then(data => callback(normalizeResponse(data)))
    .catch(() => {
      callback([]);
    });
}

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


Поддержка пагинации

Для больших наборов данных сервер часто возвращает частичные результаты.

Пример ответа:

{
  "items": [
    { "id": 1, "name": "Item 1" },
    { "id": 2, "name": "Item 2" }
  ],
  "has_more": true,
  "next_page": 2
}

Tom Select не обрабатывает пагинацию автоматически, поэтому логика реализуется вручную:

let currentPage = 1;

new TomSelect("#select", {
  load: function(query, callback) {
    fetch(`/api/items?q=${query}&page=${currentPage}`)
      .then(res => res.json())
      .then(json => {
        currentPage = json.next_page || currentPage;
        callback(json.items);
      })
      .catch(() => callback());
  }
});

Инкрементальная загрузка данных

Для реализации бесконечной прокрутки используется расширение логики load и обработка события раскрытия списка.

onDropdownOpen: function() {
  if (!this.settings.loadMore) return;

  this.load(this.lastQuery, items => {
    this.addOption(items);
    this.refreshOptions(false);
  });
}

Управление параметрами запроса

Формирование query string играет ключевую роль при интеграции с API:

function buildQuery(query, page) {
  const params = new URLSearchParams();
  params.set("q", query);
  params.set("page", page);
  params.set("limit", 20);
  return params.toString();
}

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

fetch(`/api/search?${buildQuery(query, 1)}`)

Прерывание предыдущих запросов

При быстром вводе пользователя возникает проблема гонки запросов. Решение — AbortController.

let controller = null;

load: function(query, callback) {
  if (controller) controller.abort();
  controller = new AbortController();

  fetch(`/api/search?q=${query}`, {
    signal: controller.signal
  })
    .then(res => res.json())
    .then(data => callback(data))
    .catch(() => callback());
}

Это предотвращает обработку устаревших ответов.


Кеширование результатов

Для оптимизации можно хранить результаты запросов в памяти:

const cache = new Map();

load: function(query, callback) {
  if (cache.has(query)) {
    callback(cache.get(query));
    return;
  }

  fetch(`/api/search?q=${query}`)
    .then(res => res.json())
    .then(data => {
      cache.set(query, data);
      callback(data);
    })
    .catch(() => callback());
}

Кеширование особенно эффективно при повторяющихся запросах пользователей.


Контроль частоты запросов

Tom Select поддерживает параметр loadThrottle, который ограничивает частоту вызова load.

new TomSelect("#select", {
  loadThrottle: 300
});

Это снижает нагрузку на сервер при быстром вводе текста.


Пустые результаты и UX-обработка

Если сервер возвращает пустой массив, интерфейс должен корректно сбрасывать список:

.then(data => {
  const items = normalizeResponse(data);
  callback(items.length ? items : []);
})

При необходимости можно возвращать специальные служебные элементы:

[
  { "id": null, "name": "Ничего не найдено", "disabled": true }
]

Поддержка optgroups в ответе сервера

Сервер может группировать данные:

[
  {
    "optgroup": "Города",
    "items": [
      { "id": 1, "name": "Алматы" }
    ]
  }
]

Требуется трансформация:

function normalizeGroups(data) {
  const result = [];

  data.forEach(group => {
    group.items.forEach(item => {
      result.push({
        ...item,
        optgroup: group.optgroup
      });
    });
  });

  return result;
}

Защита и санитаризация данных

При обработке серверных ответов важно исключать:

  • HTML-инъекции в label
  • некорректные символы
  • неожиданные поля

Простейшая защита:

function sanitize(item) {
  return {
    ...item,
    label: String(item.label)
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
  };
}

Производительность при больших данных

При масштабных выборках ключевыми становятся:

  • минимизация размера ответа
  • ограничение limit
  • серверная фильтрация вместо клиентской
  • исключение тяжёлых полей

Оптимальный контракт:

{
  "items": [
    { "id": 1, "name": "A" }
  ],
  "meta": {
    "total": 10000
  }
}

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


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

Полный цикл обработки обычно включает:

  • запрос с параметрами поиска
  • отмену предыдущего запроса
  • получение JSON
  • нормализацию структуры
  • кеширование результата
  • передачу данных в callback
load: async function(query, callback) {
  try {
    if (controller) controller.abort();
    controller = new AbortController();

    const res = await fetch(`/api/search?q=${query}`, {
      signal: controller.signal
    });

    const json = await res.json();
    const data = normalizeResponse(json);

    cache.set(query, data);

    callback(data);
  } catch {
    callback();
  }
}