Сервер вернул 404, а catch не сработал. Такое поведение у Fetch ожидаемо. Соберём каталог, который различает ответ с ошибкой, недоступную сеть и неверный JSON, показывает загрузку и позволяет повторить запрос с клавиатуры.

В результате на странице будут четыре понятных состояния: ожидание, список товаров, пустой каталог и ошибка. Проверять их будем на собственном учебном сервере. Данные вымышленные; ни ключей API, ни регистрации не понадобится.

Если вы ещё не отправляли запросы, начните с базового разбора Fetch. Здесь займёмся тем, что происходит вокруг запроса в интерфейсе. Для повторной загрузки рекомендуем явную кнопку: бесконечный автоматический повтор мешает увидеть причину сбоя и продолжает обращаться к серверу.

Fetch: запрос и состояние интерфейсаПроверяем HTTP-статус и JSON. Ошибки ведут к кнопке повтора.Загрузкаloading = trueHTTP 2xxJSON прочитанОшибкаHTTP / сеть / JSONГотовый списокили пустой результатКнопка«Повторить»finally: снимаем loading при любом исходе.
Для HTTP-ошибки проверяем response.ok. Ошибка сети и неверный JSON тоже приводят к состоянию ошибки. В finally снимаем loading.

Подготовьте страницу и сервер

Понадобятся браузер, установленный Node.js и понимание функций, массивов и async/await. Создайте отдельную папку fetch-states. В ней будут index.html, app.js и server.mjs. Расширение .mjs позволяет использовать модули без дополнительной настройки проекта.

Начните с разметки. Сохраните её как index.html. Переключатель сценариев нужен только для упражнения: он даст управляемую ошибку без изменения кода при каждой проверке.

<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <title>Учебный каталог</title>
</head>
<body>
  <main>
    <h1>Каталог</h1>
    <label for="scenario">Сценарий ответа</label>
    <select id="scenario">
      <option value="ok">Товары</option>
      <option value="empty">Пустой список</option>
      <option value="404">Ошибка 404</option>
      <option value="json">Неверный JSON</option>
    </select>
    <button id="reload" type="button">Обновить каталог</button>
    <p id="status" role="status"></p>
    <ul id="products" aria-busy="false"></ul>
  </main>
  <script src="/app.js" defer></script>
</body>
</html>

Сообщение существует в разметке ещё до запроса. Роль status позволяет вспомогательным технологиям сообщать о его изменении без переноса фокуса. Такой способ описан в справке MDN о status. Проверка с конкретным экранным диктором всё равно нужна: сочетание браузера и диктора влияет на озвучивание.

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

// server.mjs
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';

const files = {
  '/': [await readFile(new URL('./index.html', import.meta.url)), 'text/html'],
  '/app.js': [await readFile(new URL('./app.js', import.meta.url)), 'text/javascript'],
};

createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1:3000');
  if (url.pathname === '/api/products') {
    const scenario = url.searchParams.get('case');
    setTimeout(() => {
      const code = scenario === '404' ? 404 : 200;
      const body = scenario === 'json' ? '{broken'
        : JSON.stringify(scenario === 'empty' ? []
          : [{ id: 1, name: 'Учебная лампа' }]);
      res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
      res.end(body);
    }, 800);
    return;
  }
  const file = files[url.pathname];
  res.writeHead(file ? 200 : 404, {
    'Content-Type': file ? file[1] + '; charset=utf-8' : 'text/plain',
  });
  res.end(file ? file[0] : 'Not found');
}).listen(3000, '127.0.0.1');

Задержка 800 миллисекунд даст время увидеть состояние загрузки. Страница и API работают на одном адресе http://127.0.0.1:3000, поэтому CORS не участвует в опыте. Откройте страницу по HTTP после запуска сервера; двойной щелчок по HTML-файлу для этой задачи не подходит.

Разделите получение ответа и чтение данных

У запроса есть несколько границ отказа. Сам вызов fetch() может завершиться отклонением промиса. Полученный HTTP-ответ может иметь неуспешный статус. Чтение тела тоже асинхронно: JSON бывает повреждённым, а соединение может оборваться во время получения данных.

Ответ со статусом 404 или 500 сам по себе не отклоняет промис Fetch. После await fetch() нужно проверить статус. Это поведение закреплено в руководстве MDN по Fetch. Свойство Response.ok истинно для диапазона 200–299.

Порядок важен. Сначала получаем Response, затем проверяем ok, после этого читаем JSON. В примере мы сами создадим ошибку для неуспешного статуса и пометим её категорией. По одному тексту error.message строить логику интерфейса не стоит: он предназначен для человека и может измениться.

Добавьте загрузку и повтор в app.js

Ниже весь клиентский файл. В нём один активный запрос, ограничение ожидания в пять секунд и отдельные сообщения для HTTP, формата и недоступного ответа. Сохраните код как app.js.

const button = document.querySelector('#reload');
const scenario = document.querySelector('#scenario');
const status = document.querySelector('#status');
const list = document.querySelector('#products');
let loading = false;

class CatalogError extends Error {
  constructor(kind, message) {
    super(message);
    this.kind = kind;
  }
}

