Обработка ошибок загрузки

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

Типичные категории ошибок:

  • отсутствие соединения с сервером;
  • неверный URL API;
  • HTTP-ошибки (404, 500, 403);
  • получение некорректного JSON;
  • пустой ответ сервера;
  • слишком медленная загрузка;
  • отмена предыдущего запроса;
  • превышение лимитов API;
  • ошибки CORS;
  • повреждённая структура данных для Choices.js.

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


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

Чаще всего Choices.js используется совместно с fetch() для получения данных с сервера.

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

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

const choices = new Choices(element, {
  searchEnabled: true,
  shouldSort: false
});

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

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

loadUsers();

В подобной реализации отсутствует защита от ошибок. Любая проблема приведёт к необработанному исключению.


Перехват ошибок через try/catch

Главный механизм защиты асинхронного кода — конструкция try/catch.

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

    const data = await response.json();

    choices.setChoices(
      data,
      'value',
      'label',
      true
    );

  } catch (error) {
    console.error(error);
  }
}

Теперь ошибки не прерывают выполнение приложения полностью.


Проверка HTTP-статуса ответа

fetch() не выбрасывает исключение при статусах 404 или 500. Необходимо вручную проверять свойство response.ok.

Неправильный вариант:

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

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

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

if (!response.ok) {
  throw new Error(`HTTP Error: ${response.status}`);
}

const data = await response.json();

Это особенно важно при интеграции с REST API.


Вывод ошибок пользователю

Ошибка не должна оставаться только в консоли браузера. Пользовательский интерфейс обязан сообщать о проблеме.

Простейший вариант

<div id="error"></div>
const errorBlock = document.querySelector('#error');

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

    if (!response.ok) {
      throw new Error('Ошибка загрузки');
    }

    const data = await response.json();

    choices.setChoices(
      data,
      'value',
      'label',
      true
    );

    errorBlock.textContent = '';

  } catch (error) {
    errorBlock.textContent = error.message;
  }
}

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

Даже при статусе 200 сервер может вернуть повреждённый JSON.

Пример проблемного ответа:

{
  "users": [

Вызов response.json() завершится ошибкой парсинга.

Безопасный вариант:

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

  const text = await response.text();

  const data = JSON.parse(text);

} catch (error) {
  console.error('Ошибка JSON:', error);
}

Подобный подход полезен при работе с нестабильными API.


Проверка структуры данных

Choices.js ожидает определённый формат данных. Если API возвращает неправильную структуру, компонент не сможет отобразить элементы.

Ожидаемый формат:

[
  {
    value: 1,
    label: 'Admin'
  }
]

Проверка структуры:

if (!Array.isArray(data)) {
  throw new Error('Ответ должен быть массивом');
}

Проверка элементов:

data.forEach(item => {
  if (!('value' in item) || !('label' in item)) {
    throw new Error('Некорректная структура элемента');
  }
});

Обработка пустого ответа

API может вернуть пустой массив.

[]

Choices.js не выдаёт ошибку в такой ситуации, однако пользователь увидит пустой список без объяснений.

Решение:

if (data.length === 0) {
  errorBlock.textContent = 'Данные отсутствуют';
  return;
}

Таймаут запросов

fetch() не поддерживает таймауты автоматически. При медленном сервере интерфейс может зависнуть на неопределённое время.

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

async function loadUsers() {
  const controller = new AbortController();

  const timeout = setTimeout(() => {
    controller.abort();
  }, 5000);

  try {
    const response = await fetch('/api/users', {
      signal: controller.signal
    });

    clearTimeout(timeout);

    const data = await response.json();

    choices.setChoices(
      data,
      'value',
      'label',
      true
    );

  } catch (error) {
    if (error.name === 'AbortError') {
      console.error('Превышено время ожидания');
    }
  }
}

Повторные попытки загрузки

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

Простая реализация retry

async function fetchWithRetry(url, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url);

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

      return await response.json();

    } catch (error) {
      if (i === retries - 1) {
        throw error;
      }
    }
  }
}

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

try {
  const data = await fetchWithRetry('/api/users');

  choices.setChoices(
    data,
    'value',
    'label',
    true
  );

} catch (error) {
  console.error(error);
}

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

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

Проблемы:

  • перегрузка API;
  • гонки запросов;
  • отображение устаревших результатов.

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

let controller;

async function searchUsers(query) {

  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,
      'value',
      'label',
      true
    );

  } catch (error) {

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

  }
}

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

Choices.js часто используется вместе с удалённым поиском.

Пример интеграции:

element.addEventListener('search', async (event) => {

  try {

    const response = await fetch(
      `/api/search?q=${event.detail.value}`
    );

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

    const data = await response.json();

    choices.clearChoices();

    choices.setChoices(
      data,
      'value',
      'label',
      true
    );

  } catch (error) {
    console.error(error);
  }

});

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

