JS: Promise, async/await и fetch

JS: Promise, async/await и fetch

В прошлом уроке мы поймали submit формы и отменили перезагрузку. Сейчас научимся реально отправить данные в твой backend и обработать ответ. Без <form action>, без перезагрузки - современный JS делает это через fetch.

Почему асинхронность

JavaScript однопоточный. Если в коде встретится «жди 3 секунды» в синхронном виде - страница замрёт целиком: не реагирует на клики, не прокручивается, не рендерит. Всё, что требует ожидания (сеть, диск, таймеры), в JS делается асинхронно: «запусти, продолжай работать дальше, когда придёт результат - вызови callback».

Бэкендер на Go из горутин и каналов привык думать о параллельности. В JS параллельности (в смысле многопоточности) на уровне языка нет. Есть конкурентность через event loop: задачи откладываются в очередь, основной поток подбирает их по очереди, пока свободен.

На практике это значит: HTTP-запрос не блокирует UI, но и не «работает параллельно» - он ждёт, пока сеть вернёт ответ, и потом дёргает обратно твой код.

Promise - обещание результата

Promise - объект, представляющий «результат, который будет позже». Три состояния: pending → fulfilled (есть значение) или rejected (есть ошибка).

Promise lifecycle: pending переходит в fulfilled или rejected, переход необратим

const promise = fetch('/api/v1/tracks');
// promise сейчас pending; станет fulfilled с объектом Response, когда сервер ответит

promise
  .then(response => {
    console.log('Получили ответ:', response.status);
    return response.json();         // .json() тоже возвращает Promise
  })
  .then(data => {
    console.log('Данные:', data);
  })
  .catch(error => {
    console.error('Ошибка сети:', error);
  });

.then подписывает callback на fulfilled, .catch - на rejected. Цепочка .then().then().catch() - типичная для старого кода, но читать её больно. Современный путь - async/await.

async/await - Promise, который читается как обычный код

async function loadTracks() {
  try {
    const response = await fetch('/api/v1/tracks');
    const data = await response.json();
    console.log('Данные:', data);
  } catch (error) {
    console.error('Ошибка сети:', error);
  }
}

loadTracks();

То же самое, но плоско. Правила:

  • await ставится перед выражением, которое возвращает Promise. Ждёт результата и подставляет его в переменную.
  • await работает только внутри async функции.
  • async function всегда возвращает Promise (даже если внутри return 42 - снаружи это будет Promise<42>).
  • Ошибки ловятся обычным try/catch.
В скриптах с `type="module"` (ES-модули) `await` можно писать без оборачивания в async - это top-level await. В обычных скриптах - только внутри async-функции.

Fetch - HTTP-запросы

Базовый GET:

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

POST с JSON-телом:

const response = await fetch('/api/v1/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: 'ivan@mail.ru',
    password: 'secret123',
  }),
});

const user = await response.json();

Что важно:

  • method по умолчанию 'GET'.
  • headers - объект; обязательно Content-Type: application/json при отправке JSON, иначе backend получит body, но не догадается, как его парсить.
  • body - строка; объект надо вручную через JSON.stringify.
  • await fetch(...) возвращает Response - это пока ещё не данные, а метаданные ответа.
  • await response.json() - отдельный await, который парсит тело как JSON. Тело можно прочитать только один раз; повторно - ошибка.

Главная грабля fetch: status

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

if (!response.ok) {
  // response.ok === true только для 2xx
  console.error('HTTP error:', response.status);
  return;
}

const user = await response.json();

fetch НЕ кидает исключение на 404, 401, 500. Только на сетевые ошибки (нет интернета, CORS, DNS не разрезолвился). Это разочаровывает: код в .catch сработает только на «вообще не доехало». Любые HTTP-ошибки сервера придут как обычный response, и их нужно проверять через response.ok или response.status.

Поток обработки fetch: catch ловит только обрыв сети, HTTP-ошибки проверяются через response.ok

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

  if (response.status === 404) {
    // обработай 404 явно
    return null;
  }

  if (!response.ok) {
    // 4xx (кроме 404) или 5xx
    const errorBody = await response.json().catch(() => ({}));
    throw new Error(`HTTP ${response.status}: ${errorBody.error || 'unknown'}`);
  }

  return await response.json();
} catch (error) {
  // сюда попадут: network errors + наши throw выше
  console.error(error);
}

Сквозной пример: отправка формы в backend

<form id="signup">
  <input name="email" type="email" required />
  <input name="password" type="password" required />
  <button type="submit">Регистрация</button>
  <div id="status" role="status"></div>
</form>
const form = document.querySelector('#signup');
const status = document.querySelector('#status');

