fetch: запросы к серверу
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Почти любое приложение рано или поздно идёт за данными на сервер. В браузере для этого есть fetch — функция, которая возвращает промис, спокойно переживает ответ 404 и умеет отменять сама себя.
fetch(url) отправляет запрос и возвращает промис. Промис выполняется не тогда, когда пришли данные, а тогда, когда пришли заголовки ответа — то есть сервер уже сказал «понял, отвечаю». Само тело ответа читается отдельно, и это второй промис. Отсюда два await подряд — самая узнаваемая деталь работы с fetch.
Объект response — это не данные, а сам ответ: код состояния, заголовки и обещание тела. Данные достаются методами, и метод выбирается по формату: json() для JSON, text() для HTML, CSV или просто строки.
| Что пишем | Что получаем |
|---|---|
| response.status | числовой код ответа: 200, 404, 500 |
| response.ok | true, если код в диапазоне 200–299 |
| response.headers.get("content-type") | значение заголовка ответа |
| await response.json() | тело, разобранное из JSON в объект |
| await response.text() | тело целиком как строка |
Тело ответа — поток: браузер отдаёт его по кусочкам и не хранит копию. Как только поток вычитан, повторно взять его неоткуда, и второй вызов json() завершится ошибкой. Если тело нужно в двух местах — сохраните результат в переменную или сделайте response.clone() до чтения.
Главная ловушка новичка: fetch не отвергает промис из-за плохого кода ответа. С точки зрения браузера всё прошло отлично — запрос ушёл, ответ вернулся. То, что в ответе написано «нет такой страницы», это уже смысл, а не техника.
404 и 500 — это успешно доставленные ответы «нет» и «мне плохо». fetch считает свою работу сделанной; понять, что ответ негодный, обязаны вы.
Поэтому проверка response.ok — обязательная строчка, а не хороший тон. Удобно спрятать её в маленькую обёртку и дальше пользоваться только ею: тогда провал сервера превращается в обычную ошибку промиса, которую ловит catch.
Второй аргумент fetch — объект настроек. Для отправки данных нужны три поля: method, headers с типом содержимого и body — уже готовая строка. Объект в body положить нельзя, его превращает в строку JSON.stringify.
Ответ на POST читается ровно так же, как на GET: сначала response, потом тело. Код 201 означает «создано» — и он тоже попадает в response.ok, потому что лежит в диапазоне 200–299.
В body можно положить строку, URLSearchParams, Blob или FormData. Последнее — для форм и файлов: FormData хранит список пар «имя — значение», его собирают из формы или заполняют методом append, а файлы в нём едут байтами. Такое тело уходит частями, разделёнными случайной служебной строкой, и заголовок Content-Type вместе с этой строкой браузер подставляет сам. Если написать заголовок вручную, разделителя в нём не окажется и сервер не разберёт тело. Подробно — в статье «FormData и отправка на сервер» в части II.
Промис fetch отвергается только тогда, когда запрос вообще не состоялся: нет интернета, домен не разрешился, сервер разорвал соединение или ответ запретил CORS. Ошибка приходит с именем TypeError, а текст сообщения у каждого браузера свой — опираться на него не стоит. Отменённый запрос тоже попадает в catch, но у его ошибки своё имя — о нём в конце статьи.
Рабочий шаблон складывается из двух проверок: try/catch вокруг запроса ловит проблемы сети, а response.ok — проблемы смысла. Пропустите любую из них — и приложение однажды покажет пользователю undefined вместо данных.
Запрос иногда становится не нужен: пользователь ушёл со страницы или успел напечатать в поиске новую букву. Отменяют запрос через AbortController: у контроллера есть signal, который передают в настройки fetch, и метод abort(). После отмены промис отвергается ошибкой с именем AbortError.
Для самого частого случая — «ждём не дольше N миллисекунд» — контроллер писать не обязательно: есть готовый AbortSignal.timeout(ms). По истечении срока запрос обрывается ошибкой TimeoutError. Какая из веток сработает в примере ниже, зависит от скорости сети — это честно непредсказуемо.
У любого чужого API вы столкнётесь с тремя вещами. Первая — параметры запроса. Склеивать их руками через ? и & опасно: пробелы и кириллица ломают адрес. Пусть этим займётся URL — fetch принимает такой объект напрямую.
Вторая вещь — ключ доступа: обычно он передаётся заголовком Authorization со значением вида Bearer ваш-токен. Третья — CORS: если чужой сервер не разрешил вашему адресу читать ответ, запрос упадёт в catch, а в консоли появится длинное объяснение про cross-origin. Это не чинится со стороны фронтенда — только настройками сервера или собственным прокси.
- fetch возвращает промис с объектом Response: первый await — заголовки, второй (json или text) — тело.
- Коды 404 и 500 не отвергают промис. Проверка response.ok обязательна в каждом запросе.
- POST собирается из method, headers с Content-Type и body — строки из JSON.stringify.
- В catch попадает только несостоявшийся запрос: нет сети, не разрешился домен, CORS или отмена.
- AbortController и его signal прерывают запрос ошибкой AbortError; для таймаута хватит AbortSignal.timeout(ms).
1/3Сервер ответил кодом 404. Что произойдёт с промисом от fetch?
2/3Зачем при работе с JSON нужны два await подряд?
3/3Что делает controller.abort() для уже запущенного fetch?
Обёртка getJSON с проверкой ответа
Напишите функцию getJSON(url), которая делает запрос, возвращает разобранный JSON при удачном ответе и бросает Error с текстом вида HTTP 404, если response.ok оказался ложным. Проверьте её на двух адресах — существующем и несуществующем.
Вся хитрость в одной строчке с if: без неё функция вернёт разобранное тело страницы ошибки и притворится, что всё хорошо.
Возвращать response.json() без await можно: async-функция сама дождётся вложенного промиса.
Загрузка с таймаутом
Напишите loadWithTimeout(url, ms): запрос отменяется, если сервер не ответил за ms миллисекунд. Используйте AbortController и setTimeout, а таймер обязательно погасите clearTimeout, когда ответ пришёл вовремя — иначе он выстрелит в пустоту.
Блок finally выполняется и при успехе, и при ошибке — идеальное место для уборки таймера.
Обратите внимание на return await: без await функция вышла бы из try раньше, чем тело дочитано, и таймер снялся бы слишком рано. Почти то же в одну строку даёт готовый AbortSignal.timeout(ms) — только имя ошибки у него своё, TimeoutError.