Отсутствие визуального индикатора создаёт ощущение зависания интерфейса.

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

let isLoading = false;

async function loadUsers() {

  if (isLoading) {
    return;
  }

  isLoading = true;

  try {

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

    const data = await response.json();

    choices.setChoices(
      data,
      'value',
      'label',
      true
    );

  } catch (error) {

    console.error(error);

  } finally {

    isLoading = false;

  }
}

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

<div id="loader">Загрузка...</div>
const loader = document.querySelector('#loader');

async function loadUsers() {

  loader.style.display = 'block';

  try {

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

    const data = await response.json();

    choices.setChoices(
      data,
      'value',
      'label',
      true
    );

  } catch (error) {

    console.error(error);

  } finally {

    loader.style.display = 'none';

  }
}

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

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

catch (error) {

  choices.clearChoices();

  errorBlock.textContent = 'Ошибка обновления данных';

}

Обработка ошибок авторизации

Многие API требуют токен доступа.

Пример:

const response = await fetch('/api/users', {
  headers: {
    Authorization: `Bearer ${token}`
  }
});

Обработка 401 Unauthorized:

if (response.status === 401) {
  throw new Error('Требуется авторизация');
}

Обработка 403 Forbidden:

if (response.status === 403) {
  throw new Error('Доступ запрещён');
}

Работа с CORS-ошибками

Если сервер не разрешает междоменные запросы, браузер блокирует запрос ещё до получения ответа.

Типичная ошибка:

Access to fetch has been blocked by CORS policy

На стороне клиента исправить проблему невозможно. Необходима настройка серверных заголовков:

Access-Control-Allow-Origin: *

Или:

Access-Control-Allow-Origin: https://example.com

Логирование ошибок

Для диагностики необходимо фиксировать подробности ошибок.

catch (error) {

  console.error({
    message: error.message,
    stack: error.stack,
    time: new Date().toISOString()
  });

}

Централизованный обработчик загрузки

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

Универсальный загрузчик

async function loadChoicesData(url, choicesInstance) {

  try {

    const response = await fetch(url);

    if (!response.ok) {
      throw new Error(`Ошибка ${response.status}`);
    }

    const data = await response.json();

    if (!Array.isArray(data)) {
      throw new Error('Неверный формат данных');
    }

    choicesInstance.clearChoices();

    choicesInstance.setChoices(
      data,
      'value',
      'label',
      true
    );

  } catch (error) {

    choicesInstance.clearChoices();

    console.error(error);

  }

}

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

loadChoicesData('/api/users', choices);

Кэширование успешных данных

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

let cachedData = [];

async function loadUsers() {

  try {

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

    const data = await response.json();

    cachedData = data;

    choices.setChoices(
      data,
      'value',
      'label',
      true
    );

  } catch (error) {

    choices.setChoices(
      cachedData,
      'value',
      'label',
      true
    );

  }
}

Дебаунс запросов

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

Реализация debounce

function debounce(callback, delay = 300) {

  let timeout;

  return (...args) => {

    clearTimeout(timeout);

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

  };

}

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

const debouncedSearch = debounce(searchUsers, 500);

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

Обработка нестандартных ответов API

Некоторые серверы возвращают ошибки внутри JSON.

Пример:

{
  "success": false,
  "message": "Limit exceeded"
}

Проверка:

if (data.success === false) {
  throw new Error(data.message);
}

Защита от повреждённых данных

Иногда API возвращает null или неполные записи.

Безопасная фильтрация:

const normalized = data
  .filter(item => item)
  .filter(item => item.id && item.name)
  .map(item => ({
    value: item.id,
    label: item.name
  }));

Обработка сетевого отключения

Браузер позволяет определить потерю сети.

window.addEventListener('offline', () => {
  console.error('Интернет-соединение потеряно');
});

Возврат соединения:

window.addEventListener('online', () => {
  loadUsers();
});

Использование fallback-данных

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

const fallbackData = [
  {
    value: 1,
    label: 'Guest'
  }
];

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

catch (error) {

  choices.setChoices(
    fallbackData,
    'value',
    'label',
    true
  );

}

Комплексный пример устойчивой загрузки

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

const choices = new Choices(element);

const errorBlock = document.querySelector('#error');

let controller;

async function loadUsers(query = '') {

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

  controller = new AbortController();

  errorBlock.textContent = '';

  try {

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

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    const data = await response.json();

    if (!Array.isArray(data)) {
      throw new Error('Некорректный формат');
    }

    choices.clearChoices();

    choices.setChoices(
      data,
      'value',
      'label',
      true
    );

  } catch (error) {

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

    choices.clearChoices();

    errorBlock.textContent = error.message;

    console.error(error);

  }

}

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