Индикация процесса загрузки

При работе с динамическими данными интерфейс не всегда способен мгновенно отобразить список элементов. Источником данных могут быть:

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

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

  • создаётся ощущение зависания;
  • появляются повторные клики;
  • увеличивается вероятность ошибок;
  • ухудшается UX;
  • теряется понимание текущего состояния компонента.

Индикация загрузки решает эту проблему через отображение промежуточного состояния.


Базовая схема загрузки данных

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

  1. Пользователь открывает dropdown.
  2. Выполняется запрос к серверу.
  3. Пока сервер отвечает — отображается индикатор.
  4. После получения данных Choices.js обновляет список вариантов.

Простейшая индикация загрузки

HTML

<sel ect id="users"></select>

<div id="loader" class="loader hidden">
  Загрузка...
</div>

CSS

.hidden {
  display: none;
}

.loader {
  margin-top: 10px;
  color: #666;
  font-size: 14px;
}

Javascript

const loader = document.getElementById('loader');

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

async function loadUsers() {
  loader.classList.remove('hidden');

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

    choices.setChoices(
      users,
      'id',
      'name',
      true
    );
  } catch (error) {
    console.error(error);
  } finally {
    loader.classList.add('hidden');
  }
}

loadUsers();

Управление состояниями интерфейса

Интерфейс загрузки обычно состоит из нескольких состояний:

Состояние Описание
idle ожидание
loading загрузка
success успешная загрузка
error ошибка
empty пустой результат

Choices.js сам по себе не управляет подобной логикой автоматически. Состояния реализуются вручную.


Создание полноценного блока состояний

HTML

<div class="status-box">

  <div id="loadingState" class="hidden">
    Загрузка данных...
  </div>

  <div id="errorState" class="hidden">
    Ошибка загрузки
  </div>

  <div id="emptyState" class="hidden">
    Ничего не найдено
  </div>

</div>

<select id="products"></select>

Javascript

const loadingState = document.getElementById('loadingState');
const errorState = document.getElementById('errorState');
const emptyState = document.getElementById('emptyState');

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

function resetStates() {
  loadingState.classList.add('hidden');
  errorState.classList.add('hidden');
  emptyState.classList.add('hidden');
}

async function loadProducts() {
  resetStates();

  loadingState.classList.remove('hidden');

  try {
    const response = await fetch('/api/products');

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

    const products = await response.json();

    if (!products.length) {
      emptyState.classList.remove('hidden');
      return;
    }

    choices.setChoices(
      products,
      'id',
      'title',
      true
    );

  } catch (error) {
    errorState.classList.remove('hidden');
  } finally {
    loadingState.classList.add('hidden');
  }
}

loadProducts();

Индикация загрузки при поиске

Одна из важнейших возможностей Choices.js — поиск с удалённым API.

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

a
ap
app
appl
apple

Без индикации пользователь не понимает:

  • выполняется ли поиск;
  • завис ли интерфейс;
  • обновятся ли результаты.

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

HTML

<select id="search-users"></select>

<div id="searchLoader" class="hidden">
  Выполняется поиск...
</div>

Javascript

const searchLoader = document.getElementById('searchLoader');

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

const input = choices.input.element;

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

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

  searchLoader.classList.remove('hidden');

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

    const users = await response.json();

    choices.clearChoices();

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

  } catch (error) {
    console.error(error);
  } finally {
    searchLoader.classList.add('hidden');
  }
});

Проблема множественных запросов

При быстром вводе появляются серьёзные проблемы:

Запрос 1 → "a"
Запрос 2 → "ap"
Запрос 3 → "app"

Сервер может ответить в другом порядке:

Ответ 3
Ответ 1
Ответ 2

В результате интерфейс покажет устаревшие данные.


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

Для корректной индикации загрузки используется AbortController.

Пример

let controller = null;

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

const input = choices.input.element;

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

  const query = event.target.value;

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

  controller = new AbortController();

  loader.classList.remove('hidden');

  try {

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

    const data = await response.json();

    choices.clearChoices();

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

  } catch (error) {

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

  } finally {
    loader.classList.add('hidden');
  }
});

Индикатор внутри dropdown

Внешний loader подходит не всегда. Часто требуется встроить сообщение прямо в список Choices.js.


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

Пример

choices.clearChoices();

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

После завершения загрузки временный элемент заменяется настоящими данными.


Полный пример

async function loadCategories() {

  choices.clearChoices();

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

  try {

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

    const categories = await response.json();

    choices.clearChoices();

    choices.setChoices(
      categories,
      'id',
      'title',
      true
    );

  } catch (error) {

    choices.clearChoices();

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

  }
}

Использование CSS-анимации

Текстовая индикация подходит не всегда. Более современным решением является spinner.


CSS spinner

.spinner {
  width: 20px;
  height: 20px;

  border: 3px solid #ddd;
  border-top: 3px solid #333;

  border-radius: 50%;

  animation: spin 1s linear infinite;
}

@keyframes spin {
  fr om {
    transform: rotate(0deg);
  }

  to {
    transform: rotate(360deg);
  }
}

HTML

<div id="spinner" class="spinner hidden"></div>

Показ spinner

spinner.classList.remove('hidden');

try {
  await loadData();
} finally {
  spinner.classList.add('hidden');
}

Индикация при бесконечной подгрузке

Некоторые интерфейсы подгружают данные порциями:

  • страницы;
  • сообщения;
  • товары;
  • пользователей.