async function loadProducts() {
  if (loading) return;
  loading = true;
  button.setAttribute('aria-disabled', 'true');
  list.setAttribute('aria-busy', 'true');
  list.replaceChildren();
  status.textContent = 'Загружаем каталог…';

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 5000);
  let phase = 'request';

  try {
    const query = new URLSearchParams({ case: scenario.value });
    const response = await fetch('/api/products?' + query, {
      signal: controller.signal,
    });
    if (!response.ok) {
      throw new CatalogError('http', 'Сервер вернул HTTP ' + response.status + '.');
    }
    phase = 'body';
    const products = await response.json();
    phase = 'validate';
    if (!Array.isArray(products) || products.some((product) =>
      !product || typeof product.name !== 'string')) {
      throw new CatalogError('format', 'Полученные данные не подходят каталогу.');
    }
    for (const product of products) {
      const item = document.createElement('li');
      item.textContent = product.name;
      list.append(item);
    }
    status.textContent = products.length
      ? 'Каталог загружен.' : 'В каталоге пока нет товаров.';
    button.textContent = 'Обновить каталог';
  } catch (error) {
    if (error.name === 'AbortError') {
      status.textContent = 'Не дождались ответа. Попробуйте повторить запрос.';
    } else if (error instanceof CatalogError) {
      status.textContent = error.message + ' Попробуйте повторить запрос.';
    } else if (phase === 'body' && error instanceof SyntaxError) {
      status.textContent = 'Сервер прислал неверный JSON. Попробуйте позже.';
    } else if (phase === 'request' || phase === 'body') {
      status.textContent = 'Ответ недоступен. Проверьте подключение и повторите запрос.';
    } else {
      status.textContent = 'Не удалось показать каталог. Попробуйте позже.';
    }
    console.error(error);
    button.textContent = 'Повторить загрузку';
  } finally {
    clearTimeout(timer);
    loading = false;
    button.setAttribute('aria-disabled', 'false');
    list.setAttribute('aria-busy', 'false');
  }
}

button.addEventListener('click', loadProducts);
loadProducts();

Атрибут aria-disabled сообщает вспомогательным технологиям, что кнопка временно недоступна. При этом до неё по-прежнему можно дойти клавишей Tab. Атрибут сам не блокирует нажатие. Эту часть делает проверка loading в начале функции, поэтому повторные клики во время запроса не создают параллельные загрузки.

После ошибки кнопка остаётся на странице. До неё можно дойти клавиатурой. Нажатие Enter запускает новый запрос. Мы не отправляем фокус к сообщению и не скрываем кнопку после удачного запроса.

Для вывода названий используем textContent. Данные API становятся текстом элемента; разметку из поля name клиент не выполняет.

Проверьте finally на каждом исходе

Блок finally снимает состояние загрузки независимо от успешного ответа или исключения. Если вернуть кнопку только в удачной ветке, после первой ошибки интерфейс останется заблокированным. Семантика блока описана в справочнике try…catch.

Таймер тоже очистится. Иначе он продолжит ждать после завершения запроса. В finally оставьте только очистку. Добавленный сюда return может изменить результат функции или скрыть выброшенную ошибку.

Команда запуска:

node server.mjs

Откройте http://127.0.0.1:3000, выберите сценарий и нажмите «Обновить каталог». В инструментах разработчика смотрите одновременно интерфейс и вкладку Network.

СценарийЧто ожидаемЧто проверяем
ТоварыHTTP 200, название лампыСообщение о загрузке сменилось успехом
Пустой списокHTTP 200, пустой массивПустота объяснена отдельным сообщением
Ошибка 404HTTP 404Сработала наша проверка ok
Неверный JSONHTTP 200, испорченное телоОшибка возникла при чтении JSON
Сервер остановленОтвет не полученКнопка снова доступна для повтора

Для последней строки сначала дождитесь загрузки страницы, затем остановите сервер сочетанием Ctrl+C в терминале. Нажмите кнопку на уже открытой странице. После нового запуска сервера повтор должен вернуть товар без обновления всей страницы.

Учтите границы диагностики

По исключению Fetch нельзя достоверно определить, пропал ли интернет. В другом проекте такой же недоступный ответ может вызвать CORS, ошибка сертификата или блокировка запроса. Сообщение пользователю поэтому осторожное: «Ответ недоступен». Точную причину разработчик ищет в консоли и Network.

JSON требует отдельной проверки. Статус 200 ещё не гарантирует данные нужной структуры. Метод Response.json() читает и разбирает тело; проверку полей товара после этого выполняет ваш код.

Наш каталог при повторе очищается. Для рабочего интерфейса с уже загруженными товарами часто полезнее оставить прежний список и сообщить, что обновить его пока не удалось. Тогда сохраните старые элементы до получения новых данных, а состояние ошибки обновления отличайте от ошибки первого запроса.

Доработайте каталог и переходите к проектному JavaScript

Самостоятельная задача: добавьте сценарий, который отвечает через шесть секунд. Проверьте сообщение о превышенном ожидании и возвращение кнопки в доступное состояние. Затем решите, как показать прежние товары при неудачном обновлении. Успех задачи можно проверить без чтения кода: список сохраняется, сообщение меняется, повтор работает.

Если функции, DOM и промисы уже знакомы, сверяйте следующий шаг с программой курса «JavaScript. Профессиональная разработка веб-интерфейсов». Перед выбором курса проверьте входные требования на его странице, а этот каталог сохраните как упражнение на состояния интерфейса.


«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.

ТелеграмПодкастБесплатные учебники