Ленивая загрузка данных

Ленивая загрузка данных (Lazy Loading) — подход, при котором данные для элементов интерфейса загружаются не заранее, а только в момент необходимости. В контексте Choices.js это особенно важно при работе с:

  • большими списками значений;
  • удалёнными API;
  • динамическими справочниками;
  • поиском пользователей, товаров, категорий;
  • асинхронными формами;
  • зависимыми селектами.

Без ленивой загрузки браузер вынужден сразу обрабатывать огромный массив <option>, что приводит к:

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

Choices.js позволяет реализовать загрузку данных по требованию через:

  • fetch;
  • async/await;
  • пользовательские обработчики событий;
  • динамическое добавление choices;
  • обновление списка через API.

Базовый принцип работы

Обычный <select> содержит все данные заранее:

<select id="cities">
  <option value="1">Москва</option>
  <option value="2">Санкт-Петербург</option>
  <option value="3">Казань</option>
</select>

При ленивой загрузке список изначально пуст:

<select id="cities"></select>

Данные появляются только после:

  • открытия выпадающего списка;
  • ввода текста;
  • выбора другой опции;
  • прокрутки;
  • пользовательского действия.

Инициализация пустого списка

<select id="users"></select>
const choices = new Choices('#users', {
  searchEnabled: true,
  shouldSort: false
});

На данном этапе список пустой.


Динамическая загрузка через fetch

Простая асинхронная загрузка

async function loadUsers() {
  const response = await fetch('/api/users');
  const users = await response.json();

  choices.setChoices(
    users,
    'id',
    'name',
    true
  );
}

loadUsers();

Как работает setChoices

Метод:

setChoices(array, valueKey, labelKey, replaceChoices)

Аргументы

Аргумент Назначение
array Массив данных
valueKey Поле value
labelKey Поле текста
replaceChoices Заменять ли старые элементы

Формат данных

Ожидаемая структура

[
  {
    "id": 1,
    "name": "Иван"
  },
  {
    "id": 2,
    "name": "Пётр"
  }
]

Загрузка при открытии списка

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


Обработчик showDropdown

let loaded = false;

const choices = new Choices('#users');

document
  .getElementById('users')
  .addEventListener('showDropdown', async () => {

    if (loaded) {
      return;
    }

    const response = await fetch('/api/users');
    const users = await response.json();

    choices.setChoices(users, 'id', 'name', true);

    loaded = true;
  });

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

Без флага:

let loaded = false;

каждое открытие списка отправляло бы новый запрос.

Это может привести к:

  • избыточной нагрузке на сервер;
  • дублированию данных;
  • мерцанию интерфейса;
  • ухудшению UX.

Загрузка по поисковому запросу

Один из самых востребованных сценариев — подгрузка данных во время поиска.


Поиск пользователей

const choices = new Choices('#users', {
  searchEnabled: true,
  searchChoices: false
});

Параметр:

searchChoices: false

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

Теперь поиск будет выполняться сервером.


Отслеживание ввода

const input = document.querySelector('.choices__input');

input.addEventListener('input', async (event) => {
  const query = event.target.value;

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

  const users = await response.json();

  choices.clearChoices();

  choices.setChoices(
    users,
    'id',
    'name',
    true
  );
});

Проблема слишком частых запросов

При быстром вводе:

a
ab
abc
abcd

отправится 4 запроса подряд.

Это создаёт:

  • перегрузку API;
  • скачки интерфейса;
  • гонки запросов;
  • лишний трафик.

Debounce

Debounce ограничивает частоту вызова функции.


Реализация debounce

function debounce(callback, delay) {
  let timeout;

  return (...args) => {
    clearTimeout(timeout);

    timeout = setTimeout(() => {
      callback(...args);
    }, delay);
  };
}

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

const input = document.querySelector('.choices__input');