В этом случае индикатор появляется внизу списка.


Подгрузка данных по scroll

Пример

const dropdown = choices.dropdown.element;

dropdown.addEventListener('scroll', async () => {

  const reachedBottom =
    dropdown.scrollTop + dropdown.clientHeight >=
    dropdown.scrollHeight - 10;

  if (!reachedBottom) {
    return;
  }

  showLoader();

  try {
    await loadNextPage();
  } finally {
    hideLoader();
  }
});

Блокировка интерфейса во время загрузки

Иногда требуется запретить взаимодействие до завершения операции.


Отключение select

choices.disable();

try {
  await loadData();
} finally {
  choices.enable();
}

Комбинирование loader и disable

choices.disable();

loader.classList.remove('hidden');

try {

  await fetchData();

} finally {

  loader.classList.add('hidden');

  choices.enable();
}

Индикация процента загрузки

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


HTML

<div class="progress-bar">
  <div id="progress"></div>
</div>

CSS

.progress-bar {
  width: 300px;
  height: 20px;
  background: #eee;
}

#progress {
  width: 0%;
  height: 100%;
  background: #333;
}

Javascript

function updateProgress(percent) {
  progress.style.width = `${percent}%`;
}

Имитация загрузки

let percent = 0;

const interval = setInterval(() => {

  percent += 10;

  updateProgress(percent);

  if (percent >= 100) {
    clearInterval(interval);
  }

}, 300);

Skeleton Loader

Современные интерфейсы часто используют skeleton-анимацию вместо spinner.


CSS

.skeleton {
  height: 40px;
  margin-bottom: 10px;

  border-radius: 4px;

  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );

  background-size: 200% 100%;

  animation: skeleton 1.5s infinite;
}

@keyframes skeleton {
  0% {
    background-position: 200% 0;
  }

  100% {
    background-position: -200% 0;
  }
}

HTML

<div id="skeletons">

  <div class="skeleton"></div>
  <div class="skeleton"></div>
  <div class="skeleton"></div>

</div>

Скрытие skeleton после загрузки

skeletons.style.display = 'block';

try {

  await loadData();

} finally {

  skeletons.style.display = 'none';

}

Индикация повторной загрузки

Первичная загрузка и повторное обновление интерфейса обычно различаются.


Первый запрос

Пустой экран + loader

Повторная загрузка

Старые данные + небольшой индикатор обновления

Пример

async function refreshData() {

  refreshIndicator.style.display = 'block';

  try {

    const data = await fetchData();

    updateChoices(data);

  } finally {

    refreshIndicator.style.display = 'none';

  }
}

Debounce для уменьшения количества загрузок

Без debounce индикатор может мигать слишком часто.


Простейший debounce

function debounce(callback, delay) {

  let timeout;

  return (...args) => {

    clearTimeout(timeout);

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

  };
}

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

const debouncedSearch = debounce(async (query) => {

  loader.classList.remove('hidden');

  try {

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

    const data = await response.json();

    choices.clearChoices();

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

  } finally {

    loader.classList.add('hidden');

  }

}, 400);

Обработка слишком быстрой загрузки

Если сервер отвечает мгновенно, loader может «моргать».

Это ухудшает восприятие интерфейса.


Минимальное время отображения loader

async function withMinimumLoader(task) {

  const start = Date.now();

  loader.classList.remove('hidden');

  try {

    return await task();

  } finally {

    const elapsed = Date.now() - start;

    const remaining = 500 - elapsed;

    if (remaining > 0) {
      await new Promise(resolve => {
        setTimeout(resolve, remaining);
      });
    }

    loader.classList.add('hidden');
  }
}

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

await withMinimumLoader(async () => {

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

  const users = await response.json();

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

});

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

Отсутствие finally

Неправильно:

loader.style.display = 'block';

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

loader.style.display = 'none';

Если произойдёт ошибка — loader останется навсегда.


Правильный вариант

loader.style.display = 'block';

try {

  await fetch('/api/users');

} finally {

  loader.style.display = 'none';

}

Несколько loader одновременно

Ошибка:

showLoader();
showLoader();
hideLoader();

Loader исчезнет слишком рано.


Счётчик активных загрузок

let loadingCount = 0;

function startLoading() {

  loadingCount++;

  loader.style.display = 'block';
}

function stopLoading() {

  loadingCount--;

  if (loadingCount <= 0) {

    loadingCount = 0;

    loader.style.display = 'none';
  }
}

Неправильная очистка Choices.js

Ошибка:

choices.setChoices(data);
choices.setChoices(newData);

Старые элементы могут сохраниться.


Правильная очистка

choices.clearChoices();

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

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

В крупных проектах управление loader выносится в отдельный модуль.


Менеджер загрузки

class LoaderManager {

  constructor(element) {
    this.element = element;
    this.count = 0;
  }

  show() {

    this.count++;

    this.element.classList.remove('hidden');
  }

  hide() {

    this.count--;

    if (this.count <= 0) {

      this.count = 0;

      this.element.classList.add('hidden');
    }
  }
}

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

const manager = new LoaderManager(loader);

async function loadData() {

  manager.show();

  try {

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

    return await response.json();

  } finally {

    manager.hide();

  }
}

Интеграция с async/await

Наиболее удобная архитектура строится вокруг:

  • async/await;
  • try/catch/finally;
  • AbortController;
  • debounce;
  • централизованного loader manager.

Подобная комбинация обеспечивает:

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