Интеграция с fetch API

Асинхронная загрузка данных через fetch API в Choices.js позволяет превращать обычные селекты в динамические компоненты, работающие с удалёнными источниками данных. Такой подход используется при больших справочниках, поиске по базе пользователей, тегах, товарах и любых сценариях, где список опций не может быть заранее загружен целиком.

Основная идея интеграции заключается в том, что Choices.js не хранит полный набор данных локально, а получает его порциями по мере ввода пользователя. Для этого используется событие ввода, обработка запроса и вызов fetch к серверному API.


Связка Choices.js и fetch API строится вокруг нескольких ключевых этапов:

  • отслеживание пользовательского ввода;
  • формирование HTTP-запроса;
  • получение JSON-ответа;
  • преобразование данных в формат Choices.js;
  • обновление списка опций.

Choices.js ожидает данные в формате:

{
  value: "id",
  label: "Название"
}

или расширенный вариант:

{
  value: "id",
  label: "Название",
  selected: false,
  disabled: false
}

Инициализация Choices.js для удалённых данных

Для работы с fetch обычно отключается первоначальная загрузка опций и включается режим поиска:

const element = document.querySelector('#users');

const choices = new Choices(element, {
  searchEnabled: true,
  shouldSort: false,
  placeholderValue: 'Начните ввод...',
  loadingText: 'Загрузка...',
  noResultsText: 'Ничего не найдено',
});

На этом этапе библиотека готова принимать динамические данные, но ещё не подключена к серверу.


Подключение fetch к событию ввода

Choices.js предоставляет события, позволяющие реагировать на ввод пользователя. Наиболее часто используется событие search.

element.addEventListener('search', async (event) => {
  const query = event.detail.value;

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

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

  choices.setChoices(formatted, 'value', 'label', true);
});

Метод setChoices полностью заменяет текущие элементы списка.


Серверный контракт API

Для корректной работы fetch API важно, чтобы сервер возвращал предсказуемую структуру данных.

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

[
  { "id": 1, "name": "Алексей Иванов" },
  { "id": 2, "name": "Мария Петрова" }
]

Расширенный вариант для поиска:

{
  "items": [
    { "id": 1, "name": "Алексей Иванов" },
    { "id": 2, "name": "Мария Петрова" }
  ],
  "total": 120
}

Во втором случае требуется дополнительная обработка:

const data = await response.json();

const formatted = data.items.map(item => ({
  value: item.id,
  label: item.name
}));

Использование AbortController для отмены запросов

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

let controller = null;

element.addEventListener('search', async (event) => {
  const query = event.detail.value;

  if (controller) {
    controller.abort();
  }

  controller = new AbortController();

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

    const data = await response.json();

    choices.setChoices(
      data.map(item => ({
        value: item.id,
        label: item.name
      })),
      'value',
      'label',
      true
    );
  } catch (err) {
    if (err.name !== 'AbortError') {
      console.error('Ошибка загрузки:', err);
    }
  }
});

Такой подход гарантирует, что отображаются только актуальные результаты.


Добавление задержки (debounce) перед запросом

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

function debounce(fn, delay) {
  let timer;

  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

Применение:

const searchUsers = debounce(async (query) => {
  const response = await fetch(`/api/users?q=${query}`);
  const data = await response.json();

  choices.setChoices(
    data.map(u => ({
      value: u.id,
      label: u.name
    })),
    'value',
    'label',
    true
  );
}, 300);

element.addEventListener('search', (event) => {
  searchUsers(event.detail.value);
});

Задержка в 200–400 мс является стандартной для UX-поиска.


Интеграция с REST-пагинацией

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

{
  "items": [...],
  "page": 1,
  "hasMore": true
}

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

let page = 1;
let currentQuery = '';

const loadPage = async (query, pageNum) => {
  const response = await fetch(
    `/api/users?q=${query}&page=${pageNum}`
  );

  return response.json();
};

Обработка:

element.addEventListener('search', async (event) => {
  currentQuery = event.detail.value;
  page = 1;

  const data = await loadPage(currentQuery, page);

  choices.setChoices(
    data.items.map(i => ({
      value: i.id,
      label: i.name
    })),
    'value',
    'label',
    true
  );
});

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


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

Чтобы снизить количество запросов, используется локальный кэш:

const cache = new Map();

Логика:

const fetchUsers = async (query) => {
  if (cache.has(query)) {
    return cache.get(query);
  }

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

  cache.set(query, data);

  return data;
};

Такой подход особенно эффективен при повторяющихся запросах.


Обновление списка без полной перезагрузки

Метод setChoices перезаписывает данные. В некоторых сценариях требуется добавление новых элементов:

choices.setChoices(
  newItems,
  'value',
  'label',
  false
);

Пятый параметр replaceChoices управляет поведением:

  • true — заменить полностью;
  • false — добавить к существующим.

Обработка ошибок сети

При использовании fetch важно учитывать нестабильность соединения:

try {
  const response = await fetch(`/api/users?q=${query}`);

  if (!response.ok) {
    throw new Error('HTTP ошибка');
  }

  const data = await response.json();
} catch (error) {
  console.error('Ошибка запроса:', error);

  choices.setChoices([
    {
      value: '',
      label: 'Ошибка загрузки данных',
      disabled: true
    }
  ], 'value', 'label', true);
}

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


Формирование сложных запросов

При интеграции с backend часто требуется передавать дополнительные параметры:

const params = new URLSearchParams({
  q: query,
  limit: 20,
  locale: 'ru'
});

fetch(`/api/users?${params.toString()}`);

Это обеспечивает совместимость с фильтрацией на сервере.


Оптимизация под высоконагруженные интерфейсы

При работе с Choices.js и fetch API в масштабных системах применяются дополнительные техники:

  • ограничение количества символов до запроса;
  • минимальная длина строки (например, 2–3 символа);
  • серверная фильтрация вместо клиентской;
  • отключение автоматического поиска при пустом вводе;
  • приоритет последних запросов над устаревшими ответами.

Пример минимальной длины:

element.addEventListener('search', (event) => {
  const query = event.detail.value;

  if (query.length < 3) return;

  searchUsers(query);
});

Связка с авторизацией

Если API требует токен, fetch дополняется заголовками:

fetch(`/api/users?q=${query}`, {
  headers: {
    'Authorization': `Bearer ${token}`
  }
});

Choices.js при этом остаётся полностью независимым от механизма авторизации.


Объединение всех механизмов

В реальных приложениях fetch-интеграция включает одновременно:

  • debounce;
  • AbortController;
  • кэширование;
  • обработку ошибок;
  • пагинацию;
  • минимальную длину запроса.

Пример комбинированного подхода:

let controller;
const cache = new Map();

const searchUsers = debounce(async (query) => {
  if (query.length < 3) return;

  if (cache.has(query)) {
    choices.setChoices(cache.get(query), 'value', 'label', true);
    return;
  }

  if (controller) controller.abort();
  controller = new AbortController();

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

    const data = await res.json();

    const formatted = data.map(u => ({
      value: u.id,
      label: u.name
    }));

    cache.set(query, formatted);

    choices.setChoices(formatted, 'value', 'label', true);
  } catch (e) {
    if (e.name !== 'AbortError') {
      console.error(e);
    }
  }
}, 300);

element.addEventListener('search', (e) => {
  searchUsers(e.detail.value);
});