Клиент-серверное взаимодействие

Зачем нужно

Каждое SPA-приложение общается с сервером: загружает данные, отправляет формы, проверяет авторизацию. Понимание полного цикла запрос/ответ, форматов данных и паттернов обработки ошибок — основа клиент-серверной разработки.

Где используется

  • Любой запрос к API из браузера
  • Отправка форм (регистрация, оплата)
  • Загрузка/скачивание файлов
  • Real-time данные (polling, SSE, WebSocket)

Цикл запрос/ответ

Клиент (браузер)                    Сервер (API)
      │                                  │
      │── 1. DNS-резолвинг ──────────── │
      │── 2. TCP-соединение ─────────── │
      │── 3. TLS handshake (HTTPS) ──── │
      │                                  │
      │── 4. HTTP-запрос ──────────────→ │
      │   POST /api/users               │
      │   Content-Type: application/json │
      │   {"name": "Антон"}             │
      │                                  │
      │←─ 5. HTTP-ответ ──────────────── │
      │   201 Created                    │
      │   {"id": 1, "name": "Антон"}    │
      │                                  │

Fetch API

// === GET-запрос ===
const response = await fetch('/api/users');
const users = await response.json();

// === POST с JSON ===
const response = await fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Антон', email: 'a@mail.ru' }),
});

// === С авторизацией ===
const response = await fetch('/api/profile', {
  headers: { 'Authorization': `Bearer ${token}` },
});

// === С cookies ===
const response = await fetch('/api/profile', {
  credentials: 'include', // Отправить cookies
});

Форматы данных

JSON (основной)

// Отправка
const response = await fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'Антон',
    email: 'a@mail.ru',
    tags: ['developer', 'admin'],
  }),
});

// Получение
const data = await response.json();

FormData (файлы, формы)

// Загрузка файла
const formData = new FormData();
formData.append('avatar', fileInput.files[0]);
formData.append('name', 'Антон');

const response = await fetch('/api/upload', {
  method: 'POST',
  // НЕ ставить Content-Type — browser установит boundary автоматически
  body: formData,
});

// Из HTML-формы
const form = document.querySelector('form');
const formData = new FormData(form);

await fetch('/api/submit', {
  method: 'POST',
  body: formData,
});

URLSearchParams (query string)

// Отправка как x-www-form-urlencoded
const params = new URLSearchParams();
params.append('username', 'anton');
params.append('password', '12345');

await fetch('/api/login', {
  method: 'POST',
  body: params,
  // Content-Type: application/x-www-form-urlencoded — автоматически
});

API-клиент (обёртка над fetch)

class ApiClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
    this.token = null;
  }

  setToken(token) {
    this.token = token;
  }

  async request(endpoint, options = {}) {
    const url = `${this.baseURL}${endpoint}`;

    const config = {
      headers: {
        'Content-Type': 'application/json',
        ...(this.token && { Authorization: `Bearer ${this.token}` }),
        ...options.headers,
      },
      ...options,
    };

    if (config.body && typeof config.body === 'object'
        && !(config.body instanceof FormData)) {
      config.body = JSON.stringify(config.body);
    }

    // FormData — убрать Content-Type (browser установит сам)
    if (options.body instanceof FormData) {
      delete config.headers['Content-Type'];
    }

    const response = await fetch(url, config);

    // Обработка ошибок
    if (!response.ok) {
      const error = await response.json().catch( => ({}));
      const err = new Error(error.message || `HTTP ${response.status}`);
      err.status = response.status;
      err.data = error;
      throw err;
    }

    if (response.status === 204) return null;

    return response.json();
  }

  get(endpoint, params) {
    const query = params ? '?' + new URLSearchParams(params).toString() : '';
    return this.request(endpoint + query);
  }

  post(endpoint, body) {
    return this.request(endpoint, { method: 'POST', body });
  }

  put(endpoint, body) {
    return this.request(endpoint, { method: 'PUT', body });
  }

  patch(endpoint, body) {
    return this.request(endpoint, { method: 'PATCH', body });
  }

  delete(endpoint) {
    return this.request(endpoint, { method: 'DELETE' });
  }
}

// Использование
const api = new ApiClient('http://localhost:4000/api');
api.setToken(localStorage.getItem('token'));

const users = await api.get('/users', { page: 1, limit: 10 });
const user = await api.post('/users', { name: 'Антон' });
await api.patch('/users/42', { name: 'Новое имя' });
await api.delete('/users/42');

Паттерны обработки ошибок

// === Retry с exponential backoff ===
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      // Retry только для серверных ошибок
      if (response.status >= 500 && attempt < maxRetries) {
        const delay = Math.pow(2, attempt) * 1000;
        await new Promise(r => setTimeout(r, delay));
        continue;
      }

      return response;
    } catch (error) {
      // Сетевая ошибка
      if (attempt === maxRetries) throw error;
      const delay = Math.pow(2, attempt) * 1000;
      await new Promise(r => setTimeout(r, delay));
    }
  }
}

// === Abort Controller (отмена запроса) ===
const controller = new AbortController();

// Таймаут 5 секунд
const timeoutId = setTimeout( => controller.abort(), 5000);

try {
  const response = await fetch('/api/data', {
    signal: controller.signal,
  });
  clearTimeout(timeoutId);
  const data = await response.json();
} catch (err) {
  if (err.name === 'AbortError') {
    console.log('Запрос отменён');
  }
}

// === Отмена при уходе со страницы ===
// В SPA — отменять запросы при смене маршрута
let currentController = null;

function loadPage(url) {
  // Отменить предыдущий запрос
  currentController?.abort();
  currentController = new AbortController();

  return fetch(url, { signal: currentController.signal });
}

Loading / Error / Data паттерн

class DataLoader {
  constructor {
    this.state = {
      data: null,
      loading: false,
      error: null,
    };
  }

  async load(fetchFn) {
    this.state = { data: null, loading: true, error: null };
    this.render;

    try {
      const data = await fetchFn;
      this.state = { data, loading: false, error: null };
    } catch (error) {
      this.state = { data: null, loading: false, error: error.message };
    }

    this.render;
  }

  render {
    const container = document.getElementById('content');

    if (this.state.loading) {
      container.innerHTML = '<div class="spinner">Загрузка...</div>';
      return;
    }

    if (this.state.error) {
      container.innerHTML = `
        <div class="error">
          <p>Ошибка: ${this.state.error}</p>
          <button onclick="loader.retry">Повторить</button>
        </div>
      `;
      return;
    }

    // Рендер данных
    container.innerHTML = JSON.stringify(this.state.data, null, 2);
  }
}

Частые ошибки

  1. Не проверяют response.ok — fetch не бросает ошибку при 4xx/5xx
  2. Content-Type для FormData — нельзя ставить вручную, browser добавит boundary
  3. Нет отмены запросов — при быстрой навигации приходят ответы от старых запросов
  4. Нет retry — один таймаут = приложение «сломалось»
  5. Нет loading state — пользователь не понимает, загружаются данные или нет
  6. Забывают JSON.stringify — body: { name: 'Антон' } → отправляется [object Object]

Практика

  1. Написать API-клиент с методами get/post/put/patch/delete
  2. Добавить обработку ошибок с retry и abort
  3. Реализовать загрузку файла через FormData
  4. Реализовать паттерн loading/error/data для отображения
  5. Добавить interceptor для автоматического refresh token при 401

Связанные темы

Ресурсы