form.addEventListener('submit', async (event) => {
  event.preventDefault();

  const data = Object.fromEntries(new FormData(form));
  status.textContent = 'Отправка...';

  try {
    const response = await fetch('/api/v1/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });

    if (response.status === 409) {
      status.textContent = 'Пользователь с таким email уже существует';
      return;
    }

    if (!response.ok) {
      const body = await response.json().catch(() => ({}));
      status.textContent = body.error || 'Не удалось зарегистрироваться';
      return;
    }

    const user = await response.json();
    status.textContent = `Привет, ${user.email}!`;
    form.reset();
  } catch (error) {
    status.textContent = 'Проблемы с сетью. Попробуй позже.';
    console.error(error);
  }
});

Это полноценная регистрация без перезагрузки страницы. Дёргаем твой backend, обрабатываем 409 (BUG-028 в действии), 4xx/5xx и сетевые ошибки.

Headers, cookies, авторизация

// JWT в Authorization
await fetch('/api/v1/users/me', {
  headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});

// Cookies браузер шлёт автоматически только в same-origin запросах.
// Для cross-origin (или явного контроля) - credentials:
await fetch('https://api.example.com/me', {
  credentials: 'include',   // 'omit' | 'same-origin' (default) | 'include'
});

credentials: 'include' нужен, например, для запросов с поддомена app.example.com на example.com - иначе cookie сессии не уйдёт.

Отмена запроса: AbortController

const controller = new AbortController();

const promise = fetch('/api/v1/search?q=' + query, {
  signal: controller.signal,
});

// Где-то ещё:
controller.abort();   // запрос отменится, fetch выкинет AbortError

Зачем это надо: пользователь набирает в поиске «react», потом меняет на «redux». Первый запрос ещё летит, второй уже летит. Если первый придёт после второго, пользователь увидит результаты для устаревшего запроса. Отмена решает.

Параллельные запросы

fetch возвращает Promise, и Promise'ы умеют ждаться скопом:

// Запустить три запроса параллельно, дождаться все:
const [user, tracks, progress] = await Promise.all([
  fetch('/api/v1/users/me').then(r => r.json()),
  fetch('/api/v1/tracks').then(r => r.json()),
  fetch('/api/v1/progress').then(r => r.json()),
]);

Promise.all - все или ничего: если один падает, весь массив падает. Для «дождись всех, неважно с каким результатом» - Promise.allSettled.

Грабли, которые экономят часы дебага

1. Забыл Content-Type

// Backend получит body, но не распарсит как JSON - поведёт себя странно.
await fetch('/api/v1/users', {
  method: 'POST',
  body: JSON.stringify({ email: 'x' }),
});

Без Content-Type: application/json сервер думает, что это text/plain или form-encoded.

2. JSON.stringify забыт

// body должна быть строкой, не объектом
await fetch('/api/v1/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: { email: 'x' },   // [object Object] придёт на сервер - баг
});

3. response.json() на пустом ответе

const response = await fetch('/api/v1/users/me', { method: 'DELETE' });
const data = await response.json();    // SyntaxError: Unexpected end of JSON input

DELETE часто возвращает 204 No Content - тело пустое. response.json() упадёт. Проверяй response.status или response.headers.get('content-length') перед парсингом.

4. CORS

В DevTools → Console:

Access to fetch at 'https://api.other.com/...' from origin 'https://example.com' has been blocked by CORS policy

CORS - браузер блокирует cross-origin запросы, если сервер не выставил Access-Control-Allow-Origin. Решение на стороне backend (твой Go-код в cors.go), не на стороне fetch. На своём API ты этим управляешь сам.

Что пропустили намеренно

  • XMLHttpRequest - старый API, заменён fetch. Встретится только в legacy.
  • WebSockets - для двусторонней связи (чаты, лента). Отдельная тема.
  • EventSource (Server-Sent Events) - push-уведомления от сервера.
  • Service Worker - для офлайна и push. PWA-фича, нишевая.
  • Race conditions в fetch - типа «пришёл старый ответ после нового». Решается AbortController + дисциплиной.

Мини-задание

  • Открой DevTools → Network на странице, где есть форма (например, твой /login)
  • Отправь её, посмотри в Network: метод, заголовки, body - что именно ушло
  • Возьми ту же форму с прошлого урока, добавь fetch на POST /api/v1/users и обработай 201/409/4xx/5xx
  • Поставь backend на паузу (docker stop backend-prod - не делай это на проде) и проверь, что fetch падает в .catch (или try/catch)
  • Сделай два параллельных fetch через Promise.all - например, /api/v1/tracks и /api/v1/users/me

На следующем уроке вылезем из браузера: Node.js - тот же JS, но без DOM. Зачем он бэкендеру и как с ним жить.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.