Подключаем внешний API
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Пока приложение работает на своих выдуманных данных, оно живёт в тепличных условиях. Настоящий API отвечает с задержкой, иногда отвечает ошибкой, а иногда не отвечает вовсе — и интерфейс обязан пережить все три сценария, не показав пользователю пустой экран.
Запрос к чужому серверу в современном браузере выглядит короче, чем кажется: const res = await fetch(url) — и у нас есть ответ. Но ответ — это ещё не данные. fetch завершается, как только пришли заголовки: статус известен, а тело может продолжать загружаться. Поэтому за первым await почти всегда следует второй: const data = await res.json().
В песочнице учебника сети нет, поэтому дальше вместо fetch будет заглушка с тем же интерфейсом — объект с ok, status и методом json(). Функция, написанная на такой заглушке, без единой правки работает и с настоящим fetch: важна форма ответа, а не то, кто его выдал.
Всё, что попало в код страницы, видно пользователю. Ключ платного API, вшитый во фронтенд, — это уже утёкший ключ: браузер должен ходить на ваш собственный сервер, а тот — на чужой API.
Про то, что 404 — не ошибка, вы уже знаете: fetch не считает ошибкой ни 404, ни 500, потому что с его точки зрения запрос удался — сервер ответил, ответ доставлен. Промис отклоняется только тогда, когда ответа не было вовсе: нет сети, сорвалось соединение, запрос отменили. Поэтому try/catch вокруг fetch ловит меньше, чем вы думаете.
Лечится это одной строкой: проверить res.ok (он true для статусов 200–299) и превратить плохой статус в исключение самому. Дальше по коду будет ровно один путь для всех бед — catch.
Ответ приходит потоком: сначала статус и заголовки, потом байты тела. fetch отдаёт управление на первом же этапе, чтобы вы могли решить — читать тело или выбросить ответ, не скачивая мегабайты.
Тело можно прочитать только один раз и только одним способом: res.json(), res.text(), res.blob(). Второй вызов на том же ответе бросит ошибку — поток уже вычерпан.
Пока идёт запрос, у экрана нет данных — и это нормальное, а не аварийное состояние. Удобно держать в приложении одно поле status с тремя значениями: loading, error, ready. Тогда разметка становится честной функцией от состояния, а не набором разбросанных флагов вроде isLoading и hasError, которые рано или поздно окажутся включены одновременно.
Правило простое: перед запросом ставим loading, в try получаем данные и ставим ready, в catch — error с текстом. Ни одна ветка не остаётся без записи состояния, иначе спиннер останется крутиться навсегда.
Отмену запроса вы видели в статье про fetch; в проекте она из удобства становится обязательной — ниже видно, почему. Устройство напомним: создаём const controller = new AbortController(), кладём controller.signal в настройки запроса, второй аргумент fetch — fetch(url, { signal: controller.signal }) — и в любой момент зовём controller.abort(). Промис запроса тут же отклонится ошибкой с именем AbortError.
Важная деталь: отмена приходит в тот же catch, что и настоящие сбои. Но пользователю нечего показывать — он сам ушёл со страницы или начал новый поиск. Поэтому в обработчике ошибку по имени отделяют от остальных и молча выходят.
Пользователь печатает в поиске «Дуб», потом «Дубай». Уходят два запроса, и никто не обещал, что ответы вернутся в том же порядке: первый мог застрять в пути и прийти вторым. Экран покажет результаты по «Дуб», хотя в поле написано «Дубай». Ошибки нет, данные настоящие — просто устаревшие.
Два лекарства, и они дополняют друг друга. Первое — AbortController: перед новым запросом отменить предыдущий, чтобы он не тратил сеть. Второе — сторож на стороне кода: нумеровать запросы и применять только ответ с последним номером. Второе нужно даже с отменой, потому что ответ мог уже прилететь в момент отмены.
У fetch нет таймаута по умолчанию: запрос может висеть, пока браузер не сдастся сам. Ставьте предел явно — AbortSignal.timeout(5000) отменит запрос через пять секунд. Попадёте вы в тот же catch, что и при отмене, но имя у ошибки другое — TimeoutError, а не AbortError: молчать про таймаут как про отмену нельзя, человек ничего не отменял. Пять секунд молчания для пользователя — уже вечность, лучше показать ему повтор.
| Что сломалось | Как это видно в коде | Что показать пользователю |
|---|---|---|
| Нет сети | промис fetch отклонён, TypeError | «Нет связи» и кнопка «Повторить» |
| Сервер вернул 500 | res.ok равен false, статус 500 | «Сервис недоступен, попробуйте позже» |
| Ответ не JSON | res.json() бросает SyntaxError | «Не удалось прочитать ответ», подробности — в лог |
| Ответ слишком долгий | сработал таймаут, TimeoutError | «Долго не отвечает» и повтор |
| Запрос отменили сами | AbortError | ничего: это не ошибка пользователя |
Если у приложения есть чем прикрыться — прошлые данные, кеш, разумные значения по умолчанию — покажите их, но честно пометьте как устаревшие. Пустой экран хуже старой цифры с подписью «данные могли устареть».
Бить в упавший сервер каждые сто миллисекунд — верный способ добить его. Пауза между попытками удваивается, попыток обычно три, и повторять стоит только сетевые сбои и 5xx: на 404 повтор ничего не изменит.
fetchдаёт ответ в два шага: сначалаawait fetch(url)(статус и заголовки), потомawait res.json()(тело).- HTTP-ошибка для
fetchне ошибка: проверяйтеres.okи бросайте исключение сами, чтобы все беды сходились в одномcatch. - Держите одно поле состояния с тремя значениями — loading, error, ready — и рисуйте экран как функцию от него.
AbortControllerотменяет запрос, а нумерация запросов защищает от гонки: применяем только самый свежий ответ.- У
fetchнет таймаута по умолчанию — задайте его явно, а на случай недоступного API держите повтор и запасные данные с пометкой «устарели».
1/3Сервер ответил статусом 500. Что сделает await fetch(url)?
2/3Пользователь быстро правит строку поиска, ответы приходят вразнобой. Что окажется на экране, если ничего не предпринять?
3/3Что произойдёт с промисом fetch после вызова controller.abort()?
Загрузка с честными состояниями
Напишите loadUser(request): она получает промис ответа (в бою это был бы fetch(url)), проверяет res.ok, читает JSON и возвращает объект состояния — { status: "ready", data } при успехе и { status: "error", error } с текстом вида HTTP 500 при любой беде. Функция не должна бросать исключений наружу.
Весь риск заворачиваем в try/catch, а плохой статус превращаем в исключение сами — тогда обе беды сходятся в одной ветке.
Заметьте: catch ловит и сорванную сеть, и наш собственный throw — вызывающему коду не нужно знать, что именно пошло не так.
Сторож против устаревших ответов
Сделайте фабрику createLatest(): она возвращает функцию start(), каждый вызов которой выдаёт проверку isActual(). isActual() должна вернуть true только для самого свежего вызова start() и false для всех предыдущих.
Ожидаемое поведение: сделали const a = start(), потом const b = start(). Тогда a() — false, b() — true. Никаких таймеров: вся логика на одном счётчике в замыкании.
Счётчик живёт в замыкании фабрики, а каждая проверка помнит свой номер.
В настоящем коде isActual() вызывают сразу после await: если она вернула false, ответ просто выбрасывают и состояние не трогают.