input.addEventListener(
  'input',
  debounce(async (event) => {

    const query = event.target.value;

    if (query.length < 2) {
      return;
    }

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

    const users = await response.json();

    choices.clearChoices();

    choices.setChoices(
      users,
      'id',
      'name',
      true
    );

  }, 400)
);

Минимальная длина запроса

Практически всегда серверный поиск ограничивают минимальной длиной строки.

Например:

if (query.length < 2) {
  return;
}

или:

if (query.trim().length < 3) {
  return;
}

Это уменьшает количество запросов.


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

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


Добавление временного элемента

choices.setChoices([
  {
    value: '',
    label: 'Загрузка...',
    disabled: true
  }
], 'value', 'label', true);

Полный пример загрузки с индикатором

async function searchUsers(query) {

  choices.clearChoices();

  choices.setChoices([
    {
      value: '',
      label: 'Загрузка...',
      disabled: true
    }
  ], 'value', 'label', true);

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

  const users = await response.json();

  choices.clearChoices();

  choices.setChoices(
    users,
    'id',
    'name',
    true
  );
}

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

Сетевые запросы всегда могут завершиться ошибкой.


try/catch

async function loadUsers() {

  try {

    const response = await fetch('/api/users');

    if (!response.ok) {
      throw new Error('Ошибка сервера');
    }

    const users = await response.json();

    choices.setChoices(
      users,
      'id',
      'name',
      true
    );

  } catch (error) {

    choices.clearChoices();

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

    console.error(error);
  }
}

Очистка старых данных

Перед новой загрузкой обычно выполняют:

choices.clearChoices();

Иначе:

  • старые данные останутся;
  • появятся дубликаты;
  • нарушится логика поиска.

Зависимые селекты

Ленивая загрузка особенно полезна в связанных списках.

Пример:

  • страна;
  • город;
  • район.

Загрузка городов после выбора страны

<select id="country"></select>
<select id="city"></select>

Инициализация

const countryChoices = new Choices('#country');
const cityChoices = new Choices('#city');

Обработчик изменения страны

document
  .getElementById('country')
  .addEventListener('change', async (event) => {

    const countryId = event.target.value;

    cityChoices.clearChoices();

    const response = await fetch(
      `/api/cities?country=${countryId}`
    );

    const cities = await response.json();

    cityChoices.setChoices(
      cities,
      'id',
      'name',
      true
    );
  });

Сброс выбранного значения

После смены родительского элемента обычно сбрасывают дочерний список.

cityChoices.removeActiveItems();

Infinite Scroll

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


Подгрузка частями

Например:

  • первые 50 элементов;
  • затем ещё 50;
  • затем ещё.

Хранение страницы

let page = 1;

Загрузка очередной порции

async function loadNextPage() {

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

  const users = await response.json();

  choices.setChoices(
    users,
    'id',
    'name',
    false
  );

  page++;
}

Ключевой момент:

false

вместо:

true

Теперь новые элементы добавляются, а не заменяют старые.


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

Во время infinite scroll легко отправить несколько одинаковых запросов подряд.


Флаг загрузки

let loading = false;

Проверка состояния

async function loadNextPage() {

  if (loading) {
    return;
  }

  loading = true;

  try {

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

    const users = await response.json();

    choices.setChoices(
      users,
      'id',
      'name',
      false
    );

    page++;

  } finally {
    loading = false;
  }
}

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

При повторных запросах полезно использовать кэш.


Пример кэша

const cache = {};

Проверка кэша

async function searchUsers(query) {

  if (cache[query]) {

    choices.clearChoices();

    choices.setChoices(
      cache[query],
      'id',
      'name',
      true
    );

    return;
  }

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

  const users = await response.json();

  cache[query] = users;

  choices.clearChoices();

  choices.setChoices(
    users,
    'id',
    'name',
    true
  );
}

AbortController

При быстром вводе старые запросы могут приходить позже новых.

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


Отмена предыдущего запроса

let controller;

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

async function searchUsers(query) {

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

  controller = new AbortController();

  try {

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

    const users = await response.json();

    choices.clearChoices();

    choices.setChoices(
      users,
      'id',
      'name',
      true
    );

  } catch (error) {

    if (error.name !== 'AbortError') {
      console.error(error);
    }
  }
}

Ленивая загрузка тегов

Choices.js поддерживает режим тегов.


Инициализация

const choices = new Choices('#tags', {
  removeItemButton: true,
  duplicateItemsAllowed: false
});

Подгрузка популярных тегов

async function loadTags() {

  const response = await fetch('/api/tags');

  const tags = await response.json();

  choices.setChoices(
    tags,
    'id',
    'title',
    true
  );
}

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

Часто используют комбинированную стратегию:

  1. Сначала загружают популярные элементы.
  2. Затем подключают поиск по API.

Пример

await loadPopularUsers();

enableRemoteSearch();

Такой подход:

  • ускоряет первый рендер;
  • уменьшает количество запросов;
  • улучшает UX.

Работа с большими массивами

Массивы на десятки тысяч элементов создают проблемы даже при ленивой загрузке.


Основные рекомендации

Не хранить огромные массивы в браузере

Плохо:

const users = 100000;

Хорошо:

fetch('/api/users?page=1');

Не использовать локальный поиск

Плохо:

searchChoices: true

для огромных массивов.

Лучше:

searchChoices: false

и серверный поиск.


Ограничивать размер ответа

Сервер не должен возвращать тысячи записей.

Оптимально:

20–50 элементов

Серверная пагинация

Типичный API:

/api/users?page=1&limit=20

или:

/api/users?q=alex&page=2

Формат ответа API

{
  "items": [
    {
      "id": 1,
      "name": "Alex"
    }
  ],
  "hasMore": true
}

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

if (data.hasMore) {
  loadNextButton.style.display = 'block';
}

Очистка при уничтожении компонента

При динамических интерфейсах важно корректно удалять экземпляры Choices.js.


Уничтожение

choices.destroy();

Очистка обработчиков

input.removeEventListener('input', handler);

Типичные ошибки

Повторная инициализация

Плохо:

new Choices('#users');
new Choices('#users');

Загрузка без очистки

Плохо:

choices.setChoices(data);
choices.setChoices(data2);

Отсутствие debounce

Плохо:

input.addEventListener('input', async () => {
  await fetch(...);
});

Игнорирование отмены запросов

Без AbortController результаты могут приходить в неправильном порядке.


Архитектура ленивой загрузки

Крупные проекты обычно разделяют:

  • UI;
  • API;
  • состояние;
  • кэш;
  • пагинацию;
  • управление запросами.

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

class UsersService {

  async search(query) {

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

    return response.json();
  }
}

Класс управления Choices.js

class UsersSelect {

  constructor(selector) {

    this.choices = new Choices(selector, {
      searchChoices: false
    });

    this.service = new UsersService();
  }

  async search(query) {

    const users = await this.service.search(query);

    this.choices.clearChoices();

    this.choices.setChoices(
      users,
      'id',
      'name',
      true
    );
  }
}

Оптимизация UX

Скелетоны

Вместо пустого списка:

Загрузка...

используют placeholder-элементы.


Сохранение предыдущих результатов

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


Ограничение частоты поиска

Дополнительно применяют:

  • throttle;
  • request queue;
  • batching.

Ленивая загрузка и мобильные устройства

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

  • слабее CPU;
  • меньше памяти;
  • медленнее сеть.

Поэтому lazy loading становится обязательным подходом.


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

Использовать серверный поиск

Особенно при:

  • 1000+ элементов;
  • каталогах;
  • CRM;
  • административных панелях.

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

Оптимально:

10–30 элементов

Использовать debounce

Наиболее распространённый диапазон:

300–500 мс

Отменять предыдущие запросы

Лучшее решение:

AbortController

Использовать кэш

Особенно для:

  • повторяющихся запросов;
  • справочников;
  • популярных значений.

Не загружать всё сразу

Даже если API позволяет вернуть все данные одним запросом.